feat(store): v4.0.18 门店多收款账户与子账号继承二维码
C 端门店列表拼接省市区县地址;门店多银行账户与默认打款账户;子账号独立 sa_ 关联码及统计维度;同步 v4.0.18 开发文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
|||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ import TencentLocPickerModal from '../components/TencentLocPickerModal';
|
|||||||
import AdminStorePackagesSection, {
|
import AdminStorePackagesSection, {
|
||||||
type AdminStorePackagesHandle,
|
type AdminStorePackagesHandle,
|
||||||
} from '../components/AdminStorePackagesSection';
|
} from '../components/AdminStorePackagesSection';
|
||||||
|
import StoreBankAccountsPanel from '../components/StoreBankAccountsPanel';
|
||||||
import StorePackageAuditPanel, {
|
import StorePackageAuditPanel, {
|
||||||
auditStorePackageRequest,
|
auditStorePackageRequest,
|
||||||
} from '../components/StorePackageAuditPanel';
|
} from '../components/StorePackageAuditPanel';
|
||||||
@@ -1297,6 +1298,14 @@ export default function StoresPage() {
|
|||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'bank-accounts',
|
||||||
|
label: '收款账户',
|
||||||
|
forceRender: true,
|
||||||
|
children: (
|
||||||
|
<StoreBankAccountsPanel storeId={String(detail.id)} />
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'media',
|
key: 'media',
|
||||||
label: '审核材料',
|
label: '审核材料',
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ export default function AssocQrcodePage() {
|
|||||||
<section className="partner-bill-card" style={{ margin: 16 }}>
|
<section className="partner-bill-card" style={{ margin: 16 }}>
|
||||||
<div style={{ padding: 24, textAlign: 'center' }}>
|
<div style={{ padding: 24, textAlign: 'center' }}>
|
||||||
<p className="body-md text-muted" style={{ marginBottom: 16 }}>
|
<p className="body-md text-muted" style={{ marginBottom: 16 }}>
|
||||||
用户扫码后首次锁定本合伙人,后续酒单按订单佣金结算
|
{summary?.isSubAccount
|
||||||
|
? '子账号专属二维码:用户扫码后锁定主账号(佣金归主账号),额外计入本子账号统计'
|
||||||
|
: '用户扫码后首次锁定本合伙人,后续酒单按订单佣金结算'}
|
||||||
</p>
|
</p>
|
||||||
{summary?.qrcodeUrl ? (
|
{summary?.qrcodeUrl ? (
|
||||||
<img
|
<img
|
||||||
@@ -75,6 +77,7 @@ export default function AssocQrcodePage() {
|
|||||||
)}
|
)}
|
||||||
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
<p className="label-md text-muted" style={{ marginTop: 12 }}>
|
||||||
已关联 {summary?.userCount ?? 0} 人
|
已关联 {summary?.userCount ?? 0} 人
|
||||||
|
{typeof summary?.scanCount === 'number' ? ` · 已扫码 ${summary.scanCount} 次` : ''}
|
||||||
</p>
|
</p>
|
||||||
<button type="button" className="partner-btn-primary" style={{ marginTop: 20 }} onClick={() => void downloadQr()}>
|
<button type="button" className="partner-btn-primary" style={{ marginTop: 20 }} onClick={() => void downloadQr()}>
|
||||||
下载二维码
|
下载二维码
|
||||||
|
|||||||
@@ -192,7 +192,9 @@ export default function UsersManagePage() {
|
|||||||
|
|
||||||
<section className="partner-bill-card" style={{ marginBottom: 20, textAlign: 'center', padding: 20 }}>
|
<section className="partner-bill-card" style={{ marginBottom: 20, textAlign: 'center', padding: 20 }}>
|
||||||
<p className="body-md text-muted" style={{ marginBottom: 12 }}>
|
<p className="body-md text-muted" style={{ marginBottom: 12 }}>
|
||||||
用户扫码后首次锁定,后续购酒计入关联订单
|
{summary?.isSubAccount
|
||||||
|
? '子账号专属二维码:扫码锁定主账号(佣金归主账号),下方统计仅计本子账号'
|
||||||
|
: '用户扫码后首次锁定,后续购酒计入关联订单'}
|
||||||
</p>
|
</p>
|
||||||
{summary?.activityPosterId && !hidePoster && (previewUrl || heroUrl) ? (
|
{summary?.activityPosterId && !hidePoster && (previewUrl || heroUrl) ? (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import StatusPage from './pages/StatusPage';
|
|||||||
import MinePage from './pages/MinePage';
|
import MinePage from './pages/MinePage';
|
||||||
import StaffPage from './pages/StaffPage';
|
import StaffPage from './pages/StaffPage';
|
||||||
import WithdrawPage from './pages/WithdrawPage';
|
import WithdrawPage from './pages/WithdrawPage';
|
||||||
|
import BankAccountsPage from './pages/BankAccountsPage';
|
||||||
import PackagesPage from './pages/PackagesPage';
|
import PackagesPage from './pages/PackagesPage';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@@ -25,6 +26,7 @@ export default function App() {
|
|||||||
<Route path="/select-store" element={<SelectStorePage />} />
|
<Route path="/select-store" element={<SelectStorePage />} />
|
||||||
<Route path="/staff" element={<StaffPage />} />
|
<Route path="/staff" element={<StaffPage />} />
|
||||||
<Route path="/withdraw" element={<WithdrawPage />} />
|
<Route path="/withdraw" element={<WithdrawPage />} />
|
||||||
|
<Route path="/bank-accounts" element={<BankAccountsPage />} />
|
||||||
<Route path="/packages" element={<PackagesPage />} />
|
<Route path="/packages" element={<PackagesPage />} />
|
||||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
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,6 +153,12 @@ export default function MinePage() {
|
|||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>account_balance_wallet</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>account_balance_wallet</span>
|
||||||
结算提现
|
结算提现
|
||||||
</button>
|
</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 ? (
|
{profile?.isPrimary ? (
|
||||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/packages')}>
|
<button type="button" className="shop-mine-action" onClick={() => navigate('/packages')}>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>restaurant_menu</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>restaurant_menu</span>
|
||||||
|
|||||||
@@ -1923,6 +1923,18 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-withdraw-input {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 6px;
|
||||||
|
height: 42px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--color-border, #e5e5e5);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
background: #fff;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
/* ─── 休息中核销·开张确认弹窗 ─── */
|
/* ─── 休息中核销·开张确认弹窗 ─── */
|
||||||
.shop-redeem-modal {
|
.shop-redeem-modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ type EnterOptionsLike = {
|
|||||||
function normalizeAssocScene(raw: unknown): string | null {
|
function normalizeAssocScene(raw: unknown): string | null {
|
||||||
if (raw == null || raw === '') return null;
|
if (raw == null || raw === '') return null;
|
||||||
const s = safeDecode(String(raw)).trim();
|
const s = safeDecode(String(raw)).trim();
|
||||||
return /^pa_\d+$/.test(s) ? s : null;
|
return /^(pa|sa)_\d+$/.test(s) ? s : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractAssocSceneFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
|
function extractAssocSceneFromEnterOptions(opts?: EnterOptionsLike | null): string | null {
|
||||||
|
|||||||
@@ -17,6 +17,22 @@ 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 function fullStoreAddress(
|
||||||
|
store: {
|
||||||
|
province?: string | null;
|
||||||
|
cityName?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
district?: string | null;
|
||||||
|
address?: string | null;
|
||||||
|
},
|
||||||
|
fallback = '地址待完善',
|
||||||
|
): string {
|
||||||
|
const city = store.cityName || store.city || '';
|
||||||
|
const text = `${store.province || ''}${city}${store.district || ''}${store.address || ''}`.trim();
|
||||||
|
return text || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
export function storeCategoryTags(
|
export function storeCategoryTags(
|
||||||
store: {
|
store: {
|
||||||
tags?: unknown;
|
tags?: unknown;
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import {
|
|||||||
toWeappShareTimeline,
|
toWeappShareTimeline,
|
||||||
} from '../../lib/wechat-share';
|
} from '../../lib/wechat-share';
|
||||||
import BenefitSloganBar from '../../components/BenefitSloganBar';
|
import BenefitSloganBar from '../../components/BenefitSloganBar';
|
||||||
import { storeCategoryTags, storeLeafCategoryIds, storeStarCount } from '../../lib/store-display';
|
import { fullStoreAddress, 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 = {
|
||||||
@@ -317,7 +317,12 @@ export default function StoresPage() {
|
|||||||
if (!matchesCategory(s)) return false;
|
if (!matchesCategory(s)) return false;
|
||||||
if (!keyword.trim()) return true;
|
if (!keyword.trim()) return true;
|
||||||
const q = keyword.trim();
|
const q = keyword.trim();
|
||||||
return s.name.includes(q) || (s.address ?? '').includes(q) || (s.district ?? '').includes(q);
|
return (
|
||||||
|
s.name.includes(q) ||
|
||||||
|
(s.address ?? '').includes(q) ||
|
||||||
|
(s.district ?? '').includes(q) ||
|
||||||
|
fullStoreAddress(s, '').includes(q)
|
||||||
|
);
|
||||||
});
|
});
|
||||||
const next = [...list];
|
const next = [...list];
|
||||||
next.sort((a, b) => {
|
next.sort((a, b) => {
|
||||||
@@ -498,7 +503,7 @@ export default function StoresPage() {
|
|||||||
</View>
|
</View>
|
||||||
<View className="store-card-row store-card-row--mid">
|
<View className="store-card-row store-card-row--mid">
|
||||||
<Text className="store-card-address" numberOfLines={2}>
|
<Text className="store-card-address" numberOfLines={2}>
|
||||||
{s.address || (s.district ? `${s.district}` : '地址待完善')}
|
{fullStoreAddress(s)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="store-card-distance">
|
<Text className="store-card-distance">
|
||||||
{formatDistanceMeters(s.distanceMeters)}
|
{formatDistanceMeters(s.distanceMeters)}
|
||||||
|
|||||||
+13
-4
@@ -1,8 +1,8 @@
|
|||||||
# 杜康好客 · 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**(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-现状对照`](./杜康好客-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,6 +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) |
|
||||||
|
|
||||||
## 1. 锚点(沿用 V3,佣金归属改写)
|
## 1. 锚点(沿用 V3,佣金归属改写)
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
- 主账号首页两张卡:「关联用户」(按 `assocBoundAt` 拆本日/本月)、「关联用户订单」(已付购酒单且用户**当前**关联本合伙人,按 `paidAt` 拆本日/本月)。文案不用「佣金订单」——与账单快照 `partner_account_id_at_pay` 可能不完全重合。子账号不展示。
|
- 主账号首页两张卡:「关联用户」(按 `assocBoundAt` 拆本日/本月)、「关联用户订单」(已付购酒单且用户**当前**关联本合伙人,按 `paidAt` 拆本日/本月)。文案不用「佣金订单」——与账单快照 `partner_account_id_at_pay` 可能不完全重合。子账号不展示。
|
||||||
- 主账号底部 Tab:首页 / **用户管理** / 门店管理 / 合伙人中心。用户管理页 = 关联码 + 已关联用户列表(搜索昵称/手机/编号/本合伙人备注;排序关联时间/注册时间/订单数)。点订单数看该用户已付购酒单。
|
- 主账号底部 Tab:首页 / **用户管理** / 门店管理 / 合伙人中心。用户管理页 = 关联码 + 已关联用户列表(搜索昵称/手机/编号/本合伙人备注;排序关联时间/注册时间/订单数)。点订单数看该用户已付购酒单。
|
||||||
- **子账号**也可进入用户管理:可看关联码、下载二维码、看已关联用户;**不能**看活动图入口与合成主图(只出纯关联码)。
|
- **子账号**也可进入用户管理:可看关联码、下载二维码、看已关联用户;**不能**看活动图入口与合成主图(只出纯关联码)。
|
||||||
|
- **子账号继承二维码**(v4.0.18):子账号有**自己的**小程序码(`getwxacodeunlimit`,scene=`sa_{subAccountId}`),与主账号 `pa_{partnerId}` 前缀、id 均不同,不冲突。扫码**仍锁定主账号**(佣金归主账号,first-lock 校验主账号),额外写入 `user_user.assoc_sub_account_id` 记录子账号归属,形成**新增一级子账号维度统计**。子账号在用户管理/关联码页展示并下载**自己的**码、看**自己维度**的已扫码/已关联/订单;主账号已扫码在读取时聚合 own+children,主账号行为不变。
|
||||||
- 用户管理页二维码下方展示「已扫码」与「已关联」人数;为 0 的段不显示。已扫码 = C 端带 `pa_` scene 进入时累加(未登录也计),与已关联人数独立。
|
- 用户管理页二维码下方展示「已扫码」与「已关联」人数;为 0 的段不显示。已扫码 = C 端带 `pa_` scene 进入时累加(未登录也计),与已关联人数独立。
|
||||||
- 主账号新建子账号默认 **ACTIVE**(可立即登录),列表中可再禁用。
|
- 主账号新建子账号默认 **ACTIVE**(可立即登录),列表中可再禁用。
|
||||||
- 主账号可在合伙人中心填写收款账户:收款人、银行账号、开户行名称(写入主账号 `bank_account_*`)。
|
- 主账号可在合伙人中心填写收款账户:收款人、银行账号、开户行名称(写入主账号 `bank_account_*`)。
|
||||||
@@ -97,6 +99,13 @@ HQ 财务详情与合伙人确认页均展示两段列表。不再「无快照
|
|||||||
- **应付为 0 仍出账**:出账日无订单或应付为 0 仍生成账单;业务状态展示「无需打款」(DB 仍为 `UNPAID`,不可确认打款)。
|
- **应付为 0 仍出账**:出账日无订单或应付为 0 仍生成账单;业务状态展示「无需打款」(DB 仍为 `UNPAID`,不可确认打款)。
|
||||||
- **已打款不回刷**;未打款账单可重算;明细 `orderId` 冲突时从其他未打款账单迁入。
|
- **已打款不回刷**;未打款账单可重算;明细 `orderId` 冲突时从其他未打款账单迁入。
|
||||||
|
|
||||||
## 8. 不做
|
## 8. 门店收款账户(v4.0.18)
|
||||||
|
|
||||||
改推广码体系;改核销归属;子账号自己的码;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
|
- 一个门店可维护**多个**收款银行账户(表 `store_bank_account`,挂在 `storeId`);`is_default=1` 为打款默认账户(每店至多一个)。
|
||||||
|
- 打款/核销结算/提现/账单导出统一读取**默认账户**(无默认取第一个 ACTIVE;回退旧主账号字段兼容存量)。
|
||||||
|
- **切换/新增/删除账户无需重启服务器**:银行字段均实时查库,无进程内缓存。
|
||||||
|
- 权限:门店主账号维护本店账户(子账号只读);总部可维护任意门店账户。
|
||||||
|
|
||||||
|
## 9. 不做
|
||||||
|
|
||||||
|
改推广码体系;改核销归属;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
> 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md)
|
> 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md)
|
||||||
> V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。
|
> V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。
|
||||||
|
|
||||||
## 0. 总览(2026-09-02)
|
## 0. 总览(2026-09-07)
|
||||||
|
|
||||||
| 维度 | 结论 |
|
| 维度 | 结论 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 版本线 | **v4.0.15** HQ 概览折线图(粒度分桶、总量/增量、维度线条) |
|
| 版本线 | **v4.0.18** 门店多收款账户 + 子账号继承二维码(含 v4.0.15 HQ 概览折线图) |
|
||||||
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
||||||
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
||||||
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
||||||
@@ -24,6 +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) | ✅ 已实现 |
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -37,3 +38,4 @@
|
|||||||
| 2026-09-02 | v4.0.13:收货禁「全市」;脏地址下单拦截;小飞侠超区/推单失败挂 `fulfillmentHold`(不做仓/收件坐标) |
|
| 2026-09-02 | v4.0.13:收货禁「全市」;脏地址下单拦截;小飞侠超区/推单失败挂 `fulfillmentHold`(不做仓/收件坐标) |
|
||||||
| 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 端门店列表省市区县地址;门店多收款账户(默认账户打款、切换无需重启);子账号独立继承码 + 子账号维度统计 |
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# 杜康好客 · v4.0.18 开发文档
|
||||||
|
|
||||||
|
> **2026-09-07** · mini-user / store / settlement / iam / h5-shop / admin-web / h5-partner / shared-types
|
||||||
|
> **主题**:C 端门店列表省市区县地址;门店多收款账户;子账号独立继承二维码
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
| # | 任务 | 类型 | 交付 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | C 端门店列表地址 | 需求 | 「省市区县 + 详细地址」拼接展示,搜索同步匹配完整地址 |
|
||||||
|
| 2 | 门店多银行账号 | 需求 | 门店级收款账户列表;设「默认」账户用于打款;**切换无需重启** |
|
||||||
|
| 3 | 子账号二维码 | 需求 | 子账号独立继承码 `sa_{subId}`;新增一级子账号维度统计 |
|
||||||
|
|
||||||
|
**不做**:银行账号历史打款回刷;子账号佣金独立归属(佣金仍归主账号);按承运商维度的收款账户。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 规则
|
||||||
|
|
||||||
|
### 2.1 门店列表地址
|
||||||
|
|
||||||
|
接口已返回 `province` / `cityName` / `district` / `address`,纯前端拼接。复用助手 `fullStoreAddress(store)`(`province + (cityName ?? city) + district + address` trim,空回退「地址待完善」),与门店详情页逻辑一致。
|
||||||
|
|
||||||
|
### 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`),属微信支付、非银行账号。
|
||||||
|
- 权限:门店主账号可维护本店账户(子账号只读);总部可维护任意门店账户。
|
||||||
|
|
||||||
|
### 2.3 子账号继承二维码
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
scanC[C端扫码 sa_subId] --> touch[touchScan 子账号 assocScanCount+1]
|
||||||
|
scanC --> bind[bindUser]
|
||||||
|
bind -->|first-lock| setAssoc[user.assocPartnerAccountId=主账号, assocSubAccountId=子账号]
|
||||||
|
setAssoc --> main[主账号聚合统计: 关联用户/订单]
|
||||||
|
setAssoc --> sub[子账号统计: 已扫码/已关联/订单]
|
||||||
|
```
|
||||||
|
|
||||||
|
- 复用 `partner_account.assoc_qrcode_id / assoc_qrcode_resource_id / assoc_scan_count`:子账号行存自己的 `sa_{subId}` 码(`assoc_qrcode_id` 为 `@unique`,`sa_` 与 `pa_` 前缀、id 均不同,不冲突)。
|
||||||
|
- 新增 `user_user.assoc_sub_account_id`:记录带来该用户的子账号(主账号码扫码则为空)。
|
||||||
|
- `touchScan`:`sa_` 场景对**子账号行** `assocScanCount` 自增;主账号已扫码在读取时聚合 own+children。
|
||||||
|
- `bindUser`:`sa_` 场景解析子账号 → 主账号;first-lock 仍校验主账号,写入 `assocPartnerAccountId=主账号` + `assocSubAccountId=子账号`。
|
||||||
|
- `getSummary`/`getStats`/`listUsers`/`listAssocOrders`:子账号调用时返回**自己维度**数据(`assocSubAccountId=子账号`);`activityPosterId` 恒为空;主账号保持聚合行为不变。
|
||||||
|
- 佣金仍归主账号,不因扫码来源为子账号而改变归属。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 模块)
|
||||||
|
|
||||||
|
- `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 }`。
|
||||||
|
- `GET /partner/assoc`(summary):子账号返回 `{ partnerId, primaryAccountId, isSubAccount, qrcodeUrl, userCount, scanCount }`。
|
||||||
|
- `GET /partner/assoc/stats`、`GET /partner/assoc/users`、`GET /partner/assoc/orders`:子账号返回自己维度。
|
||||||
|
- `GET /partner/assoc/qrcode`:子账号下载自己的码。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 变更面
|
||||||
|
|
||||||
|
| 层 | 路径 |
|
||||||
|
|----|------|
|
||||||
|
| Prisma | `schema.prisma`(新增 `StoreBankAccount`;`User.assocSubAccountId`) |
|
||||||
|
| 迁移 | `migrate-store-bank-account-v4018.sql`、`migrate-user-assoc-sub-account-v4018.sql` |
|
||||||
|
| shared-types | `settlement.ts`(`StoreBankAccountDto`);`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/统计) |
|
||||||
|
| API settlement | `settlement.service.ts`(summary/withdraw/admin-withdrawal/export 改读默认账户) |
|
||||||
|
| API ops | `admin-redeem.service.ts`(默认账户) |
|
||||||
|
| mini-user | `stores/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`(收款账户页签) |
|
||||||
|
| h5-partner | `AssocQrcodePage.tsx`、`UsersManagePage.tsx`(子账号提示) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验收
|
||||||
|
|
||||||
|
- [ ] C 端门店列表地址显示「省市区县 + 详细地址」;空值回退「地址待完善」;搜索「省/市/区县」关键词可命中
|
||||||
|
- [ ] 门店可新增/编辑/删除/设默认收款账户;默认账户用于提现打款与账单导出
|
||||||
|
- [ ] 切换默认账户后(无需重启)提现 summary 与打款信息即时生效
|
||||||
|
- [ ] 子账号可生成并下载自己的二维码(scene `sa_{subId}`),与主账号 `pa_{id}` 不冲突
|
||||||
|
- [ ] 扫子账号码:用户仍锁定主账号(佣金归主账号),并写入 `assoc_sub_account_id`
|
||||||
|
- [ ] 子账号用户管理/关联码页展示自己维度的已扫码/已关联/订单统计;主账号聚合 own+children 不变
|
||||||
|
- [ ] 主账号码扫码:`assoc_sub_account_id` 为空,行为与 v4.0.9 一致
|
||||||
|
- [ ] shared-types 构建通过;相关 lint/单测过
|
||||||
@@ -11,6 +11,7 @@ export type PartnerAssocTouchRequest = {
|
|||||||
|
|
||||||
export type PartnerAssocTouchResult = {
|
export type PartnerAssocTouchResult = {
|
||||||
partnerId: string;
|
partnerId: string;
|
||||||
|
subAccountId?: string | null;
|
||||||
scanCounted: boolean;
|
scanCounted: boolean;
|
||||||
scanCount: number;
|
scanCount: number;
|
||||||
};
|
};
|
||||||
@@ -19,11 +20,16 @@ export type PartnerAssocBindResult = {
|
|||||||
bound: boolean;
|
bound: boolean;
|
||||||
alreadyBound: boolean;
|
alreadyBound: boolean;
|
||||||
partnerId: string;
|
partnerId: string;
|
||||||
|
subAccountId?: string | null;
|
||||||
partnerName: string;
|
partnerName: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PartnerAssocSummary = {
|
export type PartnerAssocSummary = {
|
||||||
partnerId: string;
|
partnerId: string;
|
||||||
|
/** 子账号时返回主账号 ID */
|
||||||
|
primaryAccountId?: string;
|
||||||
|
/** 是否为子账号自己的二维码维度 */
|
||||||
|
isSubAccount?: boolean;
|
||||||
qrcodeUrl: string | null;
|
qrcodeUrl: string | null;
|
||||||
userCount: number;
|
userCount: number;
|
||||||
/** 关联码扫码进入次数(未登录也计) */
|
/** 关联码扫码进入次数(未登录也计) */
|
||||||
|
|||||||
@@ -55,6 +55,25 @@ export interface StoreWithdrawBankAccountDto {
|
|||||||
bankBranch?: string | null;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoreWithdrawSummaryDto {
|
export interface StoreWithdrawSummaryDto {
|
||||||
availableAmount: number;
|
availableAmount: number;
|
||||||
pendingReviewAmount: number;
|
pendingReviewAmount: number;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
-- v4.0.18:门店多收款银行账户(列表 + 默认账户)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `store_bank_account` (
|
||||||
|
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
`store_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`bank_account_name` VARCHAR(64) NOT NULL,
|
||||||
|
`bank_account_no` VARCHAR(32) NOT NULL,
|
||||||
|
`bank_branch` VARCHAR(128) NULL,
|
||||||
|
`is_default` TINYINT NOT NULL DEFAULT 0,
|
||||||
|
`status` ENUM('ACTIVE','DISABLED') NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
`sort_order` INT NOT NULL DEFAULT 0,
|
||||||
|
`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_store_bank_account_store_status` (`store_id`, `status`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='门店收款银行账户';
|
||||||
|
|
||||||
|
-- 回填:按门店主账号(is_primary=1,缺失则取最早绑定账号)的收款字段,为每店生成一条默认账户
|
||||||
|
INSERT INTO `store_bank_account`
|
||||||
|
(`store_id`, `bank_account_name`, `bank_account_no`, `bank_branch`, `is_default`, `status`, `sort_order`)
|
||||||
|
SELECT
|
||||||
|
b.`store_id`,
|
||||||
|
a.`bank_account_name`,
|
||||||
|
a.`bank_account_no`,
|
||||||
|
a.`bank_branch`,
|
||||||
|
1,
|
||||||
|
'ACTIVE',
|
||||||
|
0
|
||||||
|
FROM `store_account_store` b
|
||||||
|
JOIN `store_account` a ON a.`id` = b.`store_account_id`
|
||||||
|
WHERE a.`bank_account_name` IS NOT NULL
|
||||||
|
AND TRIM(a.`bank_account_name`) <> ''
|
||||||
|
AND a.`bank_account_no` IS NOT NULL
|
||||||
|
AND TRIM(a.`bank_account_no`) <> ''
|
||||||
|
AND b.`store_account_id` = (
|
||||||
|
SELECT b2.`store_account_id`
|
||||||
|
FROM `store_account_store` b2
|
||||||
|
JOIN `store_account` a2 ON a2.`id` = b2.`store_account_id`
|
||||||
|
WHERE b2.`store_id` = b.`store_id`
|
||||||
|
ORDER BY (a2.`is_primary` = 1) DESC, a2.`id` ASC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE `updated_at` = `updated_at`;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- v4.0.18:子账号独立继承二维码 —— 记录带来用户的子账号归属
|
||||||
|
|
||||||
|
ALTER TABLE `user_user`
|
||||||
|
ADD COLUMN `assoc_sub_account_id` BIGINT UNSIGNED NULL AFTER `assoc_partner_account_id`,
|
||||||
|
ADD KEY `idx_user_user_assoc_sub_account_id` (`assoc_sub_account_id`);
|
||||||
@@ -1278,6 +1278,7 @@ model PartnerAccount {
|
|||||||
stores Store[]
|
stores Store[]
|
||||||
bills PartnerBill[]
|
bills PartnerBill[]
|
||||||
assocUsers User[] @relation("UserPartnerAssoc")
|
assocUsers User[] @relation("UserPartnerAssoc")
|
||||||
|
assocSubUsers User[] @relation("UserSubAccountAssoc")
|
||||||
userNotes PartnerUserNote[]
|
userNotes PartnerUserNote[]
|
||||||
assocQrcodeResource CommonResource? @relation("PartnerAssocQrcode", fields: [assocQrcodeResourceId], references: [id], onDelete: SetNull)
|
assocQrcodeResource CommonResource? @relation("PartnerAssocQrcode", fields: [assocQrcodeResourceId], references: [id], onDelete: SetNull)
|
||||||
activityPoster ActivityPoster? @relation(fields: [activityPosterId], references: [id], onDelete: SetNull)
|
activityPoster ActivityPoster? @relation(fields: [activityPosterId], references: [id], onDelete: SetNull)
|
||||||
@@ -1457,6 +1458,7 @@ model User {
|
|||||||
sourceLabel String? @map("source_label") @db.VarChar(128)
|
sourceLabel String? @map("source_label") @db.VarChar(128)
|
||||||
referrerUserId BigInt? @map("referrer_user_id") @db.UnsignedBigInt
|
referrerUserId BigInt? @map("referrer_user_id") @db.UnsignedBigInt
|
||||||
assocPartnerAccountId BigInt? @map("assoc_partner_account_id") @db.UnsignedBigInt
|
assocPartnerAccountId BigInt? @map("assoc_partner_account_id") @db.UnsignedBigInt
|
||||||
|
assocSubAccountId BigInt? @map("assoc_sub_account_id") @db.UnsignedBigInt
|
||||||
assocBoundAt DateTime? @map("assoc_bound_at") @db.DateTime(3)
|
assocBoundAt DateTime? @map("assoc_bound_at") @db.DateTime(3)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
@@ -1466,6 +1468,7 @@ model User {
|
|||||||
referrer User? @relation("UserReferrer", fields: [referrerUserId], references: [id], onDelete: SetNull)
|
referrer User? @relation("UserReferrer", fields: [referrerUserId], references: [id], onDelete: SetNull)
|
||||||
referrers User[] @relation("UserReferrer")
|
referrers User[] @relation("UserReferrer")
|
||||||
assocPartner PartnerAccount? @relation("UserPartnerAssoc", fields: [assocPartnerAccountId], references: [id], onDelete: SetNull)
|
assocPartner PartnerAccount? @relation("UserPartnerAssoc", fields: [assocPartnerAccountId], references: [id], onDelete: SetNull)
|
||||||
|
assocSubAccount PartnerAccount? @relation("UserSubAccountAssoc", fields: [assocSubAccountId], references: [id], onDelete: SetNull)
|
||||||
partnerNotes PartnerUserNote[]
|
partnerNotes PartnerUserNote[]
|
||||||
avatar CommonResource? @relation("UserAvatar", fields: [avatarResourceId], references: [id], onDelete: SetNull)
|
avatar CommonResource? @relation("UserAvatar", fields: [avatarResourceId], references: [id], onDelete: SetNull)
|
||||||
addresses UserAddress[]
|
addresses UserAddress[]
|
||||||
@@ -1482,6 +1485,7 @@ model User {
|
|||||||
@@index([sourceType, sourceRefId])
|
@@index([sourceType, sourceRefId])
|
||||||
@@index([referrerUserId])
|
@@index([referrerUserId])
|
||||||
@@index([assocPartnerAccountId])
|
@@index([assocPartnerAccountId])
|
||||||
|
@@index([assocSubAccountId])
|
||||||
@@index([mergedIntoUserId])
|
@@index([mergedIntoUserId])
|
||||||
@@index([wxOpenId])
|
@@index([wxOpenId])
|
||||||
@@index([isTest])
|
@@index([isTest])
|
||||||
@@ -1598,6 +1602,7 @@ model Store {
|
|||||||
packageChangeRequests StorePackageChangeRequest[]
|
packageChangeRequests StorePackageChangeRequest[]
|
||||||
infoChangeRequests StoreInfoChangeRequest[]
|
infoChangeRequests StoreInfoChangeRequest[]
|
||||||
categoryLinks StoreCategoryLink[]
|
categoryLinks StoreCategoryLink[]
|
||||||
|
bankAccounts StoreBankAccount[]
|
||||||
|
|
||||||
@@index([cityId, status])
|
@@index([cityId, status])
|
||||||
@@index([partnerAccountId])
|
@@index([partnerAccountId])
|
||||||
@@ -1607,6 +1612,25 @@ model Store {
|
|||||||
@@map("store_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)
|
/// Store visibility whitelist phones (match by bound user phone)
|
||||||
model StoreVisibilityPhone {
|
model StoreVisibilityPhone {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
|
|||||||
@@ -6,6 +6,55 @@ export type StoreBankAccountSnapshot = {
|
|||||||
bankBranch: string | null;
|
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 表示无收款账户。
|
||||||
|
*/
|
||||||
|
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(
|
export async function loadStorePrimaryBank(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
storeId: bigint,
|
storeId: bigint,
|
||||||
@@ -37,28 +86,50 @@ export async function loadStorePrimaryBanksMap(
|
|||||||
): Promise<Map<string, StoreBankAccountSnapshot>> {
|
): Promise<Map<string, StoreBankAccountSnapshot>> {
|
||||||
const map = new Map<string, StoreBankAccountSnapshot>();
|
const map = new Map<string, StoreBankAccountSnapshot>();
|
||||||
if (!storeIds.length) return map;
|
if (!storeIds.length) return map;
|
||||||
const bindings = await prisma.storeAccountStore.findMany({
|
|
||||||
where: { storeId: { in: storeIds } },
|
// 优先从门店多账户表读取默认账户
|
||||||
include: {
|
const accounts = await prisma.storeBankAccount.findMany({
|
||||||
storeAccount: {
|
where: { storeId: { in: storeIds }, status: 'ACTIVE' },
|
||||||
select: {
|
orderBy: [{ storeId: 'asc' }, { isDefault: 'desc' }, { sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
isPrimary: true,
|
|
||||||
bankAccountName: true,
|
|
||||||
bankAccountNo: true,
|
|
||||||
bankBranch: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
orderBy: [{ storeAccount: { isPrimary: 'desc' } }, { storeAccountId: 'asc' }],
|
|
||||||
});
|
});
|
||||||
for (const binding of bindings) {
|
const seenStoreIds = new Set<string>();
|
||||||
const key = String(binding.storeId);
|
for (const a of accounts) {
|
||||||
|
const key = String(a.storeId);
|
||||||
if (map.has(key)) continue;
|
if (map.has(key)) continue;
|
||||||
map.set(key, {
|
map.set(key, {
|
||||||
bankAccountName: binding.storeAccount.bankAccountName,
|
bankAccountName: a.bankAccountName,
|
||||||
bankAccountNo: binding.storeAccount.bankAccountNo,
|
bankAccountNo: a.bankAccountNo,
|
||||||
bankBranch: binding.storeAccount.bankBranch,
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import type { DeliveryProvider } from '@prisma/client';
|
import type { DeliveryProvider } from '@prisma/client';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { loadStorePrimaryBank } from '../../common/store/store-bank.util';
|
import { loadStoreDefaultBank } from '../../common/store/store-bank.util';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||||
import { calcDeliveryFreightAmount } from '../fulfillment/delivery-freight.util';
|
import { calcDeliveryFreightAmount } from '../fulfillment/delivery-freight.util';
|
||||||
@@ -116,7 +116,7 @@ export class AdminRedeemService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!record) throw new NotFoundException('核销记录不存在');
|
if (!record) throw new NotFoundException('核销记录不存在');
|
||||||
const primaryAccount = await loadStorePrimaryBank(this.prisma, record.storeId);
|
const primaryAccount = await loadStoreDefaultBank(this.prisma, record.storeId);
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...record,
|
...record,
|
||||||
store: {
|
store: {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ import { AnalyticsService } from '../analytics/analytics.service';
|
|||||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||||
import {
|
import {
|
||||||
loadStorePrimaryBank,
|
loadStoreDefaultBank,
|
||||||
loadStorePrimaryBanksMap,
|
loadStorePrimaryBanksMap,
|
||||||
loadWineryBankConfig,
|
loadWineryBankConfig,
|
||||||
formatWecomBankAccount,
|
formatWecomBankAccount,
|
||||||
@@ -540,16 +540,14 @@ export class SettlementService implements OnModuleInit {
|
|||||||
|
|
||||||
async getShopWithdrawSummary(storeAccountId: bigint, storeId: bigint) {
|
async getShopWithdrawSummary(storeAccountId: bigint, storeId: bigint) {
|
||||||
await this.assertShopStoreAccess(storeAccountId, storeId);
|
await this.assertShopStoreAccess(storeAccountId, storeId);
|
||||||
const [account, available, pending, todayApplied] = await Promise.all([
|
const [account, bank, available, pending, todayApplied] = await Promise.all([
|
||||||
this.prisma.storeAccount.findUniqueOrThrow({
|
this.prisma.storeAccount.findUniqueOrThrow({
|
||||||
where: { id: storeAccountId },
|
where: { id: storeAccountId },
|
||||||
select: {
|
select: {
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
bankAccountName: true,
|
|
||||||
bankAccountNo: true,
|
|
||||||
bankBranch: true,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
loadStoreDefaultBank(this.prisma, storeId),
|
||||||
this.listAvailableUnbilledPayouts(storeId),
|
this.listAvailableUnbilledPayouts(storeId),
|
||||||
this.prisma.storeWithdrawRequest.findFirst({
|
this.prisma.storeWithdrawRequest.findFirst({
|
||||||
where: { storeId, status: 'PENDING_REVIEW' },
|
where: { storeId, status: 'PENDING_REVIEW' },
|
||||||
@@ -562,10 +560,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })),
|
available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })),
|
||||||
);
|
);
|
||||||
const dailyLimit = getStoreWithdrawDailyLimit();
|
const dailyLimit = getStoreWithdrawDailyLimit();
|
||||||
const hasBankAccount = !!(
|
const hasBankAccount = !!(bank?.bankAccountName?.trim() && bank?.bankAccountNo?.trim());
|
||||||
account.bankAccountName?.trim() &&
|
|
||||||
account.bankAccountNo?.trim()
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
availableAmount,
|
availableAmount,
|
||||||
@@ -575,11 +570,13 @@ export class SettlementService implements OnModuleInit {
|
|||||||
remainingDailyLimit: Math.max(0, round2(dailyLimit - todayApplied)),
|
remainingDailyLimit: Math.max(0, round2(dailyLimit - todayApplied)),
|
||||||
isPrimary: account.isPrimary === 1,
|
isPrimary: account.isPrimary === 1,
|
||||||
hasBankAccount,
|
hasBankAccount,
|
||||||
bankAccount: {
|
bankAccount: bank
|
||||||
bankAccountName: account.bankAccountName,
|
? {
|
||||||
bankAccountNo: account.bankAccountNo,
|
bankAccountName: bank.bankAccountName,
|
||||||
bankBranch: account.bankBranch,
|
bankAccountNo: bank.bankAccountNo,
|
||||||
},
|
bankBranch: bank.bankBranch,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -590,7 +587,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
) {
|
) {
|
||||||
await this.assertShopStoreAccess(storeAccountId, storeId);
|
await this.assertShopStoreAccess(storeAccountId, storeId);
|
||||||
|
|
||||||
const [store, account, available, pending, todayApplied] = await Promise.all([
|
const [store, account, bank, available, pending, todayApplied] = await Promise.all([
|
||||||
this.prisma.store.findUniqueOrThrow({
|
this.prisma.store.findUniqueOrThrow({
|
||||||
where: { id: storeId },
|
where: { id: storeId },
|
||||||
select: { id: true, name: true, phone: true, cityName: true },
|
select: { id: true, name: true, phone: true, cityName: true },
|
||||||
@@ -599,10 +596,9 @@ export class SettlementService implements OnModuleInit {
|
|||||||
where: { id: storeAccountId },
|
where: { id: storeAccountId },
|
||||||
select: {
|
select: {
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
bankAccountName: true,
|
|
||||||
bankAccountNo: true,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
loadStoreDefaultBank(this.prisma, storeId),
|
||||||
this.listAvailableUnbilledPayouts(storeId),
|
this.listAvailableUnbilledPayouts(storeId),
|
||||||
this.prisma.storeWithdrawRequest.findFirst({
|
this.prisma.storeWithdrawRequest.findFirst({
|
||||||
where: { storeId, status: 'PENDING_REVIEW' },
|
where: { storeId, status: 'PENDING_REVIEW' },
|
||||||
@@ -619,10 +615,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })),
|
available.map((p) => ({ payoutAmount: Number(p.payoutAmount) })),
|
||||||
);
|
);
|
||||||
const dailyLimit = getStoreWithdrawDailyLimit();
|
const dailyLimit = getStoreWithdrawDailyLimit();
|
||||||
const hasBankAccount = !!(
|
const hasBankAccount = !!(bank?.bankAccountName?.trim() && bank?.bankAccountNo?.trim());
|
||||||
account.bankAccountName?.trim() &&
|
|
||||||
account.bankAccountNo?.trim()
|
|
||||||
);
|
|
||||||
const requestAmount =
|
const requestAmount =
|
||||||
dto?.amount != null && Number.isFinite(Number(dto.amount))
|
dto?.amount != null && Number.isFinite(Number(dto.amount))
|
||||||
? round2(Number(dto.amount))
|
? round2(Number(dto.amount))
|
||||||
@@ -827,8 +820,10 @@ export class SettlementService implements OnModuleInit {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!row) throw new NotFoundException('提现申请不存在');
|
if (!row) throw new NotFoundException('提现申请不存在');
|
||||||
|
const bankAccount = await loadStoreDefaultBank(this.prisma, row.storeId);
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...row,
|
...row,
|
||||||
|
bankAccount,
|
||||||
paymentProofUrls: parsePaymentProofUrls(row.paymentProofUrls),
|
paymentProofUrls: parsePaymentProofUrls(row.paymentProofUrls),
|
||||||
overdue:
|
overdue:
|
||||||
row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt) : false,
|
row.status === 'PENDING_REVIEW' ? isWithdrawOverdue(row.appliedAt) : false,
|
||||||
@@ -900,7 +895,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
loadStorePrimaryBank(this.prisma, row.storeId),
|
loadStoreDefaultBank(this.prisma, row.storeId),
|
||||||
]);
|
]);
|
||||||
const meta = storeWecomMeta(store, String(row.storeId));
|
const meta = storeWecomMeta(store, String(row.storeId));
|
||||||
const bankParts = wecomBankParts(bank);
|
const bankParts = wecomBankParts(bank);
|
||||||
@@ -1543,7 +1538,7 @@ export class SettlementService implements OnModuleInit {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!bill) throw new NotFoundException('门店对账单不存在');
|
if (!bill) throw new NotFoundException('门店对账单不存在');
|
||||||
const storeAccount = await loadStorePrimaryBank(this.prisma, bill.storeId);
|
const storeAccount = await loadStoreDefaultBank(this.prisma, bill.storeId);
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
...bill,
|
...bill,
|
||||||
...mapStoreBillDates(bill.billDate),
|
...mapStoreBillDates(bill.billDate),
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma, type PartnerAccount } from '@prisma/client';
|
||||||
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 { PartnerCityService } from '../city-scope/partner-city.service';
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
@@ -13,7 +13,8 @@ import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.c
|
|||||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||||
|
|
||||||
const ASSOC_SCENE_PREFIX = 'pa_';
|
const PRIMARY_SCENE_PREFIX = 'pa_';
|
||||||
|
const SUB_SCENE_PREFIX = 'sa_';
|
||||||
|
|
||||||
function maskPhoneNumber(phone: string | null) {
|
function maskPhoneNumber(phone: string | null) {
|
||||||
if (!phone || phone.length < 7) return phone;
|
if (!phone || phone.length < 7) return phone;
|
||||||
@@ -27,12 +28,20 @@ function dayBounds(now = new Date()) {
|
|||||||
return { todayStart, monthStart };
|
return { todayStart, monthStart };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAssocScene(raw?: string | null): string | null {
|
export type AssocScene = { kind: 'primary' | 'sub'; id: string };
|
||||||
|
|
||||||
|
export function parseAssocScene(raw?: string | null): AssocScene | null {
|
||||||
const s = String(raw ?? '').trim();
|
const s = String(raw ?? '').trim();
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
if (s.startsWith(ASSOC_SCENE_PREFIX)) {
|
const prefixes: Array<[string, AssocScene['kind']]> = [
|
||||||
const id = s.slice(ASSOC_SCENE_PREFIX.length);
|
[PRIMARY_SCENE_PREFIX, 'primary'],
|
||||||
return /^\d+$/.test(id) ? id : null;
|
[SUB_SCENE_PREFIX, 'sub'],
|
||||||
|
];
|
||||||
|
for (const [prefix, kind] of prefixes) {
|
||||||
|
if (s.startsWith(prefix)) {
|
||||||
|
const id = s.slice(prefix.length);
|
||||||
|
return /^\d+$/.test(id) ? { kind, id } : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -48,15 +57,42 @@ export class PartnerAssocService {
|
|||||||
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async bindUser(userId: bigint, input: { scene?: string; partnerId?: string }) {
|
/** 解析 scene/partnerId,返回主账号与(可选的)子账号;校验激活状态 */
|
||||||
const partnerIdRaw = parseAssocScene(input.scene) || input.partnerId?.trim();
|
private async resolveAssocTarget(input: { scene?: string; partnerId?: string }) {
|
||||||
if (!partnerIdRaw || !/^\d+$/.test(partnerIdRaw)) {
|
const parsed = parseAssocScene(input.scene);
|
||||||
throw new BadRequestException('关联码无效');
|
let accountId: bigint;
|
||||||
|
let sub: PartnerAccount | null = null;
|
||||||
|
|
||||||
|
if (parsed) {
|
||||||
|
accountId = BigInt(parsed.id);
|
||||||
|
if (parsed.kind === 'sub') {
|
||||||
|
const subAccount = await this.prisma.partnerAccount.findUnique({
|
||||||
|
where: { id: accountId },
|
||||||
|
});
|
||||||
|
if (!subAccount || subAccount.isPrimary === 1 || !subAccount.parentAccountId) {
|
||||||
|
throw new BadRequestException('子账号不存在或无效');
|
||||||
|
}
|
||||||
|
if (subAccount.status !== 'ACTIVE') {
|
||||||
|
throw new BadRequestException('子账号已停用');
|
||||||
|
}
|
||||||
|
sub = subAccount;
|
||||||
|
accountId = subAccount.parentAccountId;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const raw = input.partnerId?.trim();
|
||||||
|
if (!raw || !/^\d+$/.test(raw)) throw new BadRequestException('关联码无效');
|
||||||
|
accountId = BigInt(raw);
|
||||||
}
|
}
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(BigInt(partnerIdRaw));
|
|
||||||
|
const primary = await this.partnerCityService.resolvePrimaryAccount(accountId);
|
||||||
if (primary.isPrimary !== 1 || primary.status !== 'ACTIVE') {
|
if (primary.isPrimary !== 1 || primary.status !== 'ACTIVE') {
|
||||||
throw new BadRequestException('合伙人不存在或已停用');
|
throw new BadRequestException('合伙人不存在或已停用');
|
||||||
}
|
}
|
||||||
|
return { primary, sub };
|
||||||
|
}
|
||||||
|
|
||||||
|
async bindUser(userId: bigint, input: { scene?: string; partnerId?: string }) {
|
||||||
|
const { primary, sub } = await this.resolveAssocTarget(input);
|
||||||
|
|
||||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||||
if (!user) throw new NotFoundException('用户不存在');
|
if (!user) throw new NotFoundException('用户不存在');
|
||||||
@@ -67,6 +103,7 @@ export class PartnerAssocService {
|
|||||||
bound: true,
|
bound: true,
|
||||||
alreadyBound: true,
|
alreadyBound: true,
|
||||||
partnerId: primary.id.toString(),
|
partnerId: primary.id.toString(),
|
||||||
|
subAccountId: sub ? sub.id.toString() : null,
|
||||||
partnerName: primary.companyName || primary.name,
|
partnerName: primary.companyName || primary.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -77,6 +114,7 @@ export class PartnerAssocService {
|
|||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
data: {
|
data: {
|
||||||
assocPartnerAccountId: primary.id,
|
assocPartnerAccountId: primary.id,
|
||||||
|
assocSubAccountId: sub ? sub.id : null,
|
||||||
assocBoundAt: new Date(),
|
assocBoundAt: new Date(),
|
||||||
...(user.sourceType === 'ORGANIC'
|
...(user.sourceType === 'ORGANIC'
|
||||||
? { sourceType: 'PARTNER_ASSOC', sourceRefId: primary.id }
|
? { sourceType: 'PARTNER_ASSOC', sourceRefId: primary.id }
|
||||||
@@ -88,31 +126,37 @@ export class PartnerAssocService {
|
|||||||
bound: true,
|
bound: true,
|
||||||
alreadyBound: false,
|
alreadyBound: false,
|
||||||
partnerId: primary.id.toString(),
|
partnerId: primary.id.toString(),
|
||||||
|
subAccountId: sub ? sub.id.toString() : null,
|
||||||
partnerName: primary.companyName || primary.name,
|
partnerName: primary.companyName || primary.name,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async touchScan(input: { scene?: string; partnerId?: string; countScan?: boolean }) {
|
async touchScan(input: { scene?: string; partnerId?: string; countScan?: boolean }) {
|
||||||
const partnerIdRaw = parseAssocScene(input.scene) || input.partnerId?.trim();
|
const { primary, sub } = await this.resolveAssocTarget(input);
|
||||||
if (!partnerIdRaw || !/^\d+$/.test(partnerIdRaw)) {
|
|
||||||
throw new BadRequestException('关联码无效');
|
|
||||||
}
|
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(BigInt(partnerIdRaw));
|
|
||||||
if (primary.isPrimary !== 1 || primary.status !== 'ACTIVE') {
|
|
||||||
throw new BadRequestException('合伙人不存在或已停用');
|
|
||||||
}
|
|
||||||
const shouldCountScan = input.countScan !== false;
|
const shouldCountScan = input.countScan !== false;
|
||||||
let scanCount = primary.assocScanCount ?? 0;
|
let scanCount = 0;
|
||||||
if (shouldCountScan) {
|
if (shouldCountScan) {
|
||||||
const updated = await this.prisma.partnerAccount.update({
|
if (sub) {
|
||||||
where: { id: primary.id },
|
const updated = await this.prisma.partnerAccount.update({
|
||||||
data: { assocScanCount: { increment: 1 } },
|
where: { id: sub.id },
|
||||||
select: { assocScanCount: true },
|
data: { assocScanCount: { increment: 1 } },
|
||||||
});
|
select: { assocScanCount: true },
|
||||||
scanCount = updated.assocScanCount;
|
});
|
||||||
|
scanCount = updated.assocScanCount;
|
||||||
|
} else {
|
||||||
|
const updated = await this.prisma.partnerAccount.update({
|
||||||
|
where: { id: primary.id },
|
||||||
|
data: { assocScanCount: { increment: 1 } },
|
||||||
|
select: { assocScanCount: true },
|
||||||
|
});
|
||||||
|
scanCount = updated.assocScanCount;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
scanCount = sub ? (sub.assocScanCount ?? 0) : (primary.assocScanCount ?? 0);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
partnerId: primary.id.toString(),
|
partnerId: primary.id.toString(),
|
||||||
|
subAccountId: sub ? sub.id.toString() : null,
|
||||||
scanCounted: shouldCountScan,
|
scanCounted: shouldCountScan,
|
||||||
scanCount,
|
scanCount,
|
||||||
};
|
};
|
||||||
@@ -157,11 +201,35 @@ export class PartnerAssocService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getSummary(partnerAccountId: bigint) {
|
async getSummary(partnerAccountId: bigint) {
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const { self, primary, subAccountId } = await this.scopeOf(partnerAccountId);
|
||||||
|
|
||||||
|
if (subAccountId) {
|
||||||
|
const ensured = await this.ensureSubQrcode(subAccountId);
|
||||||
|
const userCount = await this.prisma.user.count({
|
||||||
|
where: { assocPartnerAccountId: primary.id, assocSubAccountId: subAccountId },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
partnerId: subAccountId.toString(),
|
||||||
|
primaryAccountId: primary.id.toString(),
|
||||||
|
isSubAccount: true,
|
||||||
|
qrcodeUrl: ensured.qrcodeUrl,
|
||||||
|
userCount,
|
||||||
|
scanCount: self.assocScanCount ?? 0,
|
||||||
|
companyName: primary.companyName,
|
||||||
|
name: self.name,
|
||||||
|
activityPosterId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const ensured = await this.ensureQrcode(primary.id);
|
const ensured = await this.ensureQrcode(primary.id);
|
||||||
const userCount = await this.prisma.user.count({
|
const userCount = await this.prisma.user.count({
|
||||||
where: { assocPartnerAccountId: primary.id },
|
where: { assocPartnerAccountId: primary.id },
|
||||||
});
|
});
|
||||||
|
const childrenAgg = await this.prisma.partnerAccount.aggregate({
|
||||||
|
where: { parentAccountId: primary.id },
|
||||||
|
_sum: { assocScanCount: true },
|
||||||
|
});
|
||||||
|
const scanCount = (primary.assocScanCount ?? 0) + Number(childrenAgg._sum.assocScanCount ?? 0);
|
||||||
const selectedPoster = primary.activityPosterId
|
const selectedPoster = primary.activityPosterId
|
||||||
? await this.prisma.activityPoster.findUnique({
|
? await this.prisma.activityPoster.findUnique({
|
||||||
where: { id: primary.activityPosterId },
|
where: { id: primary.activityPosterId },
|
||||||
@@ -175,13 +243,24 @@ export class PartnerAssocService {
|
|||||||
partnerId: primary.id.toString(),
|
partnerId: primary.id.toString(),
|
||||||
qrcodeUrl: ensured.qrcodeUrl,
|
qrcodeUrl: ensured.qrcodeUrl,
|
||||||
userCount,
|
userCount,
|
||||||
scanCount: primary.assocScanCount ?? 0,
|
scanCount,
|
||||||
companyName: primary.companyName,
|
companyName: primary.companyName,
|
||||||
name: primary.name,
|
name: primary.name,
|
||||||
activityPosterId: partnerAccountId === primary.id ? activityPosterId : null,
|
activityPosterId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回调用者账号、主账号、以及子账号维度(主账号为 null) */
|
||||||
|
private async scopeOf(partnerAccountId: bigint) {
|
||||||
|
const self = await this.prisma.partnerAccount.findUnique({
|
||||||
|
where: { id: partnerAccountId },
|
||||||
|
});
|
||||||
|
if (!self) throw new NotFoundException('合伙人账号不存在');
|
||||||
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
|
const subAccountId = self.isPrimary === 1 ? null : self.id;
|
||||||
|
return { self, primary, subAccountId };
|
||||||
|
}
|
||||||
|
|
||||||
async getSelectedActivityPosterId(partnerAccountId: bigint): Promise<string | null> {
|
async getSelectedActivityPosterId(partnerAccountId: bigint): Promise<string | null> {
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
if (!primary.activityPosterId) return null;
|
if (!primary.activityPosterId) return null;
|
||||||
@@ -209,12 +288,16 @@ export class PartnerAssocService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getStats(partnerAccountId: bigint) {
|
async getStats(partnerAccountId: bigint) {
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const { primary, subAccountId } = await this.scopeOf(partnerAccountId);
|
||||||
const { todayStart, monthStart } = dayBounds();
|
const { todayStart, monthStart } = dayBounds();
|
||||||
const userWhere = { assocPartnerAccountId: primary.id };
|
const userWhere: Prisma.UserWhereInput = subAccountId
|
||||||
|
? { assocPartnerAccountId: primary.id, assocSubAccountId: subAccountId }
|
||||||
|
: { assocPartnerAccountId: primary.id };
|
||||||
const orderWhere = {
|
const orderWhere = {
|
||||||
payStatus: 'PAID' as const,
|
payStatus: 'PAID' as const,
|
||||||
user: { assocPartnerAccountId: primary.id },
|
user: subAccountId
|
||||||
|
? { assocSubAccountId: subAccountId }
|
||||||
|
: { assocPartnerAccountId: primary.id },
|
||||||
};
|
};
|
||||||
const [userTotal, userToday, userMonth, orderTotal, orderToday, orderMonth] = await Promise.all([
|
const [userTotal, userToday, userMonth, orderTotal, orderToday, orderMonth] = await Promise.all([
|
||||||
this.prisma.user.count({ where: userWhere }),
|
this.prisma.user.count({ where: userWhere }),
|
||||||
@@ -234,9 +317,11 @@ export class PartnerAssocService {
|
|||||||
maskPhone = false,
|
maskPhone = false,
|
||||||
opts: { keyword?: string; sort?: 'createdAt' | 'boundAt' | 'orderCount' } = {},
|
opts: { keyword?: string; sort?: 'createdAt' | 'boundAt' | 'orderCount' } = {},
|
||||||
) {
|
) {
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const { primary, subAccountId } = await this.scopeOf(partnerAccountId);
|
||||||
const keyword = opts.keyword?.trim();
|
const keyword = opts.keyword?.trim();
|
||||||
const where: Prisma.UserWhereInput = { assocPartnerAccountId: primary.id };
|
const where: Prisma.UserWhereInput = subAccountId
|
||||||
|
? { assocPartnerAccountId: primary.id, assocSubAccountId: subAccountId }
|
||||||
|
: { assocPartnerAccountId: primary.id };
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ userNo: { contains: keyword } },
|
{ userNo: { contains: keyword } },
|
||||||
@@ -301,7 +386,7 @@ export class PartnerAssocService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listAssocOrders(partnerAccountId: bigint, page = 1, pageSize = 20, userId?: bigint) {
|
async listAssocOrders(partnerAccountId: bigint, page = 1, pageSize = 20, userId?: bigint) {
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const { primary, subAccountId } = await this.scopeOf(partnerAccountId);
|
||||||
if (userId) {
|
if (userId) {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { id: userId },
|
where: { id: userId },
|
||||||
@@ -313,7 +398,9 @@ export class PartnerAssocService {
|
|||||||
}
|
}
|
||||||
const where: Prisma.OrderWhereInput = {
|
const where: Prisma.OrderWhereInput = {
|
||||||
payStatus: 'PAID',
|
payStatus: 'PAID',
|
||||||
user: { assocPartnerAccountId: primary.id },
|
user: subAccountId
|
||||||
|
? { assocSubAccountId: subAccountId }
|
||||||
|
: { assocPartnerAccountId: primary.id },
|
||||||
...(userId ? { userId } : {}),
|
...(userId ? { userId } : {}),
|
||||||
};
|
};
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
@@ -421,8 +508,29 @@ export class PartnerAssocService {
|
|||||||
return { qrcodeId: primary.assocQrcodeId, qrcodeUrl: resource.url };
|
return { qrcodeId: primary.assocQrcodeId, qrcodeUrl: resource.url };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const scene = `${PRIMARY_SCENE_PREFIX}${primary.id.toString()}`;
|
||||||
|
return this.generateAccountQrcode(primary, scene);
|
||||||
|
}
|
||||||
|
|
||||||
const scene = `${ASSOC_SCENE_PREFIX}${primary.id.toString()}`;
|
/** 生成/复用子账号自己的二维码(scene = sa_{subId}),写入子账号行 */
|
||||||
|
async ensureSubQrcode(subAccountId: bigint, force = false) {
|
||||||
|
const sub = await this.prisma.partnerAccount.findUnique({ where: { id: subAccountId } });
|
||||||
|
if (!sub || sub.isPrimary === 1 || !sub.parentAccountId) {
|
||||||
|
throw new BadRequestException('子账号不存在或无效');
|
||||||
|
}
|
||||||
|
if (!force && sub.assocQrcodeResourceId) {
|
||||||
|
const resource = await this.prisma.commonResource.findUnique({
|
||||||
|
where: { id: sub.assocQrcodeResourceId },
|
||||||
|
});
|
||||||
|
if (resource?.url) {
|
||||||
|
return { qrcodeId: sub.assocQrcodeId, qrcodeUrl: resource.url };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const scene = `${SUB_SCENE_PREFIX}${sub.id.toString()}`;
|
||||||
|
return this.generateAccountQrcode(sub, scene);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateAccountQrcode(account: PartnerAccount, scene: string) {
|
||||||
if (scene.length > 32) {
|
if (scene.length > 32) {
|
||||||
throw new BadRequestException('合伙人 ID 过长,无法写入小程序码');
|
throw new BadRequestException('合伙人 ID 过长,无法写入小程序码');
|
||||||
}
|
}
|
||||||
@@ -436,11 +544,11 @@ export class PartnerAssocService {
|
|||||||
checkPath: false,
|
checkPath: false,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`assoc qrcode failed partner=${primary.id}: ${err instanceof Error ? err.message : err}`);
|
this.logger.warn(`assoc qrcode failed account=${account.id}: ${err instanceof Error ? err.message : err}`);
|
||||||
throw new BadRequestException('生成关联码失败,请稍后重试');
|
throw new BadRequestException('生成关联码失败,请稍后重试');
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileName = `partner-assoc-${primary.id}.png`;
|
const fileName = `partner-assoc-${account.id}.png`;
|
||||||
const uploaded = await this.oss.putObject({
|
const uploaded = await this.oss.putObject({
|
||||||
bizType: 'QRCODE',
|
bizType: 'QRCODE',
|
||||||
mediaType: 'IMAGE',
|
mediaType: 'IMAGE',
|
||||||
@@ -451,7 +559,7 @@ export class PartnerAssocService {
|
|||||||
const resource = await this.prisma.commonResource.create({
|
const resource = await this.prisma.commonResource.create({
|
||||||
data: {
|
data: {
|
||||||
ownerType: 'PARTNER',
|
ownerType: 'PARTNER',
|
||||||
ownerId: primary.id,
|
ownerId: account.id,
|
||||||
bizType: 'QRCODE',
|
bizType: 'QRCODE',
|
||||||
mediaType: 'IMAGE',
|
mediaType: 'IMAGE',
|
||||||
ossBucket: uploaded.bucket,
|
ossBucket: uploaded.bucket,
|
||||||
@@ -463,7 +571,7 @@ export class PartnerAssocService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.prisma.partnerAccount.update({
|
await this.prisma.partnerAccount.update({
|
||||||
where: { id: primary.id },
|
where: { id: account.id },
|
||||||
data: { assocQrcodeId: scene, assocQrcodeResourceId: resource.id },
|
data: { assocQrcodeId: scene, assocQrcodeResourceId: resource.id },
|
||||||
});
|
});
|
||||||
return { qrcodeId: scene, qrcodeUrl: resource.url };
|
return { qrcodeId: scene, qrcodeUrl: resource.url };
|
||||||
@@ -482,16 +590,16 @@ export class PartnerAssocService {
|
|||||||
|
|
||||||
/** 只读已有 OSS 关联码,不调微信补码。无码返回 null。 */
|
/** 只读已有 OSS 关联码,不调微信补码。无码返回 null。 */
|
||||||
async getExistingQrcodeBuffer(partnerAccountId: bigint): Promise<{ buffer: Buffer; fileName: string } | null> {
|
async getExistingQrcodeBuffer(partnerAccountId: bigint): Promise<{ buffer: Buffer; fileName: string } | null> {
|
||||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
const account = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||||
if (!primary.assocQrcodeResourceId) return null;
|
if (!account.assocQrcodeResourceId) return null;
|
||||||
const resource = await this.prisma.commonResource.findUnique({
|
const resource = await this.prisma.commonResource.findUnique({
|
||||||
where: { id: primary.assocQrcodeResourceId },
|
where: { id: account.assocQrcodeResourceId },
|
||||||
select: { url: true },
|
select: { url: true },
|
||||||
});
|
});
|
||||||
if (!resource?.url) return null;
|
if (!resource?.url) return null;
|
||||||
const res = await fetch(resource.url);
|
const res = await fetch(resource.url);
|
||||||
if (!res.ok) return null;
|
if (!res.ok) return null;
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
return { buffer, fileName: `partner-assoc-${primary.id.toString()}.png` };
|
return { buffer, fileName: `partner-assoc-${account.id.toString()}.png` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
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,6 +31,8 @@ import {
|
|||||||
} from './store-info-change.controller';
|
} from './store-info-change.controller';
|
||||||
import { StoreInfoChangeService } from './store-info-change.service';
|
import { StoreInfoChangeService } from './store-info-change.service';
|
||||||
import { PartnerAssocService } from './partner-assoc.service';
|
import { PartnerAssocService } from './partner-assoc.service';
|
||||||
|
import { StoreBankService } from './store-bank.service';
|
||||||
|
import { AdminStoreBankController, ShopStoreBankController } from './store-bank.controller';
|
||||||
import {
|
import {
|
||||||
PartnerAssocController,
|
PartnerAssocController,
|
||||||
PartnerCommissionController,
|
PartnerCommissionController,
|
||||||
@@ -67,8 +69,10 @@ import {
|
|||||||
UserPartnerAssocController,
|
UserPartnerAssocController,
|
||||||
PartnerAssocController,
|
PartnerAssocController,
|
||||||
PartnerCommissionController,
|
PartnerCommissionController,
|
||||||
|
ShopStoreBankController,
|
||||||
|
AdminStoreBankController,
|
||||||
],
|
],
|
||||||
providers: [StoreService, StoreCategoryService, StorePackageService, StoreInfoChangeService, PartnerAssocService],
|
providers: [StoreService, StoreCategoryService, StorePackageService, StoreInfoChangeService, PartnerAssocService, StoreBankService],
|
||||||
exports: [StoreService, StoreCategoryService, StorePackageService, PartnerAssocService],
|
exports: [StoreService, StoreCategoryService, StorePackageService, PartnerAssocService, StoreBankService],
|
||||||
})
|
})
|
||||||
export class StoreModule {}
|
export class StoreModule {}
|
||||||
|
|||||||
Reference in New Issue
Block a user