@@ -1,178 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Button, Form, Input, Modal, Popconfirm, Space, Table, Tag, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { StoreBankAccountDto } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type Props = { storeId: string };
|
||||
|
||||
type FormValues = { bankAccountName: string; bankAccountNo: string; bankBranch?: string };
|
||||
|
||||
export default function StoreBankAccountsPanel({ storeId }: Props) {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [items, setItems] = useState<StoreBankAccountDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<StoreBankAccountDto[]>(`/admin/stores/${storeId}/bank-accounts`);
|
||||
setItems(res ?? []);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [storeId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function openCreate() {
|
||||
setEditingId(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(item: StoreBankAccountDto) {
|
||||
setEditingId(item.id);
|
||||
form.setFieldsValue({
|
||||
bankAccountName: item.bankAccountName,
|
||||
bankAccountNo: item.bankAccountNo,
|
||||
bankBranch: item.bankBranch ?? '',
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const v = await form.validateFields();
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const body = JSON.stringify({
|
||||
bankAccountName: v.bankAccountName.trim(),
|
||||
bankAccountNo: v.bankAccountNo.replace(/\s+/g, ''),
|
||||
bankBranch: v.bankBranch?.trim() || undefined,
|
||||
});
|
||||
if (editingId) {
|
||||
await request(`/admin/stores/${storeId}/bank-accounts/${editingId}`, { method: 'PUT', body });
|
||||
} else {
|
||||
await request(`/admin/stores/${storeId}/bank-accounts`, { method: 'POST', body });
|
||||
}
|
||||
message.success('已保存');
|
||||
setModalOpen(false);
|
||||
void load();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault(id: string) {
|
||||
try {
|
||||
await request(`/admin/stores/${storeId}/bank-accounts/${id}/default`, { method: 'POST' });
|
||||
message.success('已设为默认打款账户');
|
||||
void load();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '设置失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
try {
|
||||
await request(`/admin/stores/${storeId}/bank-accounts/${id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
void load();
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<StoreBankAccountDto> = [
|
||||
{ title: '收款人', dataIndex: 'bankAccountName', width: 140 },
|
||||
{ title: '银行账号', dataIndex: 'bankAccountNo', width: 180 },
|
||||
{ title: '开户行', dataIndex: 'bankBranch', render: (v) => v || '—' },
|
||||
{
|
||||
title: '默认',
|
||||
dataIndex: 'isDefault',
|
||||
width: 80,
|
||||
render: (v) => (v ? <Tag color="green">默认</Tag> : null),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_, row) => (
|
||||
<Space size={0} wrap>
|
||||
<Button type="link" size="small" onClick={() => openEdit(row)}>编辑</Button>
|
||||
{!row.isDefault ? (
|
||||
<Button type="link" size="small" onClick={() => void setDefault(row.id)}>设为默认</Button>
|
||||
) : null}
|
||||
{!row.isDefault ? (
|
||||
<Popconfirm
|
||||
title="确认删除该收款账户?"
|
||||
okText="删除"
|
||||
okButtonProps={{ danger: true }}
|
||||
onConfirm={() => void remove(row.id)}
|
||||
>
|
||||
<Button type="link" size="small" danger>删除</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 12, width: '100%', justifyContent: 'space-between' }}>
|
||||
<span style={{ color: '#888' }}>
|
||||
每门店可维护多个收款账户,标记「默认」的账户用于核销结算与提现打款;切换即时生效、无需重启服务。
|
||||
</span>
|
||||
<Button type="primary" size="small" onClick={openCreate}>新增账户</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
/>
|
||||
<Modal
|
||||
title={editingId ? '编辑收款账户' : '新增收款账户'}
|
||||
open={modalOpen}
|
||||
confirmLoading={submitting}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void submit()}
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="bankAccountName"
|
||||
label="收款人"
|
||||
rules={[{ required: true, message: '请填写收款人' }]}
|
||||
>
|
||||
<Input maxLength={64} placeholder="户名 / 收款人姓名" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bankAccountNo"
|
||||
label="银行账号"
|
||||
rules={[
|
||||
{ required: true, message: '请填写银行账号' },
|
||||
{ pattern: /^\d{8,32}$/, message: '请填写 8~32 位数字账号' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={32} placeholder="请输入银行账号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行名称">
|
||||
<Input maxLength={128} placeholder="如 中国工商银行郑州分行" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,7 +86,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
||||
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
||||
{ key: '/finance/logistics-bills', label: '物流对账' },
|
||||
{ key: '/finance/bank-accounts', label: '银行账户' },
|
||||
{ key: '/finance/bank-accounts', label: '全部银行账户' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button,
|
||||
Form,
|
||||
@@ -24,6 +25,7 @@ import { downloadBase64File } from '../lib/exportExcel';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
type CityOption = { id: string; name: string };
|
||||
|
||||
@@ -55,6 +57,7 @@ function otherNumericId(id: string) {
|
||||
}
|
||||
|
||||
export default function BankAccountsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [filterForm] = Form.useForm<{
|
||||
type?: FinanceBankAccountType;
|
||||
cityId?: string;
|
||||
@@ -198,17 +201,42 @@ export default function BankAccountsPage() {
|
||||
<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: 'ownerName',
|
||||
width: 180,
|
||||
render: (name: string, row) => (
|
||||
<AdminPrimaryLink
|
||||
onClick={() => {
|
||||
if (row.type === 'STORE' && row.ownerId) {
|
||||
navigate(`/stores?storeId=${encodeURIComponent(row.ownerId)}&tab=settlement`);
|
||||
return;
|
||||
}
|
||||
if (row.type === 'WINERY') {
|
||||
navigate('/system-settings?group=winery_bank');
|
||||
return;
|
||||
}
|
||||
if (row.type === 'PARTNER' && row.ownerId) {
|
||||
navigate(`/city-partners?partnerId=${encodeURIComponent(row.ownerId)}`);
|
||||
return;
|
||||
}
|
||||
if (row.type === 'LOGISTICS' && row.ownerId) {
|
||||
navigate(`/fulfillment-providers?id=${encodeURIComponent(row.ownerId)}`);
|
||||
return;
|
||||
}
|
||||
if (row.type === 'OTHER') {
|
||||
openEdit(row);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</AdminPrimaryLink>
|
||||
),
|
||||
},
|
||||
{ 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: 'remark', width: 200, render: (v?: string | null) => v || '—' },
|
||||
{
|
||||
title: '操作',
|
||||
@@ -251,8 +279,8 @@ export default function BankAccountsPage() {
|
||||
<div>
|
||||
{settingsModal}
|
||||
<AdminListHeader
|
||||
title="银行账户"
|
||||
description="汇总门店、酒厂、合伙人、物流的有效收款账户;可登记不挂门店的其他账户。打款仍以各业务源账户为准。"
|
||||
title="全部银行账户"
|
||||
description="汇总门店结算资质、酒厂、合伙人、物流的有效银行账户;可登记不挂门店的其他账户。打款仍以各业务源账户为准。"
|
||||
settings={settingsButton}
|
||||
actions={
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
@@ -287,7 +315,7 @@ export default function BankAccountsPage() {
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="keyword" label="查找">
|
||||
<Input allowClear style={{ width: 220 }} placeholder="户名 / 账号 / 开户行 / 归属 / 备注" />
|
||||
<Input allowClear style={{ width: 220 }} placeholder="户名 / 账号 / 开户银行 / 归属 / 备注" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
@@ -361,7 +389,7 @@ export default function BankAccountsPage() {
|
||||
>
|
||||
<Input maxLength={32} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行">
|
||||
<Form.Item name="bankBranch" label="开户银行">
|
||||
<Input maxLength={128} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
@@ -131,6 +131,8 @@ export default function CityPartnersPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialCityId = searchParams.get('cityId')?.trim() || '';
|
||||
const initialPartnerId = searchParams.get('partnerId')?.trim() || '';
|
||||
const deepLinkPartnerOpenedRef = useRef(false);
|
||||
const [filterForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
@@ -213,6 +215,14 @@ export default function CityPartnersPage() {
|
||||
return d;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialPartnerId || deepLinkPartnerOpenedRef.current) return;
|
||||
deepLinkPartnerOpenedRef.current = true;
|
||||
void openPartner(initialPartnerId).catch((e) => {
|
||||
message.error(e instanceof Error ? e.message : '合伙人详情加载失败');
|
||||
});
|
||||
}, [initialPartnerId]);
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -52,8 +53,11 @@ const SIGN_OPTIONS = [
|
||||
const DEFAULT_XFX_API_URL = 'https://beta.51xiaoju.cn/app/api/interface.do';
|
||||
|
||||
export default function FulfillmentProvidersPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialId = searchParams.get('id')?.trim() || '';
|
||||
const deepLinkOpenedRef = useRef(false);
|
||||
const [rows, setRows] = useState<FulfillmentProviderDto[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editRow, setEditRow] = useState<FulfillmentProviderDto | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
@@ -132,6 +136,13 @@ export default function FulfillmentProvidersPage() {
|
||||
setOpen(true);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialId || deepLinkOpenedRef.current || loading) return;
|
||||
const row = rows.find((r) => String(r.id) === initialId);
|
||||
deepLinkOpenedRef.current = true;
|
||||
if (row) openEdit(row);
|
||||
}, [initialId, loading, rows]);
|
||||
|
||||
async function submit() {
|
||||
const v = await form.validateFields();
|
||||
const payload: Record<string, unknown> = {
|
||||
|
||||
@@ -51,7 +51,6 @@ import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
||||
import AdminStorePackagesSection, {
|
||||
type AdminStorePackagesHandle,
|
||||
} from '../components/AdminStorePackagesSection';
|
||||
import StoreBankAccountsPanel from '../components/StoreBankAccountsPanel';
|
||||
import StorePackageAuditPanel, {
|
||||
auditStorePackageRequest,
|
||||
} from '../components/StorePackageAuditPanel';
|
||||
@@ -257,6 +256,7 @@ export default function StoresPage() {
|
||||
const initialPartnerId = searchParams.get('partnerId') ?? '';
|
||||
const initialAuditStatus = searchParams.get('auditStatus') ?? '';
|
||||
const initialStoreId = searchParams.get('storeId') ?? '';
|
||||
const initialTab = searchParams.get('tab') ?? '';
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm<StoreCreateForm>();
|
||||
@@ -477,13 +477,13 @@ export default function StoresPage() {
|
||||
const row = data?.items?.find((s) => String(s.id) === initialStoreId);
|
||||
if (row) {
|
||||
deepLinkStoreOpenedRef.current = true;
|
||||
void openStoreDetail(row);
|
||||
void openStoreDetail(row, { tab: initialTab || undefined });
|
||||
} else if (data && (data.items?.length ?? 0) >= 0) {
|
||||
// 列表无该店时仍尝试直拉详情
|
||||
deepLinkStoreOpenedRef.current = true;
|
||||
void openStoreDetail({ id: initialStoreId } as StoreRow);
|
||||
void openStoreDetail({ id: initialStoreId } as StoreRow, { tab: initialTab || undefined });
|
||||
}
|
||||
}, [data, initialStoreId, loading]);
|
||||
}, [data, initialStoreId, initialTab, loading]);
|
||||
|
||||
async function saveStoreDetail() {
|
||||
if (!detail) return;
|
||||
@@ -1277,14 +1277,14 @@ export default function StoresPage() {
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bankAccountNo"
|
||||
label="银行卡号"
|
||||
label="银行账号"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
const v = String(value || '').trim();
|
||||
if (!v) return;
|
||||
if (!/^\d{16,19}$/.test(v)) {
|
||||
throw new Error('银行卡号须为 16–19 位数字');
|
||||
throw new Error('银行账号须为 16–19 位数字');
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -1298,14 +1298,6 @@ export default function StoresPage() {
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'bank-accounts',
|
||||
label: '收款账户',
|
||||
forceRender: true,
|
||||
children: (
|
||||
<StoreBankAccountsPanel storeId={String(detail.id)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'media',
|
||||
label: '审核材料',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -159,6 +159,9 @@ function renderField(
|
||||
|
||||
export default function SystemSettingsPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const groupKey = searchParams.get('group')?.trim() || '';
|
||||
const [activeKeys, setActiveKeys] = useState<string[]>(() => (groupKey ? [groupKey] : []));
|
||||
const [form] = Form.useForm<Record<string, string>>();
|
||||
const [meta, setMeta] = useState<SystemConfigFormResponse | null>(null);
|
||||
const [profile, setProfile] = useState<HqProfile | null>(null);
|
||||
@@ -327,13 +330,30 @@ export default function SystemSettingsPage() {
|
||||
|
||||
return {
|
||||
key: group.key,
|
||||
label: group.label,
|
||||
label: <span id={`sys-setting-group-${group.key}`}>{group.label}</span>,
|
||||
forceRender: true,
|
||||
children,
|
||||
};
|
||||
});
|
||||
}, [meta, mockSmsEnabled, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (groupKey) {
|
||||
setActiveKeys((prev) => (prev.includes(groupKey) ? prev : [...prev, groupKey]));
|
||||
}
|
||||
}, [groupKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupKey || loading) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
document.getElementById(`sys-setting-group-${groupKey}`)?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
}, 80);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [groupKey, loading]);
|
||||
|
||||
async function onSave() {
|
||||
await form.validateFields();
|
||||
const values = form.getFieldsValue(true);
|
||||
@@ -432,7 +452,11 @@ export default function SystemSettingsPage() {
|
||||
|
||||
<Card loading={loading}>
|
||||
<Form form={form} layout="vertical" onValuesChange={() => setDirty(true)}>
|
||||
<Collapse defaultActiveKey={[]} items={collapseItems} />
|
||||
<Collapse
|
||||
activeKey={activeKeys}
|
||||
onChange={(keys) => setActiveKeys(Array.isArray(keys) ? keys : [keys])}
|
||||
items={collapseItems}
|
||||
/>
|
||||
</Form>
|
||||
{meta?.updatedAt ? (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginTop: 16 }}>
|
||||
|
||||
@@ -13,7 +13,6 @@ import StatusPage from './pages/StatusPage';
|
||||
import MinePage from './pages/MinePage';
|
||||
import StaffPage from './pages/StaffPage';
|
||||
import WithdrawPage from './pages/WithdrawPage';
|
||||
import BankAccountsPage from './pages/BankAccountsPage';
|
||||
import PackagesPage from './pages/PackagesPage';
|
||||
|
||||
export default function App() {
|
||||
@@ -26,7 +25,6 @@ export default function App() {
|
||||
<Route path="/select-store" element={<SelectStorePage />} />
|
||||
<Route path="/staff" element={<StaffPage />} />
|
||||
<Route path="/withdraw" element={<WithdrawPage />} />
|
||||
<Route path="/bank-accounts" element={<BankAccountsPage />} />
|
||||
<Route path="/packages" element={<PackagesPage />} />
|
||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||
|
||||
@@ -1,252 +0,0 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import type { StoreBankAccountDto } from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||
import { useStorePageView } from '../lib/usePageView';
|
||||
|
||||
type FormState = {
|
||||
id?: string;
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
};
|
||||
|
||||
const EMPTY_FORM: FormState = { bankAccountName: '', bankAccountNo: '', bankBranch: '' };
|
||||
|
||||
export default function BankAccountsPage() {
|
||||
useStorePageView('store_bank_accounts_view');
|
||||
const navigate = useNavigate();
|
||||
const { store } = useStoreSession();
|
||||
const isPrimary = !!store?.isPrimary;
|
||||
const [items, setItems] = useState<StoreBankAccountDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<keyof FormState, string>>>({});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await request<StoreBankAccountDto[]>('SHOP_H5', '/shop/store/bank-accounts');
|
||||
setItems(res ?? []);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
function startCreate() {
|
||||
setForm(EMPTY_FORM);
|
||||
setFieldErrors({});
|
||||
}
|
||||
|
||||
function startEdit(item: StoreBankAccountDto) {
|
||||
setForm({
|
||||
id: item.id,
|
||||
bankAccountName: item.bankAccountName,
|
||||
bankAccountNo: item.bankAccountNo,
|
||||
bankBranch: item.bankBranch ?? '',
|
||||
});
|
||||
setFieldErrors({});
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const name = form.bankAccountName.trim();
|
||||
const no = form.bankAccountNo.replace(/\s+/g, '');
|
||||
const branch = form.bankBranch.trim();
|
||||
const next: Partial<Record<keyof FormState, string>> = {};
|
||||
if (!name) next.bankAccountName = '请填写收款人';
|
||||
if (!/^\d{8,32}$/.test(no)) next.bankAccountNo = '请填写 8~32 位数字账号';
|
||||
setFieldErrors(next);
|
||||
if (Object.keys(next).length) return;
|
||||
|
||||
setSubmitting(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const body = JSON.stringify({ bankAccountName: name, bankAccountNo: no, bankBranch: branch });
|
||||
if (form.id) {
|
||||
await request('SHOP_H5', `/shop/store/bank-accounts/${form.id}`, {
|
||||
method: 'PUT',
|
||||
body,
|
||||
});
|
||||
} else {
|
||||
await request('SHOP_H5', '/shop/store/bank-accounts', { method: 'POST', body });
|
||||
}
|
||||
setForm(EMPTY_FORM);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function setDefault(id: string) {
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', `/shop/store/bank-accounts/${id}/default`, { method: 'POST' });
|
||||
await load();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '设置失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', `/shop/store/bank-accounts/${id}`, { method: 'DELETE' });
|
||||
await load();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
}
|
||||
|
||||
const editing = !!form.id;
|
||||
|
||||
return (
|
||||
<div className="shop-records-page">
|
||||
<header className="shop-records-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-withdraw-back"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label="返回"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>arrow_back</span>
|
||||
</button>
|
||||
<h1 className="app-page-title" style={{ margin: 0 }}>收款账户</h1>
|
||||
</header>
|
||||
|
||||
<div className="shop-records-main">
|
||||
<p
|
||||
className="shop-records-summary-note"
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
用于总部打款,可维护多个账户;标记「默认」的账户用于提现打款,切换即时生效、无需重启。
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<p className="shop-records-empty">加载中…</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="shop-records-empty">暂无收款账户{isPrimary ? ',请新增' : ''}</p>
|
||||
) : (
|
||||
<div className="shop-records-list">
|
||||
{items.map((it) => (
|
||||
<article key={it.id} className="shop-record-card">
|
||||
<div className="shop-record-card-top">
|
||||
<div>
|
||||
<div className="shop-record-order">
|
||||
<span className="shop-record-time" style={{ margin: 0 }}>收款人</span>
|
||||
<span>{it.bankAccountName}</span>
|
||||
{it.isDefault ? (
|
||||
<span className="shop-record-badge paid">默认</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="shop-record-time">账号: {it.bankAccountNo}</p>
|
||||
{it.bankBranch ? <p className="shop-record-time">开户行: {it.bankBranch}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
{isPrimary ? (
|
||||
<div className="shop-record-footer" style={{ display: 'flex', gap: 12 }}>
|
||||
<button type="button" className="shop-records-chip" onClick={() => startEdit(it)}>
|
||||
编辑
|
||||
</button>
|
||||
{!it.isDefault ? (
|
||||
<button type="button" className="shop-records-chip" onClick={() => void setDefault(it.id)}>
|
||||
设为默认
|
||||
</button>
|
||||
) : null}
|
||||
{!it.isDefault ? (
|
||||
<button type="button" className="shop-records-chip" onClick={() => void remove(it.id)}>
|
||||
删除
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isPrimary ? (
|
||||
<section className="shop-records-summary" style={{ marginTop: 16 }}>
|
||||
<h3 className="shop-records-list-title">
|
||||
{editing ? '编辑账户' : '新增账户'}
|
||||
</h3>
|
||||
<div className="shop-mine-info-card">
|
||||
<label className="shop-record-amount-label">
|
||||
收款人 <span style={{ color: 'red' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
className="shop-withdraw-input"
|
||||
value={form.bankAccountName}
|
||||
maxLength={64}
|
||||
placeholder="户名 / 收款人姓名"
|
||||
onChange={(e) => setForm({ ...form, bankAccountName: e.target.value })}
|
||||
/>
|
||||
{fieldErrors.bankAccountName ? (
|
||||
<p className="shop-withdraw-msg" role="alert">{fieldErrors.bankAccountName}</p>
|
||||
) : null}
|
||||
|
||||
<label className="shop-record-amount-label" style={{ marginTop: 12 }}>
|
||||
银行账号 <span style={{ color: 'red' }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
className="shop-withdraw-input"
|
||||
inputMode="numeric"
|
||||
value={form.bankAccountNo}
|
||||
maxLength={32}
|
||||
placeholder="请输入银行账号"
|
||||
onChange={(e) => setForm({ ...form, bankAccountNo: e.target.value.replace(/[^\d]/g, '') })}
|
||||
/>
|
||||
{fieldErrors.bankAccountNo ? (
|
||||
<p className="shop-withdraw-msg" role="alert">{fieldErrors.bankAccountNo}</p>
|
||||
) : null}
|
||||
|
||||
<label className="shop-record-amount-label" style={{ marginTop: 12 }}>
|
||||
开户行名称
|
||||
</label>
|
||||
<input
|
||||
className="shop-withdraw-input"
|
||||
value={form.bankBranch}
|
||||
maxLength={128}
|
||||
placeholder="如 中国工商银行郑州分行"
|
||||
onChange={(e) => setForm({ ...form, bankBranch: e.target.value })}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-withdraw-btn"
|
||||
disabled={submitting}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{submitting ? '保存中…' : '保存'}
|
||||
</button>
|
||||
{editing ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-records-chip"
|
||||
onClick={startCreate}
|
||||
>
|
||||
取消编辑
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
<p className="shop-records-empty">仅主账号可维护收款账户</p>
|
||||
)}
|
||||
|
||||
{msg ? <p className="shop-withdraw-msg">{msg}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -153,12 +153,6 @@ export default function MinePage() {
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>account_balance_wallet</span>
|
||||
结算提现
|
||||
</button>
|
||||
{profile?.isPrimary ? (
|
||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/bank-accounts')}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>account_balance</span>
|
||||
收款账户
|
||||
</button>
|
||||
) : null}
|
||||
{profile?.isPrimary ? (
|
||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/packages')}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>restaurant_menu</span>
|
||||
|
||||
+12
-12
@@ -1,6 +1,7 @@
|
||||
# 杜康好客 · 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)
|
||||
|
||||
@@ -15,7 +16,7 @@
|
||||
| 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.15 | 09-02 | HQ 概览改为全宽折线图:粒度分桶、总量/增量、维度线条;查看快链只带全局筛选 | [`v4.0.15`](./杜康好客-v4.0.15-开发文档.md) |
|
||||
| 4.0.18 | 09-07 / 09-08 | C 端门店列表省+市+区+详细地址(原样拼接、不去重);门店多收款账户(默认账户打款,切换无需重启);子账号独立继承二维码 + 子账号维度统计;HQ 财务银行账户总目录 | [`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,佣金归属改写)
|
||||
|
||||
@@ -99,27 +100,26 @@ HQ 财务详情与合伙人确认页均展示两段列表。不再「无快照
|
||||
- **应付为 0 仍出账**:出账日无订单或应付为 0 仍生成账单;业务状态展示「无需打款」(DB 仍为 `UNPAID`,不可确认打款)。
|
||||
- **已打款不回刷**;未打款账单可重算;明细 `orderId` 冲突时从其他未打款账单迁入。
|
||||
|
||||
## 8. 门店收款账户(v4.0.18)
|
||||
## 8. 门店打款账户(v4.0.18)
|
||||
|
||||
- 一个门店可维护**多个**收款银行账户(表 `store_bank_account`,挂在 `storeId`);`is_default=1` 为打款默认账户(每店至多一个)。
|
||||
- 打款/核销结算/提现/账单导出统一读取**默认账户**(无默认取第一个 ACTIVE;回退旧主账号字段兼容存量)。
|
||||
- **切换/新增/删除账户无需重启服务器**:银行字段均实时查库,无进程内缓存。
|
||||
- 权限:门店主账号维护本店账户(子账号只读);总部可维护任意门店账户。
|
||||
- 打款/核销结算/提现/账单导出读取门店详情「结算资质」:结算户名、银行账号、开户银行(主账号 `StoreAccount.bank_account_*`)。
|
||||
- **不**再维护独立「收款账户」列表或 `store_bank_account`;一门店一行。
|
||||
- 改结算资质后无需重启,下次打款即时生效。
|
||||
|
||||
## 9. 财务银行账户总目录(v4.0.18)
|
||||
## 9. 财务全部银行账户(v4.0.18)
|
||||
|
||||
HQ「财务 → 银行账户」聚合**有效**收款账户,供财务查阅、筛选、导出;**不替代**各业务源的维护入口,也不作为打款主数据。
|
||||
HQ「财务 → 全部银行账户」聚合**有效**银行账户,供财务查阅、筛选、导出;**不替代**各业务源的维护入口,也不作为打款主数据。
|
||||
|
||||
| 类型 | 来源 | 有效 | 本页可写 |
|
||||
|------|------|------|----------|
|
||||
| 门店 | `store_bank_account` | ACTIVE 且户名+账号非空;含非默认 | 仅备注 |
|
||||
| 门店 | 门店详情结算资质(主账号 `bank_account_*`) | 户名+账号非空;一店一行 | 仅备注 |
|
||||
| 酒厂 | `WINERY_BANK_*` | 户名+账号已填 | 仅备注 |
|
||||
| 合伙人 | 主账号 `bank_account_*` | ACTIVE 且户名+账号非空 | 仅备注 |
|
||||
| 物流 | 承运商 `common_fulfillment_provider` | ACTIVE 且户名+账号非空 | 仅备注 |
|
||||
| 其他 | `finance_bank_account` | ACTIVE | 增删改(不挂门店,不进入打款) |
|
||||
|
||||
- 列表字段:类型、归属、城市、户名、银行账号、开户行、备注;门店显示是否默认。
|
||||
- 筛选:类型、城市、关键字(户名/账号/开户行/归属/备注)。可导出 Excel 与 PDF。
|
||||
- 列表字段:类型、归属(快链到源页面)、城市、结算户名、银行账号、开户银行、备注。
|
||||
- 筛选:类型、城市、关键字(户名/账号/开户银行/归属/备注)。可导出 Excel 与 PDF。
|
||||
- 「其他」账户仅登记备查,不进入门店提现、账单打款、酒厂/物流对账。
|
||||
- 权限:`finance`。
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
| 维度 | 结论 |
|
||||
|------|------|
|
||||
| 版本线 | **v4.0.18** 门店多收款账户 + 子账号继承二维码 + HQ 财务银行账户总目录(含 v4.0.15 HQ 概览折线图) |
|
||||
| 版本线 | **v4.0.18** 子账号继承二维码 + HQ 财务全部银行账户(门店行读结算资质;含 v4.0.15 HQ 概览折线图) |
|
||||
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
||||
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
||||
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
||||
@@ -24,7 +24,7 @@
|
||||
| 4.0.13 | [`收货地址把关与拒单可感知`](./杜康好客-v4.0.13-开发文档.md) | ✅ 已实现 |
|
||||
| 4.0.14 | [`HQ 概览粒度与环比`](./杜康好客-v4.0.14-开发文档.md) | ✅ 已实现 |
|
||||
| 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 |
|
||||
| 4.0.18 | [`门店多收款账户 + 子账号继承二维码 + 财务银行账户总目录`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 |
|
||||
| 4.0.18 | [`子账号继承二维码 + 财务全部银行账户(结算资质)`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 |
|
||||
|
||||
| 日期 | 说明 |
|
||||
|------|------|
|
||||
@@ -38,7 +38,8 @@
|
||||
| 2026-09-02 | v4.0.13:收货禁「全市」;脏地址下单拦截;小飞侠超区/推单失败挂 `fulfillmentHold`(不做仓/收件坐标) |
|
||||
| 2026-09-02 | v4.0.14: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 | v4.0.18 追加:HQ 财务全部银行账户(聚合门店结算资质/酒厂/合伙人/物流有效账户;可新增不挂门店账户;筛选与 Excel/PDF 导出) |
|
||||
| 2026-09-08 | v4.0.18 再修订:撤销门店多收款账户;打款与财务门店行改读结算资质(结算户名/银行账号/开户银行) |
|
||||
| 2026-09-08 | HQ 合伙人详情关联码可下载裸二维码(与「下载活动图」分开);C 端门店核销次数由系统设置开关控制 |
|
||||
|
||||
+30
-45
@@ -1,9 +1,10 @@
|
||||
# 杜康好客 · v4.0.18 开发文档
|
||||
|
||||
> **2026-09-07** · mini-user / store / settlement / iam / h5-shop / admin-web / h5-partner / shared-types / domain
|
||||
> **主题**:C 端门店列表省市区县地址;门店多收款账户;子账号独立继承二维码;HQ 财务银行账户总目录
|
||||
> **主题**:C 端门店列表省市区县地址;子账号独立继承二维码;HQ 财务全部银行账户目录
|
||||
> **2026-09-08 修订**:门店列表地址按「省+市+区+详细地址」原样拼接,详细地址已含省市区时**不去重**
|
||||
> **2026-09-08 追加**:HQ 财务「银行账户」总目录(聚合门店/酒厂/合伙人/物流 + 不挂门店的「其他」账户)
|
||||
> **2026-09-08 追加**:HQ 财务「全部银行账户」目录(聚合门店结算资质/酒厂/合伙人/物流 + 不挂门店的「其他」账户)
|
||||
> **2026-09-08 再修订**:撤销门店多收款账户(`store_bank_account`);打款与财务门店行均读门店详情「结算资质」主账号 `bank_account_*`
|
||||
|
||||
---
|
||||
|
||||
@@ -12,9 +13,9 @@
|
||||
| # | 任务 | 类型 | 交付 |
|
||||
|---|------|------|------|
|
||||
| 1 | C 端门店列表地址 | 需求 | 「省 + 市 + 区 + 详细地址」原样拼接展示(不去重);搜索匹配完整地址 |
|
||||
| 2 | 门店多银行账号 | 需求 | 门店级收款账户列表;设「默认」账户用于打款;**切换无需重启** |
|
||||
| 2 | 门店打款账户 | 需求 | 打款读门店详情「结算资质」(结算户名 / 银行账号 / 开户银行);**不**再维护独立收款账户列表 |
|
||||
| 3 | 子账号二维码 | 需求 | 子账号独立继承码 `sa_{subId}`;新增一级子账号维度统计 |
|
||||
| 4 | HQ 财务银行账户总目录 | 需求 | 财务菜单下列出有效账户;可新增不挂门店的「其他」账户;类型/备注/筛选/Excel+PDF 导出 |
|
||||
| 4 | HQ 财务全部银行账户 | 需求 | 财务菜单「全部银行账户」列出有效账户;门店行来自结算资质;可新增不挂门店的「其他」账户;类型/备注/筛选/Excel+PDF 导出 |
|
||||
|
||||
**不做**:银行账号历史打款回刷;子账号佣金独立归属(佣金仍归主账号);按承运商维度的收款账户;「其他」账户接入打款;本页改写门店/合伙人/酒厂/物流源字段。
|
||||
|
||||
@@ -42,13 +43,12 @@ C 端门店列表卡片「地址」一栏(门店详情页同规则)= **下
|
||||
|
||||
搜索关键字同时匹配 `address` / `district` / 完整拼接结果。规则实现:`formatStoreDisplayAddress`(domain,后端地理编码同用)与 mini-user `fullStoreAddress`(列表/详情展示)。
|
||||
|
||||
### 2.2 门店多银行账号
|
||||
### 2.2 门店打款账户(结算资质)
|
||||
|
||||
- 表 `store_bank_account` 挂在 `storeId`:一门店多账户;`is_default=1` 为打款默认账户(每店至多一个)。
|
||||
- 迁移时按现有主账号银行字段(`StoreAccount.bank_account_name/no/branch`)为每店回填一条默认账户。
|
||||
- 打款/结算/提现导出统一读取**默认账户**(无默认则取第一个 ACTIVE;再退回旧主账号字段,兼容未迁移数据)。
|
||||
- **切换/新增/删除账户不需要重启服务器**:全仓无进程内缓存,`StoreAccount`/`PartnerAccount`/`FulfillmentProvider` 银行字段与酒厂 `system_config WINERY_BANK_*` 均每次请求实时查库。唯一「改后需重启」的是微信支付商户号 `WX_MCH_ID`(`system-config.registry.ts`,`requiresRestart: true`),属微信支付、非银行账号。
|
||||
- 权限:门店主账号可维护本店账户(子账号只读);总部可维护任意门店账户。
|
||||
- **撤销**独立表 `store_bank_account` 与门店端/HQ「收款账户」维护页。一门店一行,取绑定主账号(`is_primary=1`)的 `bank_account_name` / `bank_account_no` / `bank_branch`;无主账号则取最早绑定账号。
|
||||
- 对应 HQ 门店详情页签「结算资质」:结算户名、银行账号、开户银行。
|
||||
- 打款/结算/提现/账单导出统一读上述字段(`loadStoreDefaultBank`),实时查库,改后无需重启。
|
||||
- 门店端不再提供收款账户入口;银行信息由总部在结算资质维护。
|
||||
|
||||
### 2.3 子账号继承二维码
|
||||
|
||||
@@ -68,45 +68,31 @@ flowchart LR
|
||||
- `getSummary`/`getStats`/`listUsers`/`listAssocOrders`:子账号调用时返回**自己维度**数据(`assocSubAccountId=子账号`);`activityPosterId` 恒为空;主账号保持聚合行为不变。
|
||||
- 佣金仍归主账号,不因扫码来源为子账号而改变归属。
|
||||
|
||||
### 2.4 HQ 财务银行账户总目录
|
||||
### 2.4 HQ 财务全部银行账户
|
||||
|
||||
财务查阅与手工登记目录,**不改打款主数据**。打款仍读各业务源表(门店默认账户、合伙人主账号字段、承运商字段、酒厂 `WINERY_BANK_*`)。
|
||||
财务查阅与手工登记目录,**不改打款主数据**。打款仍读各业务源表(门店结算资质、合伙人主账号字段、承运商字段、酒厂 `WINERY_BANK_*`)。菜单名:**全部银行账户**。
|
||||
|
||||
**有效账户**(同时满足才入列):户名、银行账号均非空;来源状态为有效。
|
||||
**有效账户**(同时满足才入列):结算户名、银行账号均非空;来源状态为有效。
|
||||
|
||||
| 类型 | 数据源 | 有效条件 | 本页可写 |
|
||||
|------|--------|----------|----------|
|
||||
| 门店 | `store_bank_account` | `status=ACTIVE`;所有门店的全部有效账户(含非默认) | 仅备注 |
|
||||
| 门店 | 门店详情结算资质(主账号 `StoreAccount.bank_account_*`) | 户名+账号已填;一店一行 | 仅备注 |
|
||||
| 酒厂 | `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。
|
||||
- 来源账户字段仍在原处改;本页只读这些字段。备注:其他写本表;来源写 overlay 表 `finance_bank_account_note`(门店 `sourceId` = `storeId`)。
|
||||
- 筛选:类型、城市、关键字(户名/账号/开户银行/归属名/备注)。导出按当前筛选全量 Excel / PDF。
|
||||
- 权限:HQ `finance`。
|
||||
|
||||
---
|
||||
|
||||
## 3. API
|
||||
|
||||
### 3.1 门店收款账户(store 模块)
|
||||
|
||||
| 方法 | 路径 | Guard | 说明 |
|
||||
|------|------|-------|------|
|
||||
| GET | `/shop/store/bank-accounts` | 门店 | 本店账户列表 |
|
||||
| POST | `/shop/store/bank-accounts` | 门店主账号 | 新增账户(首条自动默认) |
|
||||
| PUT | `/shop/store/bank-accounts/:id` | 门店主账号 | 编辑账户 |
|
||||
| DELETE | `/shop/store/bank-accounts/:id` | 门店主账号 | 删除(默认账户不可删) |
|
||||
| POST | `/shop/store/bank-accounts/:id/default` | 门店主账号 | 设为默认 |
|
||||
| GET | `/admin/stores/:storeId/bank-accounts` | HQ | 指定门店账户列表 |
|
||||
| POST / PUT / DELETE / POST `:id/default` | `/admin/stores/:storeId/bank-accounts` | HQ | 总部维护 |
|
||||
|
||||
入参:`{ bankAccountName, bankAccountNo, bankBranch? }`;`bankAccountNo` 校验 `^\d{8,32}$`。
|
||||
|
||||
### 3.2 关联码(store 模块)
|
||||
### 3.1 关联码(store 模块)
|
||||
|
||||
- `POST /user/partner-assoc/touch`:scene 支持 `pa_{id}` 与 `sa_{subId}`;`sa_` 对子账号行计数,返回 `{ partnerId, subAccountId, scanCounted, scanCount }`。
|
||||
- `POST /user/partner-assoc/bind`:scene 支持 `sa_`;返回 `{ bound, alreadyBound, partnerId, subAccountId, partnerName }`。
|
||||
@@ -114,7 +100,7 @@ flowchart LR
|
||||
- `GET /partner/assoc/stats`、`GET /partner/assoc/users`、`GET /partner/assoc/orders`:子账号返回自己维度。
|
||||
- `GET /partner/assoc/qrcode`:子账号下载自己的码。
|
||||
|
||||
### 3.3 财务银行账户总目录(settlement 模块)
|
||||
### 3.2 财务全部银行账户(settlement 模块)
|
||||
|
||||
| 方法 | 路径 | Guard | 说明 |
|
||||
|------|------|-------|------|
|
||||
@@ -125,7 +111,7 @@ flowchart LR
|
||||
| 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}$`。
|
||||
行 `id`:`STORE:{storeId}` / `PARTNER:{partnerId}` / `LOGISTICS:{providerId}` / `WINERY:winery` / `OTHER:{id}`。`bankAccountNo` 校验 `^\d{8,32}$`(「其他」入参)。
|
||||
|
||||
---
|
||||
|
||||
@@ -133,16 +119,16 @@ flowchart LR
|
||||
|
||||
| 层 | 路径 |
|
||||
|----|------|
|
||||
| Prisma | `schema.prisma`(新增 `StoreBankAccount`、`FinanceBankAccount`、`FinanceBankAccountNote`;`User.assocSubAccountId`) |
|
||||
| 迁移 | `migrate-store-bank-account-v4018.sql`、`migrate-user-assoc-sub-account-v4018.sql`、`migrate-finance-bank-account-v4018.sql` |
|
||||
| Prisma | `schema.prisma`(`FinanceBankAccount`、`FinanceBankAccountNote`;`User.assocSubAccountId`;**已删除** `StoreBankAccount`) |
|
||||
| 迁移 | `migrate-user-assoc-sub-account-v4018.sql`、`migrate-finance-bank-account-v4018.sql`、`migrate-drop-store-bank-account-v4018.sql`(历史 `migrate-store-bank-account-v4018.sql` 仅作曾上线回填) |
|
||||
| domain | `store-address.ts`(`formatStoreDisplayAddress`:省+市+区+详细地址原样拼接,不去重) |
|
||||
| shared-types | `settlement.ts`(`StoreBankAccountDto`、`FinanceBankAccountDto`);`hq-list-columns.ts`(`finance-bank-accounts`);`partner-assoc.ts`(summary/touch/bind 增 `subAccountId`/`isSubAccount`) |
|
||||
| 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`(默认账户) |
|
||||
| shared-types | `settlement.ts`(`FinanceBankAccountDto`);`hq-list-columns.ts`(`finance-bank-accounts`);`partner-assoc.ts`(summary/touch/bind 增 `subAccountId`/`isSubAccount`) |
|
||||
| API store | `store-bank.util.ts`(`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`(结算资质银行字段) |
|
||||
| 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` |
|
||||
| admin-web | `StoreBankAccountsPanel.tsx`(新增)、`StoresPage.tsx`(收款账户页签);`BankAccountsPage.tsx`(财务银行账户总目录) |
|
||||
| h5-shop | 已删除收款账户页;`MinePage.tsx` 去掉入口 |
|
||||
| admin-web | `StoresPage.tsx` 结算资质;`BankAccountsPage.tsx`(财务全部银行账户);已删除收款账户页签 |
|
||||
| h5-partner | `AssocQrcodePage.tsx`、`UsersManagePage.tsx`(子账号提示) |
|
||||
|
||||
---
|
||||
@@ -150,11 +136,10 @@ flowchart LR
|
||||
## 5. 验收
|
||||
|
||||
- [ ] C 端门店列表地址 = 省+市+区+详细地址原文;详细地址已含省市区时仍重复拼接(例:金水东路333号 → `河南省郑州市金水区金水东路333号`;详细地址写全称 → `河南省郑州市金水区河南省郑州市金水区金水东路333号`);空值回退「地址待完善」;搜索「省/市/区县」关键词可命中
|
||||
- [ ] 门店可新增/编辑/删除/设默认收款账户;默认账户用于提现打款与账单导出
|
||||
- [ ] 切换默认账户后(无需重启)提现 summary 与打款信息即时生效
|
||||
- [ ] 门店无独立收款账户页;打款/提现/账单导出读结算资质(结算户名、银行账号、开户银行);改后无需重启即时生效
|
||||
- [ ] 子账号可生成并下载自己的二维码(scene `sa_{subId}`),与主账号 `pa_{id}` 不冲突
|
||||
- [ ] 扫子账号码:用户仍锁定主账号(佣金归主账号),并写入 `assoc_sub_account_id`
|
||||
- [ ] 子账号用户管理/关联码页展示自己维度的已扫码/已关联/订单统计;主账号聚合 own+children 不变
|
||||
- [ ] 主账号码扫码:`assoc_sub_account_id` 为空,行为与 v4.0.9 一致
|
||||
- [ ] HQ 财务菜单「银行账户」:列出全部有效门店账户(含非默认)+ 有效酒厂/主合伙人/承运商账户;可新增不挂门店账户;类型/城市/关键字筛选;Excel 与 PDF 导出与筛选一致;备注可写;来源账户改后刷新即更新
|
||||
- [ ] HQ 财务菜单「全部银行账户」:门店行来自结算资质(一店一行)+ 有效酒厂/主合伙人/承运商账户;可新增不挂门店账户;类型/城市/关键字筛选;Excel 与 PDF 导出与筛选一致;备注可写;结算资质改后刷新即更新
|
||||
- [ ] shared-types 构建通过;相关 lint/单测过
|
||||
|
||||
@@ -55,26 +55,7 @@ export interface StoreWithdrawBankAccountDto {
|
||||
bankBranch?: string | null;
|
||||
}
|
||||
|
||||
/** 门店收款银行账户(v4.0.18 多账户) */
|
||||
export interface StoreBankAccountDto {
|
||||
id: string;
|
||||
storeId: string;
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch?: string | null;
|
||||
isDefault: boolean;
|
||||
status: 'ACTIVE' | 'DISABLED';
|
||||
sortOrder?: number;
|
||||
}
|
||||
|
||||
/** 门店收款银行账户新增/编辑入参 */
|
||||
export interface StoreBankAccountInputDto {
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch?: string;
|
||||
}
|
||||
|
||||
/** HQ 财务银行账户总目录类型(v4.0.18) */
|
||||
/** 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];
|
||||
|
||||
@@ -97,7 +78,6 @@ export interface FinanceBankAccountDto {
|
||||
bankAccountNo: string;
|
||||
bankBranch?: string | null;
|
||||
remark?: string | null;
|
||||
isDefault?: boolean;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- v4.0.18 修订:撤销门店多收款账户,打款改回门店结算资质(主账号 bank_account_*)
|
||||
-- 财务目录门店行改用 storeId,旧 STORE 备注按 store_bank_account.id 已失效
|
||||
|
||||
DROP TABLE IF EXISTS `store_bank_account`;
|
||||
DELETE FROM `finance_bank_account_note` WHERE `owner_type` = 'STORE';
|
||||
@@ -1609,7 +1609,6 @@ model Store {
|
||||
packageChangeRequests StorePackageChangeRequest[]
|
||||
infoChangeRequests StoreInfoChangeRequest[]
|
||||
categoryLinks StoreCategoryLink[]
|
||||
bankAccounts StoreBankAccount[]
|
||||
|
||||
@@index([cityId, status])
|
||||
@@index([partnerAccountId])
|
||||
@@ -1619,25 +1618,6 @@ model Store {
|
||||
@@map("store_store")
|
||||
}
|
||||
|
||||
/// 门店收款银行账户(一个门店可维护多个,is_default 为打款默认账户)
|
||||
model StoreBankAccount {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
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)
|
||||
isDefault Int @default(0) @map("is_default") @db.TinyInt
|
||||
status AccountStatus @default(ACTIVE)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([storeId, status])
|
||||
@@map("store_bank_account")
|
||||
}
|
||||
|
||||
/// Store visibility whitelist phones (match by bound user phone)
|
||||
model StoreVisibilityPhone {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
|
||||
@@ -6,55 +6,10 @@ export type StoreBankAccountSnapshot = {
|
||||
bankBranch: string | null;
|
||||
};
|
||||
|
||||
export type StoreBankAccountRow = StoreBankAccountSnapshot & {
|
||||
id: bigint;
|
||||
storeId: bigint;
|
||||
isDefault: boolean;
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
};
|
||||
|
||||
/** 门店收款账户列表(默认账户优先,其次 sortOrder/创建顺序) */
|
||||
export async function loadStoreBankAccounts(
|
||||
prisma: PrismaClient,
|
||||
storeId: bigint,
|
||||
): Promise<StoreBankAccountRow[]> {
|
||||
const rows = await prisma.storeBankAccount.findMany({
|
||||
where: { storeId, status: 'ACTIVE' },
|
||||
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
storeId: r.storeId,
|
||||
bankAccountName: r.bankAccountName,
|
||||
bankAccountNo: r.bankAccountNo,
|
||||
bankBranch: r.bankBranch,
|
||||
isDefault: r.isDefault === 1,
|
||||
status: r.status,
|
||||
sortOrder: r.sortOrder,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 门店默认收款账户:优先 is_default=1;否则第一个 ACTIVE;再退回旧主账号字段(兼容未迁移数据)。
|
||||
* 返回 null 表示无收款账户。
|
||||
* 门店结算资质银行账户:主账号(is_primary=1)字段;无主账号则取最早绑定账号。
|
||||
* 来源为门店详情「结算资质」(StoreAccount.bank_account_*),实时查库。
|
||||
*/
|
||||
export async function loadStoreDefaultBank(
|
||||
prisma: PrismaClient,
|
||||
storeId: bigint,
|
||||
): Promise<StoreBankAccountSnapshot | null> {
|
||||
const accounts = await loadStoreBankAccounts(prisma, storeId);
|
||||
if (accounts.length) {
|
||||
const def = accounts.find((a) => a.isDefault) ?? accounts[0];
|
||||
return {
|
||||
bankAccountName: def.bankAccountName,
|
||||
bankAccountNo: def.bankAccountNo,
|
||||
bankBranch: def.bankBranch,
|
||||
};
|
||||
}
|
||||
return loadStorePrimaryBank(prisma, storeId);
|
||||
}
|
||||
|
||||
export async function loadStorePrimaryBank(
|
||||
prisma: PrismaClient,
|
||||
storeId: bigint,
|
||||
@@ -80,6 +35,14 @@ export async function loadStorePrimaryBank(
|
||||
return fallback?.storeAccount ?? null;
|
||||
}
|
||||
|
||||
/** 打款/提现读结算资质(主账号银行字段) */
|
||||
export async function loadStoreDefaultBank(
|
||||
prisma: PrismaClient,
|
||||
storeId: bigint,
|
||||
): Promise<StoreBankAccountSnapshot | null> {
|
||||
return loadStorePrimaryBank(prisma, storeId);
|
||||
}
|
||||
|
||||
export async function loadStorePrimaryBanksMap(
|
||||
prisma: PrismaClient,
|
||||
storeIds: bigint[],
|
||||
@@ -87,49 +50,28 @@ export async function loadStorePrimaryBanksMap(
|
||||
const map = new Map<string, StoreBankAccountSnapshot>();
|
||||
if (!storeIds.length) return map;
|
||||
|
||||
// 优先从门店多账户表读取默认账户
|
||||
const accounts = await prisma.storeBankAccount.findMany({
|
||||
where: { storeId: { in: storeIds }, status: 'ACTIVE' },
|
||||
orderBy: [{ storeId: 'asc' }, { isDefault: 'desc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
const seenStoreIds = new Set<string>();
|
||||
for (const a of accounts) {
|
||||
const key = String(a.storeId);
|
||||
if (map.has(key)) continue;
|
||||
map.set(key, {
|
||||
bankAccountName: a.bankAccountName,
|
||||
bankAccountNo: a.bankAccountNo,
|
||||
bankBranch: a.bankBranch,
|
||||
});
|
||||
seenStoreIds.add(key);
|
||||
}
|
||||
|
||||
// 未迁移/无多账户的门店,回退旧主账号字段
|
||||
const missingIds = storeIds.filter((id) => !seenStoreIds.has(String(id)));
|
||||
if (missingIds.length) {
|
||||
const bindings = await prisma.storeAccountStore.findMany({
|
||||
where: { storeId: { in: missingIds } },
|
||||
include: {
|
||||
storeAccount: {
|
||||
select: {
|
||||
isPrimary: true,
|
||||
bankAccountName: true,
|
||||
bankAccountNo: true,
|
||||
bankBranch: true,
|
||||
},
|
||||
const bindings = await prisma.storeAccountStore.findMany({
|
||||
where: { storeId: { in: storeIds } },
|
||||
include: {
|
||||
storeAccount: {
|
||||
select: {
|
||||
isPrimary: true,
|
||||
bankAccountName: true,
|
||||
bankAccountNo: true,
|
||||
bankBranch: true,
|
||||
},
|
||||
},
|
||||
orderBy: [{ storeAccount: { isPrimary: 'desc' } }, { storeAccountId: 'asc' }],
|
||||
},
|
||||
orderBy: [{ storeAccount: { isPrimary: 'desc' } }, { storeAccountId: 'asc' }],
|
||||
});
|
||||
for (const binding of bindings) {
|
||||
const key = String(binding.storeId);
|
||||
if (map.has(key)) continue;
|
||||
map.set(key, {
|
||||
bankAccountName: binding.storeAccount.bankAccountName,
|
||||
bankAccountNo: binding.storeAccount.bankAccountNo,
|
||||
bankBranch: binding.storeAccount.bankBranch,
|
||||
});
|
||||
for (const binding of bindings) {
|
||||
const key = String(binding.storeId);
|
||||
if (map.has(key)) continue;
|
||||
map.set(key, {
|
||||
bankAccountName: binding.storeAccount.bankAccountName,
|
||||
bankAccountNo: binding.storeAccount.bankAccountNo,
|
||||
bankBranch: binding.storeAccount.bankBranch,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -161,11 +161,20 @@ export class FinanceBankAccountService {
|
||||
}
|
||||
|
||||
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' },
|
||||
const [storeBindings, partnerRows, logisticsRows, otherRows, notes, winery] = await Promise.all([
|
||||
this.prisma.storeAccountStore.findMany({
|
||||
include: {
|
||||
store: { select: { id: true, name: true, cityId: true, cityName: true } },
|
||||
storeAccount: {
|
||||
select: {
|
||||
isPrimary: true,
|
||||
bankAccountName: true,
|
||||
bankAccountNo: true,
|
||||
bankBranch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ storeAccount: { isPrimary: 'desc' } }, { storeAccountId: 'asc' }],
|
||||
}),
|
||||
this.prisma.partnerAccount.findMany({
|
||||
where: { status: 'ACTIVE', parentAccountId: null, isPrimary: 1 },
|
||||
@@ -202,23 +211,25 @@ export class FinanceBankAccountService {
|
||||
|
||||
const items: FinanceBankAccountDto[] = [];
|
||||
|
||||
for (const row of storeRows) {
|
||||
const name = row.bankAccountName.trim();
|
||||
const no = row.bankAccountNo.replace(/\s+/g, '');
|
||||
const seenStores = new Set<string>();
|
||||
for (const binding of storeBindings) {
|
||||
const sourceId = binding.store.id.toString();
|
||||
if (seenStores.has(sourceId)) continue;
|
||||
seenStores.add(sourceId);
|
||||
const name = binding.storeAccount.bankAccountName?.trim() ?? '';
|
||||
const no = binding.storeAccount.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,
|
||||
ownerName: binding.store.name,
|
||||
ownerId: sourceId,
|
||||
cityId: binding.store.cityId.toString(),
|
||||
cityName: binding.store.cityName,
|
||||
bankAccountName: name,
|
||||
bankAccountNo: no,
|
||||
bankBranch: row.bankBranch,
|
||||
bankBranch: binding.storeAccount.bankBranch,
|
||||
remark: noteMap.get(`STORE:${sourceId}`) ?? null,
|
||||
isDefault: row.isDefault === 1,
|
||||
editable: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -32,10 +32,9 @@ function columnDefs(): ExportColumnDef<FinanceBankAccountDto>[] {
|
||||
{ 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: '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: 'bankBranch', header: '开户银行', value: (r) => r.bankBranch ?? '' },
|
||||
{ key: 'remark', header: '备注', value: (r) => r.remark ?? '' },
|
||||
];
|
||||
}
|
||||
@@ -43,7 +42,7 @@ function columnDefs(): ExportColumnDef<FinanceBankAccountDto>[] {
|
||||
export async function buildFinanceBankAccountsXlsx(rows: FinanceBankAccountDto[]): Promise<Buffer> {
|
||||
const cols = pickExportColumns(columnDefs());
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const sheet = workbook.addWorksheet('银行账户');
|
||||
const sheet = workbook.addWorksheet('全部银行账户');
|
||||
sheet.addRow(cols.map((c) => c.header));
|
||||
for (const row of rows) {
|
||||
sheet.addRow(cols.map((c) => c.value(row)));
|
||||
@@ -66,7 +65,7 @@ export async function buildFinanceBankAccountsPdf(rows: FinanceBankAccountDto[])
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)));
|
||||
doc.on('error', reject);
|
||||
doc.font(fontPath);
|
||||
doc.fontSize(12).text('银行账户');
|
||||
doc.fontSize(12).text('全部银行账户');
|
||||
doc.moveDown(0.4);
|
||||
doc.fontSize(9).text(cols.map((c) => c.header).join(' | '));
|
||||
doc.moveDown(0.3);
|
||||
@@ -79,5 +78,5 @@ export async function buildFinanceBankAccountsPdf(rows: FinanceBankAccountDto[])
|
||||
|
||||
export function buildFinanceBankExportFilename(format: 'xlsx' | 'pdf', count: number): string {
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
return `银行账户_${stamp}_${count}.${format}`;
|
||||
return `全部银行账户_${stamp}_${count}.${format}`;
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, UseGuards } from '@nestjs/common';
|
||||
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||
import { ShopPrimaryGuard } from '../../common/guards/shop-primary.guard';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { StoreBankService } from './store-bank.service';
|
||||
|
||||
class StoreBankDto {
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '请填写收款人' })
|
||||
@MaxLength(64)
|
||||
bankAccountName!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: '请填写银行账号' })
|
||||
@MaxLength(32)
|
||||
bankAccountNo!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
bankBranch?: string;
|
||||
}
|
||||
|
||||
/** 门店端:主账号管理本店收款账户(子账号只读) */
|
||||
@Controller('shop/store/bank-accounts')
|
||||
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||
export class ShopStoreBankController {
|
||||
constructor(private readonly storeBankService: StoreBankService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthUser) {
|
||||
return this.storeBankService.list(user.storeId!);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@UseGuards(ShopPrimaryGuard)
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: StoreBankDto) {
|
||||
return this.storeBankService.create(user.storeId!, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@UseGuards(ShopPrimaryGuard)
|
||||
update(@CurrentUser() user: AuthUser, @Param('id') id: string, @Body() dto: StoreBankDto) {
|
||||
return this.storeBankService.update(user.storeId!, BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(ShopPrimaryGuard)
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.storeBankService.remove(user.storeId!, BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/default')
|
||||
@UseGuards(ShopPrimaryGuard)
|
||||
setDefault(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.storeBankService.setDefault(user.storeId!, BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
/** 总部端:管理指定门店的收款账户 */
|
||||
@Controller('admin/stores/:storeId/bank-accounts')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStoreBankController {
|
||||
constructor(private readonly storeBankService: StoreBankService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('storeId') storeId: string) {
|
||||
return this.storeBankService.list(BigInt(storeId));
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@Param('storeId') storeId: string, @Body() dto: StoreBankDto) {
|
||||
return this.storeBankService.create(BigInt(storeId), dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
update(@Param('storeId') storeId: string, @Param('id') id: string, @Body() dto: StoreBankDto) {
|
||||
return this.storeBankService.update(BigInt(storeId), BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('storeId') storeId: string, @Param('id') id: string) {
|
||||
return this.storeBankService.remove(BigInt(storeId), BigInt(id));
|
||||
}
|
||||
|
||||
@Post(':id/default')
|
||||
setDefault(@Param('storeId') storeId: string, @Param('id') id: string) {
|
||||
return this.storeBankService.setDefault(BigInt(storeId), BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
export interface StoreBankInput {
|
||||
bankAccountName?: string;
|
||||
bankAccountNo?: string;
|
||||
bankBranch?: string;
|
||||
}
|
||||
|
||||
const BANK_NO_RE = /^\d{8,32}$/;
|
||||
|
||||
@Injectable()
|
||||
export class StoreBankService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private serialize(row: {
|
||||
id: bigint;
|
||||
storeId: bigint;
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string | null;
|
||||
isDefault: number;
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
}) {
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
storeId: row.storeId.toString(),
|
||||
bankAccountName: row.bankAccountName,
|
||||
bankAccountNo: row.bankAccountNo,
|
||||
bankBranch: row.bankBranch,
|
||||
isDefault: row.isDefault === 1,
|
||||
status: row.status,
|
||||
sortOrder: row.sortOrder,
|
||||
};
|
||||
}
|
||||
|
||||
private normalize(dto: StoreBankInput) {
|
||||
const bankAccountName = dto.bankAccountName?.trim() ?? '';
|
||||
const bankAccountNo = dto.bankAccountNo?.replace(/\s+/g, '') ?? '';
|
||||
const bankBranch = dto.bankBranch?.trim() || null;
|
||||
if (!bankAccountName) throw new BadRequestException('请填写收款人');
|
||||
if (!BANK_NO_RE.test(bankAccountNo)) throw new BadRequestException('请填写正确的银行账号');
|
||||
return { bankAccountName, bankAccountNo, bankBranch };
|
||||
}
|
||||
|
||||
async list(storeId: bigint) {
|
||||
const rows = await this.prisma.storeBankAccount.findMany({
|
||||
where: { storeId },
|
||||
orderBy: [{ isDefault: 'desc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return rows.map((r) => this.serialize(r));
|
||||
}
|
||||
|
||||
async create(storeId: bigint, dto: StoreBankInput) {
|
||||
const data = this.normalize(dto);
|
||||
const count = await this.prisma.storeBankAccount.count({
|
||||
where: { storeId, status: 'ACTIVE' },
|
||||
});
|
||||
const created = await this.prisma.storeBankAccount.create({
|
||||
data: {
|
||||
storeId,
|
||||
bankAccountName: data.bankAccountName,
|
||||
bankAccountNo: data.bankAccountNo,
|
||||
bankBranch: data.bankBranch,
|
||||
isDefault: count === 0 ? 1 : 0,
|
||||
status: 'ACTIVE',
|
||||
sortOrder: count,
|
||||
},
|
||||
});
|
||||
return this.serialize(created);
|
||||
}
|
||||
|
||||
async update(storeId: bigint, accountId: bigint, dto: StoreBankInput) {
|
||||
const account = await this.findAccount(storeId, accountId);
|
||||
const data = this.normalize(dto);
|
||||
const updated = await this.prisma.storeBankAccount.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
bankAccountName: data.bankAccountName,
|
||||
bankAccountNo: data.bankAccountNo,
|
||||
bankBranch: data.bankBranch,
|
||||
},
|
||||
});
|
||||
return this.serialize(updated);
|
||||
}
|
||||
|
||||
async remove(storeId: bigint, accountId: bigint) {
|
||||
const account = await this.findAccount(storeId, accountId);
|
||||
if (account.isDefault === 1) {
|
||||
throw new BadRequestException('请先取消默认账户再删除');
|
||||
}
|
||||
await this.prisma.storeBankAccount.delete({ where: { id: account.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async setDefault(storeId: bigint, accountId: bigint) {
|
||||
const account = await this.findAccount(storeId, accountId);
|
||||
if (account.isDefault === 1) return this.serialize(account);
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.storeBankAccount.updateMany({
|
||||
where: { storeId },
|
||||
data: { isDefault: 0 },
|
||||
}),
|
||||
this.prisma.storeBankAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { isDefault: 1, status: 'ACTIVE' },
|
||||
}),
|
||||
]);
|
||||
const updated = await this.findAccount(storeId, accountId);
|
||||
return this.serialize(updated);
|
||||
}
|
||||
|
||||
private async findAccount(storeId: bigint, accountId: bigint) {
|
||||
const account = await this.prisma.storeBankAccount.findFirst({
|
||||
where: { id: accountId, storeId },
|
||||
});
|
||||
if (!account) throw new NotFoundException('收款账户不存在');
|
||||
return account;
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,6 @@ import {
|
||||
} from './store-info-change.controller';
|
||||
import { StoreInfoChangeService } from './store-info-change.service';
|
||||
import { PartnerAssocService } from './partner-assoc.service';
|
||||
import { StoreBankService } from './store-bank.service';
|
||||
import { AdminStoreBankController, ShopStoreBankController } from './store-bank.controller';
|
||||
import {
|
||||
PartnerAssocController,
|
||||
PartnerCommissionController,
|
||||
@@ -69,10 +67,8 @@ import {
|
||||
UserPartnerAssocController,
|
||||
PartnerAssocController,
|
||||
PartnerCommissionController,
|
||||
ShopStoreBankController,
|
||||
AdminStoreBankController,
|
||||
],
|
||||
providers: [StoreService, StoreCategoryService, StorePackageService, StoreInfoChangeService, PartnerAssocService, StoreBankService],
|
||||
exports: [StoreService, StoreCategoryService, StorePackageService, PartnerAssocService, StoreBankService],
|
||||
providers: [StoreService, StoreCategoryService, StorePackageService, StoreInfoChangeService, PartnerAssocService],
|
||||
exports: [StoreService, StoreCategoryService, StorePackageService, PartnerAssocService],
|
||||
})
|
||||
export class StoreModule {}
|
||||
|
||||
Reference in New Issue
Block a user