城市合伙人端的修改(后台)

This commit is contained in:
2026-07-12 10:19:39 +08:00
parent d0f0fa09af
commit 7f031cc4c2
74 changed files with 5430 additions and 975 deletions
+4 -2
View File
@@ -26,10 +26,12 @@ alwaysApply: true
## 边界(2 人团队)
> **临时(2026-07**:刘京尧任务由 `jacy-dukang` 代管,B+D 路径可改。
| 负责人 | 路径 |
|--------|------|
| jacy-dukang | `apps/h5-user/`, `apps/admin-web/`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, `modules/{iam,trade,benefit,analytics,catalog,settlement,ops}/` |
| 刘京尧 | `apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/` |
| jacy-dukang | `apps/*`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, 全部 `modules/*` |
| ~~刘京尧~~ | ~~`apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/`~~(暂代管) |
- 跨模块只 inject **exported Service**,禁止 `prisma` 写他人表
- `apps/*` 禁止 import `server/*` 或其他 app
+8 -6
View File
@@ -19,7 +19,7 @@
```bash
# 基础设施
cd deploy && docker compose up -d
cd deploy && docker compose up -d # dukang-v1MySQL :6016 Redis :6017
# 依赖与数据库
pnpm install
@@ -60,16 +60,18 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
| 负责人 | Git 账号 | 职责 |
|--------|----------|------|
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块 |
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块**暂由 jacy-dukang 代管** |
逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**人员合并**如下:
> **临时分工(2026-07**:刘景尧任务暂由 `jacy-dukang` 全权负责;逻辑 OWNER B+D 路径可改,Prisma 迁移由 jacy 主导。
逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**当前人员合并**如下:
| 逻辑 OWNER | 负责人 | 可改路径 | 后端 Module |
|------------|--------|----------|-------------|
| **A + C + Lead** | jacy-dukang | `apps/h5-user/`, `apps/admin-web/`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | `iam`, `trade`, `benefit`, `analytics`, `catalog`, `settlement`, `ops` |
| **B + D** | 刘景尧 | `apps/h5-partner/`, `apps/h5-shop/` | `store`, `redeem` |
| **A + C + Lead + B + D** | jacy-dukang | `apps/*`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | 全部后端模块 |
| ~~B + D~~ | ~~刘景尧~~ | — | 恢复分工前由 jacy 代管 |
**Prisma 迁移**jacy-dukang 主 Review若改 `store_*` / 核销相关表,需刘景尧共同 Review。
**Prisma 迁移**jacy-dukang 主 Review涉及 `store_*` / 核销表时仍建议刘景尧知会(恢复分工后共同 Review
### 跨模块规则(R1–R8 摘要)
+2 -2
View File
@@ -8,7 +8,7 @@
```bash
cd deploy
docker compose up -d
docker compose up -d # MySQL :6016 Redis :6017(与 dukang-v3 的 6014/6015 隔离)
```
### 2. 安装依赖
@@ -48,7 +48,7 @@ pnpm dev:partner # http://localhost:5175
|----|--------|
| C端用户 | 13800000001 |
| 合伙人 | 13700000001 |
| HQ | 13600000001 |
| HQ | 13600000001(密码登录:`admin` / `dukang@123!`;短信验证码 123456 |
门店登录使用录入门店时绑定的手机号;seed 仅预置「郑州老城店」联调账号 `13910000001`
+6 -4
View File
@@ -7,8 +7,6 @@ import UsersPage from './pages/UsersPage';
import OrdersPage from './pages/OrdersPage';
import StoresPage from './pages/StoresPage';
import StoreAccountsPage from './pages/StoreAccountsPage';
import PartnersPage from './pages/PartnersPage';
import PartnerAccountsPage from './pages/PartnerAccountsPage';
import BenefitCouponsPage from './pages/BenefitCouponsPage';
import BenefitLedgersPage from './pages/BenefitLedgersPage';
import RedeemRecordsPage from './pages/RedeemRecordsPage';
@@ -17,6 +15,8 @@ import DeliveriesPage from './pages/DeliveriesPage';
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
import HqAccountsPage from './pages/HqAccountsPage';
import CitiesPage from './pages/CitiesPage';
import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage';
import StoreMediaPage from './pages/StoreMediaPage';
import ProductsPage from './pages/ProductsPage';
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
@@ -58,9 +58,11 @@ export default function App() {
<Route path="/store-accounts" element={<StoreAccountsPage />} />
<Route path="/store-media" element={<StoreMediaPage />} />
<Route path="/resources" element={<ResourcesPage />} />
<Route path="/partners" element={<PartnersPage />} />
<Route path="/partners" element={<Navigate to="/city-partners" replace />} />
<Route path="/cities" element={<CitiesPage />} />
<Route path="/partner-accounts" element={<PartnerAccountsPage />} />
<Route path="/city-partners" element={<CityPartnersPage />} />
<Route path="/city-warehouses" element={<CityWarehousesPage />} />
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
@@ -0,0 +1,34 @@
import { Cascader } from 'antd';
import type { DefaultOptionType } from 'antd/es/cascader';
import { PROVINCE_CITY_OPTIONS } from '../lib/china-region';
type ChinaProvinceCityCascaderProps = {
value?: string[];
onChange?: (codes: string[]) => void;
disabled?: boolean;
placeholder?: string;
};
export default function ChinaProvinceCityCascader({
value,
onChange,
disabled,
placeholder = '请选择省 / 市',
}: ChinaProvinceCityCascaderProps) {
return (
<Cascader
options={PROVINCE_CITY_OPTIONS as DefaultOptionType[]}
value={value}
onChange={(codes) => onChange?.((codes ?? []) as string[])}
disabled={disabled}
placeholder={placeholder}
showSearch={{
filter: (input, path) =>
path.some((option) =>
String(option.label ?? '').toLowerCase().includes(input.toLowerCase()),
),
}}
changeOnSelect={false}
/>
);
}
@@ -0,0 +1,414 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button,
Cascader,
Checkbox,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tabs,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CITY_PARTNER_SCOPE_LABELS,
CITY_PARTNER_STATUS_LABELS,
CityPartnerScopeType,
CityPartnerStatus,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
import { fmtTime } from '../lib/constants';
import PartnerSubAccountList from './PartnerSubAccountList';
type PartnerRow = {
id: string;
companyName: string;
phone: string;
name: string;
scopeType?: string;
orderCommissionRate?: number;
redeemCommissionRate?: number;
accountCount: number;
createdAt: string;
};
type PartnerDetail = PartnerRow & {
address?: string;
districtCodes?: string[] | null;
bindingStatus?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
managedWarehouseId?: string | null;
managedWarehouseName?: string | null;
contactPhone?: string | null;
children?: Array<{
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
}>;
};
type Props = {
cityId: string;
maxPartnerCommissionRate?: number;
onChanged?: () => void;
};
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
value: k,
label: PARTNER_PERMISSION_LABELS[k],
}));
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
.filter(([value]) => value !== 'PARTNER')
.map(([value, label]) => ({ value, label }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
if (!values?.length) return [];
if (Array.isArray(values[0])) {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
}
return values as string[];
}
function commissionSumError(
orderPercent: number,
redeemPercent: number,
maxRate: number,
): string | null {
const sum = orderPercent / 100 + redeemPercent / 100;
if (sum > maxRate + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
}
return null;
}
export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0.05, onChanged }: Props) {
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm();
const [partners, setPartners] = useState<PartnerRow[]>([]);
const [loading, setLoading] = useState(false);
const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [subEditOpen, setSubEditOpen] = useState(false);
const [subEditId, setSubEditId] = useState<string | null>(null);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const loadPartners = useCallback(async () => {
setLoading(true);
try {
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
setPartners(res.items);
} finally {
setLoading(false);
}
}, [cityId]);
useEffect(() => {
void loadPartners();
}, [loadPartners]);
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
companyName: d.companyName,
contactPhone: d.contactPhone ?? d.phone,
address: d.address ?? '',
scopeType: d.scopeType,
districtCodes: d.districtCodes ?? [],
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
}
async function savePartner() {
if (!detail) return;
const v = await editForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void loadPartners();
onChanged?.();
}
async function deleteSubAccount(subId: string) {
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
message.success('子账号已删除');
if (detail) void openPartner(detail.id);
void loadPartners();
onChanged?.();
}
const columns: ColumnsType<PartnerRow> = [
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '主账号', dataIndex: 'phone', width: 120 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 100,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{
title: '佣金',
width: 120,
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
},
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
return (
<>
<Button
type="primary"
style={{ marginBottom: 12 }}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({
cityId,
orderCommissionRate: 0,
redeemCommissionRate: 3,
scopeType: CityPartnerScopeType.CITY_WIDE,
});
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateOpen(true);
}}
>
</Button>
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
<Drawer
title="城市合伙人"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={<Button type="primary" onClick={() => void savePartner()}></Button>}
>
{detail && (
<Tabs
items={[
{
key: 'info',
label: '主账号',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} />
</Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县">
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text>
<Form.Item label="管仓仓库">
<Input
disabled
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
/>
</Form.Item>
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
</Form>
),
},
{
key: 'staff',
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
children: (
<PartnerSubAccountList
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
setSubOpen(true);
}}
onEdit={(row) => {
setSubEditId(row.id);
subEditForm.setFieldsValue({
name: row.name,
phone: row.phone,
staffRole: row.staffRole ?? 'INTERNAL',
permissions: row.permissions ?? [],
status: row.status,
});
setSubEditOpen(true);
}}
onDelete={(subId) => void deleteSubAccount(subId)}
/>
),
},
]}
/>
)}
</Drawer>
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3),
maxPartnerCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
cityId,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void loadPartners();
onChanged?.();
}}>
<Form form={createForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxPartnerCommissionRate * 100).toFixed(2)}%
</Typography.Text>
</Form>
</Modal>
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
if (!detail) return;
const v = await subForm.validateFields();
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
});
message.success('已创建');
setSubOpen(false);
void openPartner(detail.id);
void loadPartners();
onChanged?.();
}}>
<Form form={subForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
if (!subEditId || !detail) return;
const v = await subEditForm.validateFields();
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新');
setSubEditOpen(false);
void openPartner(detail.id);
}}>
<Form form={subEditForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</>
);
}
@@ -0,0 +1,92 @@
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
export type PartnerSubAccountRow = {
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
};
function formatPermissions(permissions?: string[]) {
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
}
type Props = {
subs: PartnerSubAccountRow[];
onAdd: () => void;
onEdit: (sub: PartnerSubAccountRow) => void;
onDelete: (subId: string) => void;
};
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
const columns: ColumnsType<PartnerSubAccountRow> = [
{ title: '姓名', dataIndex: 'name', width: 100 },
{ title: '手机', dataIndex: 'phone', width: 120 },
{
title: '角色',
dataIndex: 'staffRole',
width: 90,
render: (v) =>
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s) => (
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
),
},
{
title: '权限',
dataIndex: 'permissions',
ellipsis: true,
render: (p: string[] | undefined) => formatPermissions(p),
},
{
title: '操作',
width: 120,
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => onEdit(row)}>
</Button>
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{subs.length}·
</Typography.Text>
<Button size="small" type="primary" onClick={onAdd}>
</Button>
</div>
<Table
size="small"
rowKey="id"
pagination={false}
columns={columns}
dataSource={subs}
locale={{ emptyText: '暂无子账号' }}
/>
</>
);
}
+3 -3
View File
@@ -51,9 +51,9 @@ const MENU_ITEMS: MenuProps['items'] = [
icon: <TeamOutlined />,
label: '开城',
children: [
{ key: '/partners', label: '开城合伙人' },
{ key: '/cities', label: '开城城市' },
{ key: '/partner-accounts', label: '开城合伙人账户' },
{ key: '/cities', label: '城市' },
{ key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' },
{ key: '/partner-bills', label: '合伙人结算' },
],
},
+61 -10
View File
@@ -2,14 +2,35 @@ import { codeToText, regionData } from 'element-china-area-data';
export { regionData as CHINA_REGION_OPTIONS };
export type ProvinceCityOption = {
value: string;
label: string;
children?: Array<{ value: string; label: string }>;
};
/** 省 / 市二级(不含区县) */
export const PROVINCE_CITY_OPTIONS: ProvinceCityOption[] = regionData.map((province) => ({
value: province.value,
label: province.label,
children: province.children?.map((city) => ({
value: city.value,
label: city.label,
})),
}));
export type OpenCityRef = {
id: string;
name: string;
code: string;
partnerId?: string | null;
partner?: { id: string };
partnerBindings?: Array<{ partnerAccountId: string }>;
boundPartnerIds?: string[];
};
function cityBoundPartnerIds(city: OpenCityRef): string[] {
if (city.boundPartnerIds?.length) return city.boundPartnerIds.map(String);
return (city.partnerBindings ?? []).map((b) => String(b.partnerAccountId));
}
export type ParsedChinaRegion = {
province: string;
city: string;
@@ -19,6 +40,39 @@ export type ParsedChinaRegion = {
districtCode: string;
};
export type ParsedProvinceCity = {
province: string;
city: string;
provinceCode: string;
cityCode: string;
};
/** element-china-area-data 市级码(如 4101)→ 国标 6 位 adcode410100 */
export function normalizeCityAdcode(code: string): string {
const raw = code.trim();
if (/^\d{6}$/.test(raw)) return raw;
if (/^\d{4}$/.test(raw)) return `${raw}00`;
if (/^\d{2}$/.test(raw)) return `${raw}0100`;
return raw.padEnd(6, '0').slice(0, 6);
}
export function parseProvinceCityCodes(codes?: string[]): ParsedProvinceCity | null {
if (!codes || codes.length < 2) return null;
const [provinceCode, cityCode] = codes;
const province = codeToText[provinceCode];
let city = codeToText[cityCode];
if (!province || !city) return null;
if (city === '市辖区' || city === '县' || city === '省直辖县级行政区划') {
city = province.endsWith('市') ? province : city;
}
return {
province,
city,
provinceCode,
cityCode: normalizeCityAdcode(cityCode),
};
}
/** 区县 adcode → 地级市 adcode(如 410105 → 410100 */
export function districtCodeToCityCode(districtCode: string): string {
if (districtCode.length < 6) return districtCode;
@@ -42,14 +96,11 @@ export function formatRegionLabel(region: ParsedChinaRegion): string {
export function matchOpenCityId(
cities: OpenCityRef[],
districtCode: string,
partnerId?: string,
partnerAccountId?: string,
): string | undefined {
const cityCode = districtCodeToCityCode(districtCode);
const scoped = partnerId
? cities.filter((c) => {
const pid = c.partnerId ?? c.partner?.id;
return !pid || String(pid) === partnerId;
})
const scoped = partnerAccountId
? cities.filter((c) => cityBoundPartnerIds(c).includes(String(partnerAccountId)))
: cities;
return (
scoped.find((c) => c.code === cityCode)?.id
@@ -61,11 +112,11 @@ export function matchOpenCityId(
export function resolveRegionBinding(
codes: string[],
cities: OpenCityRef[],
partnerId?: string,
partnerAccountId?: string,
) {
const region = parseRegionCodes(codes);
if (!region) return null;
const cityId = matchOpenCityId(cities, region.districtCode, partnerId);
const cityId = matchOpenCityId(cities, region.districtCode, partnerAccountId);
const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined;
return {
region,
+7
View File
@@ -1,6 +1,13 @@
export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'CITY_CREATE', label: '新增开城城市' },
{ value: 'CITY_UPDATE', label: '编辑开城城市' },
{ value: 'CITY_DELETE', label: '删除开城城市' },
{ value: 'CITY_PARTNER_BIND', label: '绑定城市合伙人' },
{ value: 'CITY_PARTNER_UPDATE', label: '编辑城市合伙人绑定' },
{ value: 'CITY_PARTNER_UNBIND', label: '解绑城市合伙人' },
{ value: 'WAREHOUSE_CREATE', label: '新增城市仓库' },
{ value: 'WAREHOUSE_UPDATE', label: '编辑城市仓库' },
{ value: 'WAREHOUSE_DELETE', label: '删除城市仓库' },
{ value: 'PARTNER_CREATE', label: '新增城市合伙人' },
{ value: 'PARTNER_UPDATE', label: '编辑城市合伙人' },
{ value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' },
+5 -4
View File
@@ -1,5 +1,5 @@
export type StoreCreateForm = {
partnerId: string;
partnerAccountId: string;
cityId: string;
regionCodes?: string[];
province?: string;
@@ -16,17 +16,18 @@ export type StoreCreateForm = {
bankAccountName: string;
bankAccountNo: string;
bankBranch: string;
settlementRate?: number;
};
const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/;
export function validateStoreCreateStep1(
form: Pick<StoreCreateForm, 'partnerId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
form: Pick<StoreCreateForm, 'partnerAccountId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
): string | null {
if (!form.partnerId) return '请选择开城合伙人';
if (!form.partnerAccountId) return '请选择开城合伙人';
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 开城城市」配置对应区划';
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
+330 -58
View File
@@ -1,24 +1,63 @@
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message,
Button,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import CityPartnersPanel from '../components/CityPartnersPanel';
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
type Row = {
id: string; code: string; name: string; province: string; status: string;
storeCount: number; orderCount: number; createdAt: string;
partner?: { id: string; companyName: string };
type WarehouseRow = {
id: string;
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: WarehouseManagerType;
partnerAccountId: string | null;
partnerCompanyName?: string | null;
status: string;
};
type PartnerOption = { id: string; companyName: string };
type Row = {
id: string;
code: string;
name: string;
province: string;
status: string;
storeCount: number;
orderCount: number;
partnerBindingCount?: number;
createdAt: string;
};
type PartnerOption = { id: string; companyName: string; cityId?: string | null };
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
export default function CitiesPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [warehouseForm] = Form.useForm();
const [warehouseEditForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/cities',
@@ -32,37 +71,132 @@ export default function CitiesPage() {
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [warehouses, setWarehouses] = useState<WarehouseRow[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [warehouseOpen, setWarehouseOpen] = useState(false);
const [warehouseEditOpen, setWarehouseEditOpen] = useState(false);
const [warehouseEditId, setWarehouseEditId] = useState<string | null>(null);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [createRegionPreview, setCreateRegionPreview] = useState<ParsedProvinceCity | null>(null);
const createRegionCodes = Form.useWatch('regionCodes', createForm);
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
async function loadPartners() {
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
const loadPartners = useCallback(async (cityId: string) => {
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
setPartners(res.items);
}, []);
const loadWarehouses = useCallback(async (cityId: string) => {
const wh = await request<WarehouseRow[]>(`/admin/cities/${cityId}/warehouses`);
setWarehouses(wh);
}, []);
useEffect(() => {
if (!createRegionCodes?.length) {
setCreateRegionPreview(null);
return;
}
const parsed = parseProvinceCityCodes(createRegionCodes as string[]);
createForm.setFieldsValue({
code: parsed?.cityCode,
name: parsed?.city,
province: parsed?.province,
});
setCreateRegionPreview(parsed);
}, [createRegionCodes, createForm]);
function openCreateModal() {
createForm.resetFields();
createForm.setFieldsValue({ status: 'PENDING' });
setCreateRegionPreview(null);
setCreateOpen(true);
}
const openDetail = async (row: Row) => {
const d = await request<Record<string, unknown>>(`/admin/cities/${row.id}`);
setDetail(d);
editForm.setFieldsValue({
...d,
maxPartnerCommissionPercent:
d.maxPartnerCommissionRate != null
? Number(d.maxPartnerCommissionRate) * 100
: 5,
});
await loadPartners(row.id);
await loadWarehouses(row.id);
setDrawerOpen(true);
};
const columns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 90 },
{ title: '城市', dataIndex: 'name', width: 100 },
{ title: '省份', dataIndex: 'province', width: 90 },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 140, ellipsis: true },
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
{ title: '门店', dataIndex: 'storeCount', width: 70 },
{ title: '订单', dataIndex: 'orderCount', width: 70 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/cities/${row.id}`);
setDetail(d);
editForm.setFieldsValue({
...d,
partnerId: (d.partner as { id?: string } | null)?.id ?? (d as { partnerId?: string }).partnerId,
});
void loadPartners();
setDrawerOpen(true);
}}></Button>
<Button type="link" size="small" onClick={() => void openDetail(row)}>
</Button>
),
},
];
const warehouseColumns: ColumnsType<WarehouseRow> = [
{ title: '仓库名', dataIndex: 'name' },
{ title: '地址', dataIndex: 'address', ellipsis: true },
{ title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
{
title: '管仓',
dataIndex: 'managerType',
width: 100,
render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v,
},
{ title: '合伙人', dataIndex: 'partnerCompanyName', ellipsis: true, render: (v) => v || '—' },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> },
{
title: '操作',
width: 120,
render: (_, row) => (
<Space>
<Button
type="link"
size="small"
onClick={() => {
setWarehouseEditId(row.id);
setEditWarehouseManagerType(row.managerType);
warehouseEditForm.setFieldsValue(row);
setWarehouseEditOpen(true);
}}
>
</Button>
<Button
type="link"
size="small"
danger
onClick={() => {
Modal.confirm({
title: '确认删除仓库?',
onOk: async () => {
await request(`/admin/city-warehouses/${row.id}`, { method: 'DELETE' });
message.success('已删除');
if (detail?.id) await loadWarehouses(String(detail.id));
},
});
}}
>
</Button>
</Space>
),
},
];
@@ -70,8 +204,8 @@ export default function CitiesPage() {
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button type="primary" onClick={() => { void loadPartners(); setCreateOpen(true); }}></Button>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button type="primary" onClick={openCreateModal}></Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
@@ -81,59 +215,197 @@ export default function CitiesPage() {
</Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Drawer title="编辑开城城市" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}
extra={detail && (
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
await request(`/admin/cities/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已保存');
setDrawerOpen(false);
void reload();
}}></Button>
)}>
<Drawer title="城市管理" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="编码">{String(detail.code)}</Descriptions.Item>
<Descriptions.Item label="门店数">{String(detail.storeCount ?? (detail as { _count?: { stores?: number } })._count?.stores ?? '—')}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="partnerId" label="开城合伙人">
<Select allowClear placeholder="选择合伙人" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="localMinQty" label="同城起订量"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="crossMinQty" label="跨城起订量"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
</Form>
</>
<Tabs
items={[
{
key: 'basic',
label: '基本信息',
children: (
<>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="编码">{String(detail.code)}</Descriptions.Item>
<Descriptions.Item label="合伙人">{String(detail.partnerBindingCount ?? '—')}</Descriptions.Item>
</Descriptions>
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="status" label="状态">
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
<Form.Item
name="maxPartnerCommissionPercent"
label="合伙人佣金合计上限 %"
extra="订单佣金 + 核销佣金不得超过此比例;不填默认 5%"
>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} placeholder="5" />
</Form.Item>
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
const { maxPartnerCommissionPercent, ...rest } = v;
await request(`/admin/cities/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...rest,
maxPartnerCommissionRate:
maxPartnerCommissionPercent != null && maxPartnerCommissionPercent !== ''
? Number(maxPartnerCommissionPercent) / 100
: 0.05,
}),
});
message.success('已保存');
const refreshed = await request<Record<string, unknown>>(`/admin/cities/${detail.id}`);
setDetail(refreshed);
void reload();
}}></Button>
</Form>
</>
),
},
{
key: 'partners',
label: '合伙人',
children: detail?.id ? (
<CityPartnersPanel
cityId={String(detail.id)}
maxPartnerCommissionRate={
detail.maxPartnerCommissionRate != null
? Number(detail.maxPartnerCommissionRate)
: 0.05
}
onChanged={() => {
void reload();
void loadPartners(String(detail.id));
}}
/>
) : null,
},
{
key: 'warehouses',
label: '仓库管理',
children: (
<>
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => {
warehouseForm.resetFields();
warehouseForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: 'ACTIVE' });
setWarehouseManagerType(WarehouseManagerType.HQ);
setWarehouseOpen(true);
}}></Button>
<Table rowKey="id" size="small" columns={warehouseColumns} dataSource={warehouses} pagination={false} />
</>
),
},
]}
/>
)}
</Drawer>
<Modal title="新建开城城市" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/cities', { method: 'POST', body: JSON.stringify(v) });
if (!v.code || !v.name || !v.province) {
message.error('请选择省 / 市');
return;
}
await request('/admin/cities', {
method: 'POST',
body: JSON.stringify({
code: v.code,
name: v.name,
province: v.province,
status: v.status,
}),
});
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
setCreateRegionPreview(null);
void reload();
}}>
<Form form={createForm} layout="vertical">
<Form.Item name="code" label="城市编码" rules={[{ required: true }]}><Input placeholder="如 ZZ" /></Form.Item>
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="partnerId" label="开城合伙人">
<Select allowClear placeholder="选择合伙人" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
<Form.Item
name="regionCodes"
label="所在地区"
rules={[{ required: true, message: '请选择省 / 市' }]}
>
<ChinaProvinceCityCascader />
</Form.Item>
{createRegionPreview ? (
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市编码">{createRegionPreview.cityCode}</Descriptions.Item>
<Descriptions.Item label="省份">{createRegionPreview.province}</Descriptions.Item>
<Descriptions.Item label="城市名">{createRegionPreview.city}</Descriptions.Item>
</Descriptions>
) : (
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
/
</Typography.Text>
)}
<Form.Item name="code" hidden rules={[{ required: true, message: '请选择省 / 市' }]}><Input /></Form.Item>
<Form.Item name="name" hidden rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="province" hidden rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="status" label="状态" initialValue="PENDING">
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
</Form>
</Modal>
<Modal title="新增仓库" open={warehouseOpen} onCancel={() => setWarehouseOpen(false)} onOk={async () => {
const v = await warehouseForm.validateFields();
await request(`/admin/cities/${detail?.id}/warehouses`, { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setWarehouseOpen(false);
if (detail?.id) await loadWarehouses(String(detail.id));
}}>
<Form form={warehouseForm} layout="vertical">
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
<Select options={MANAGER_OPTIONS} onChange={(v) => setWarehouseManagerType(v)} />
</Form.Item>
{warehouseManagerType === WarehouseManagerType.PARTNER && (
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
<Select options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
</Form.Item>
)}
<Form.Item name="status" label="状态" initialValue="ACTIVE">
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'PAUSED', label: '暂停' }]} />
</Form.Item>
</Form>
</Modal>
<Modal title="编辑仓库" open={warehouseEditOpen} onCancel={() => setWarehouseEditOpen(false)} onOk={async () => {
const v = await warehouseEditForm.validateFields();
await request(`/admin/city-warehouses/${warehouseEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新');
setWarehouseEditOpen(false);
if (detail?.id) await loadWarehouses(String(detail.id));
}}>
<Form form={warehouseEditForm} layout="vertical">
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
<Select options={MANAGER_OPTIONS} onChange={(v) => setEditWarehouseManagerType(v)} />
</Form.Item>
{editWarehouseManagerType === WarehouseManagerType.PARTNER && (
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
<Select options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
</Form.Item>
)}
<Form.Item name="status" label="状态">
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'PAUSED', label: '暂停' }]} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,645 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert,
Button,
Cascader,
Checkbox,
Descriptions,
Drawer,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
CITY_PARTNER_SCOPE_LABELS,
CITY_PARTNER_STATUS_LABELS,
CityPartnerScopeType,
CityPartnerStatus,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
PARTNER_STAFF_ROLE_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
type SubRow = PartnerSubAccountRow;
type Row = {
id: string;
companyName: string;
phone: string;
name: string;
contactPhone?: string | null;
cityId?: string | null;
cityName?: string | null;
scopeType?: string;
orderCommissionRate?: number;
redeemCommissionRate?: number;
bindingStatus?: string;
managedWarehouseId?: string | null;
managedWarehouseName?: string | null;
maxPartnerCommissionRate?: number | null;
storeCount: number;
accountCount: number;
subAccounts?: SubRow[];
createdAt: string;
};
type PartnerDetail = Row & {
address?: string;
districtCodes?: string[] | null;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
/** 详情接口仍返回 children */
children?: SubRow[];
};
function toTableRows(items: Row[]): Row[] {
return items.map((row) => {
const { children, subAccounts, ...rest } = row as Row & { children?: SubRow[] };
return { ...rest, subAccounts: subAccounts ?? children ?? [] };
});
}
type CityOption = { id: string; name: string; code: string };
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
value: k,
label: PARTNER_PERMISSION_LABELS[k],
}));
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
.filter(([value]) => value !== 'PARTNER')
.map(([value, label]) => ({ value, label }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
if (!values?.length) return [];
if (Array.isArray(values[0])) {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
}
return values as string[];
}
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
const sum = orderPercent / 100 + redeemPercent / 100;
if (sum > maxRate + 1e-9) {
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`;
}
return null;
}
export default function CityPartnersPage() {
const [filterForm] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [subEditForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners',
() => {
const qs = new URLSearchParams();
if (filters.companyName) qs.set('companyName', filters.companyName);
if (filters.phone) qs.set('phone', filters.phone);
if (filters.cityId) qs.set('cityId', filters.cityId);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [subEditOpen, setSubEditOpen] = useState(false);
const [subEditId, setSubEditId] = useState<string | null>(null);
const [subEditParentId, setSubEditParentId] = useState<string | null>(null);
const [cities, setCities] = useState<CityOption[]>([]);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
const [createMaxRate, setCreateMaxRate] = useState(0.05);
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
}, []);
useEffect(() => {
void loadCities();
}, [loadCities]);
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
setMaxCommissionRate(d.maxPartnerCommissionRate ?? 0.05);
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
companyName: d.companyName,
contactPhone: d.contactPhone ?? d.phone,
address: d.address ?? '',
scopeType: d.scopeType,
districtCodes: d.districtCodes ?? [],
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
return d;
}
async function savePartner() {
if (!detail) return;
const v = await editForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 0),
maxCommissionRate,
);
if (err) {
message.error(err);
return;
}
await request(`/admin/partners/${detail.id}`, {
method: 'PUT',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已保存');
setDrawerOpen(false);
void reload();
}
async function refreshPartnerContext(parentId: string) {
if (detail?.id === parentId) {
await openPartner(parentId);
}
void reload();
}
async function deleteSubAccount(subId: string, parentId: string) {
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
message.success('子账号已删除');
await refreshPartnerContext(parentId);
}
function openSubEdit(row: SubRow, parentId: string) {
setSubEditId(row.id);
setSubEditParentId(parentId);
subEditForm.setFieldsValue({
name: row.name,
phone: row.phone,
staffRole: row.staffRole ?? 'INTERNAL',
permissions: row.permissions ?? [],
status: row.status,
});
setSubEditOpen(true);
}
async function openAddSubAccount(parentId: string) {
await openPartner(parentId);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
setSubOpen(true);
}
async function onCreateCityChange(cityId: string) {
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
setCreateMaxRate(cityRes.maxPartnerCommissionRate ?? 0.05);
}
const columns: ColumnsType<Row> = [
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 90 },
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
{ title: '登录手机', dataIndex: 'phone', width: 120 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 90,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{
title: '佣金',
width: 110,
render: (_, row) =>
`${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
},
{
title: '绑定状态',
dataIndex: 'bindingStatus',
width: 80,
render: (v) => (
<Tag color={v === CityPartnerStatus.ACTIVE ? 'green' : 'default'}>
{CITY_PARTNER_STATUS_LABELS[v as CityPartnerStatus] || v || '—'}
</Tag>
),
},
{
title: '管仓仓库',
dataIndex: 'managedWarehouseName',
width: 110,
ellipsis: true,
render: (v) => v || '—',
},
{ title: '门店', dataIndex: 'storeCount', width: 60 },
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
width: 80,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button
type="primary"
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({
orderCommissionRate: 0,
redeemCommissionRate: 3,
scopeType: CityPartnerScopeType.CITY_WIDE,
});
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
setCreateMaxRate(0.05);
setCreateOpen(true);
}}
>
</Button>
</Space>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message={
<>
{' '}
<Link to="/city-warehouses"></Link>
</>
}
/>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="companyName" label="公司">
<Input allowClear placeholder="公司名" />
</Form.Item>
<Form.Item name="phone" label="手机">
<Input allowClear placeholder="登录手机" />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 160 }}
placeholder="全部城市"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={toTableRows(data?.items ?? [])}
scroll={{ x: 1200 }}
childrenColumnName="__noTreeChildren__"
expandable={{
expandedRowRender: (record) => (
<PartnerSubAccountList
subs={record.subAccounts ?? []}
onAdd={() => void openAddSubAccount(record.id)}
onEdit={(sub) => openSubEdit(sub, record.id)}
onDelete={(subId) => void deleteSubAccount(subId, record.id)}
/>
),
rowExpandable: () => true,
columnWidth: 40,
}}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Drawer
title="城市合伙人"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={
<Button type="primary" onClick={() => void savePartner()}>
</Button>
}
>
{detail && (
<>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
<Descriptions.Item label="门店数">{detail.storeCount ?? 0}</Descriptions.Item>
<Descriptions.Item label="管仓仓库" span={2}>
{detail.managedWarehouseName ?? (
<Typography.Text type="secondary">
<Link to="/city-warehouses"></Link>
</Typography.Text>
)}
</Descriptions.Item>
</Descriptions>
<Tabs
items={[
{
key: 'info',
label: '主账号',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactPhone" label="业务联系电话">
<Input />
</Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
<Select options={BINDING_OPTIONS} />
</Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县">
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %">
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %">
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
</Space>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
+ {(maxCommissionRate * 100).toFixed(2)}%
</Typography.Text>
<Form.Item name="bankAccountName" label="户名">
<Input />
</Form.Item>
<Form.Item name="bankAccountNo" label="账号">
<Input />
</Form.Item>
<Form.Item name="bankBranch" label="开户行">
<Input />
</Form.Item>
</Form>
),
},
{
key: 'staff',
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
children: (
<PartnerSubAccountList
subs={detail.children ?? []}
onAdd={() => {
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
setSubOpen(true);
}}
onEdit={(sub) => openSubEdit(sub, detail.id)}
onDelete={(subId) => void deleteSubAccount(subId, detail.id)}
/>
),
},
]}
/>
</>
)}
</Drawer>
<Modal
title="新建城市合伙人"
open={createOpen}
width={560}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
const err = commissionSumError(
Number(v.orderCommissionRate ?? 0),
Number(v.redeemCommissionRate ?? 3),
createMaxRate,
);
if (err) {
message.error(err);
return;
}
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes:
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
void reload();
}}
>
<Form form={createForm} layout="vertical">
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => void onCreateCityChange(id)}
/>
</Form.Item>
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %">
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %">
<InputNumber min={0} max={100} precision={2} />
</Form.Item>
</Space>
<Typography.Text type="secondary" style={{ display: 'block' }}>
+ {(createMaxRate * 100).toFixed(2)}%
</Typography.Text>
</Form>
</Modal>
<Modal
title={detail ? `添加子账号 · ${detail.companyName}` : '添加子账号'}
open={subOpen}
onCancel={() => setSubOpen(false)}
onOk={async () => {
if (!detail) return;
const v = await subForm.validateFields();
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
});
message.success('已创建');
setSubOpen(false);
await refreshPartnerContext(detail.id);
}}
>
<Form form={subForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑子账号"
open={subEditOpen}
onCancel={() => setSubEditOpen(false)}
onOk={async () => {
if (!subEditId) return;
const parentId = detail?.id ?? subEditParentId;
if (!parentId) return;
const v = await subEditForm.validateFields();
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
message.success('已更新');
setSubEditOpen(false);
await refreshPartnerContext(parentId);
}}
>
<Form form={subEditForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="staffRole" label="角色">
<Select options={STAFF_ROLE_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
options={[
{ value: 'ACTIVE', label: '启用' },
{ value: 'DISABLED', label: '停用' },
]}
/>
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -0,0 +1,406 @@
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import {
Alert,
Button,
Descriptions,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
WAREHOUSE_MANAGER_LABELS,
WAREHOUSE_STATUS_LABELS,
WarehouseManagerType,
WarehouseStatus,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string;
cityId: string;
cityName: string;
cityCode: string;
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: WarehouseManagerType;
partnerAccountId: string | null;
partnerCompanyName?: string | null;
status: string;
createdAt: string;
};
type CityOption = { id: string; name: string; code: string };
type PartnerOption = { id: string; companyName: string };
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
export default function CityWarehousesPage() {
const [filterForm] = Form.useForm();
const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/city-warehouses',
() => {
const qs = new URLSearchParams();
if (filters.name) qs.set('name', filters.name);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.managerType) qs.set('managerType', filters.managerType);
if (filters.status) qs.set('status', filters.status);
return qs;
},
[filters],
);
const [cities, setCities] = useState<CityOption[]>([]);
const [partners, setPartners] = useState<PartnerOption[]>([]);
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [editRow, setEditRow] = useState<Row | null>(null);
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
const [createCityId, setCreateCityId] = useState<string | undefined>();
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
}, []);
const loadPartners = useCallback(async (cityId: string) => {
const res = await request<Paginated<PartnerOption>>(
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`,
);
setPartners(res.items);
}, []);
useEffect(() => {
void loadCities();
}, [loadCities]);
async function openEdit(row: Row) {
setEditRow(row);
setEditManagerType(row.managerType);
await loadPartners(row.cityId);
editForm.setFieldsValue({
name: row.name,
address: row.address,
contactName: row.contactName,
contactPhone: row.contactPhone,
managerType: row.managerType,
partnerAccountId: row.partnerAccountId,
status: row.status,
});
setEditOpen(true);
}
function onManagerTypeChange(
type: WarehouseManagerType,
form: typeof createForm | typeof editForm,
setType: (v: WarehouseManagerType) => void,
) {
setType(type);
if (type !== WarehouseManagerType.PARTNER) {
form.setFieldValue('partnerAccountId', undefined);
}
}
const columns: ColumnsType<Row> = [
{ title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 },
{
title: '城市',
width: 100,
render: (_, row) => (
<span title={row.cityCode}>
{row.cityName}
</span>
),
},
{ title: '地址', dataIndex: 'address', ellipsis: true },
{ title: '联系人', dataIndex: 'contactName', width: 90 },
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
{
title: '管仓类型',
dataIndex: 'managerType',
width: 100,
render: (v) => WAREHOUSE_MANAGER_LABELS[v as WarehouseManagerType] || v,
},
{
title: '管仓合伙人',
dataIndex: 'partnerCompanyName',
width: 120,
ellipsis: true,
render: (v, row) =>
v && row.partnerAccountId ? (
<Link to="/city-partners">{v}</Link>
) : (
'—'
),
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (s) => (
<Tag color={s === WarehouseStatus.ACTIVE ? 'green' : 'default'}>
{WAREHOUSE_STATUS_LABELS[s as WarehouseStatus] || s}
</Tag>
),
},
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
{
title: '操作',
width: 120,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => void openEdit(row)}>
</Button>
<Popconfirm
title="确定删除该仓库?"
description="删除后将解除关联合伙人的管仓绑定"
onConfirm={async () => {
await request(`/admin/city-warehouses/${row.id}`, { method: 'DELETE' });
message.success('已删除');
void reload();
}}
>
<Button type="link" size="small" danger>
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Button
type="primary"
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: WarehouseStatus.ACTIVE });
setCreateManagerType(WarehouseManagerType.HQ);
setCreateCityId(undefined);
setPartners([]);
setCreateOpen(true);
}}
>
</Button>
</Space>
<Alert
type="info"
showIcon
style={{ marginBottom: 16 }}
message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。"
/>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
setPage(1);
}}
>
<Form.Item name="name" label="仓库名">
<Input allowClear placeholder="仓库名" />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 140 }}
placeholder="全部城市"
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="managerType" label="管仓类型">
<Select allowClear style={{ width: 120 }} placeholder="全部" options={MANAGER_OPTIONS} />
</Form.Item>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 100 }} placeholder="全部" options={STATUS_OPTIONS} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit">
</Button>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Space>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1100 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
showTotal: (t) => `${t}`,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title="新建仓库"
open={createOpen}
width={520}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await request(`/admin/cities/${v.cityId}/warehouses`, {
method: 'POST',
body: JSON.stringify(v),
});
message.success('已创建');
setCreateOpen(false);
void reload();
}}
>
<Form form={createForm} layout="vertical">
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => {
setCreateCityId(id);
createForm.setFieldValue('partnerAccountId', undefined);
void loadPartners(id);
}}
/>
</Form.Item>
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
<Select
options={MANAGER_OPTIONS}
onChange={(v) => onManagerTypeChange(v, createForm, setCreateManagerType)}
/>
</Form.Item>
{createManagerType === WarehouseManagerType.PARTNER && createCityId && (
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
placeholder={partners.length ? '选择合伙人' : '该城市暂无合伙人'}
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
/>
</Form.Item>
)}
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
<Select options={STATUS_OPTIONS} />
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑仓库"
open={editOpen}
width={520}
onCancel={() => setEditOpen(false)}
onOk={async () => {
if (!editRow) return;
const v = await editForm.validateFields();
await request(`/admin/city-warehouses/${editRow.id}`, {
method: 'PUT',
body: JSON.stringify(v),
});
message.success('已更新');
setEditOpen(false);
void reload();
}}
>
{editRow && (
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="所属城市">
{editRow.cityName}{editRow.cityCode}
</Descriptions.Item>
</Descriptions>
)}
<Form form={editForm} layout="vertical">
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
<Select
options={MANAGER_OPTIONS}
onChange={(v) => onManagerTypeChange(v, editForm, setEditManagerType)}
/>
</Form.Item>
{editManagerType === WarehouseManagerType.PARTNER && editRow && (
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
/>
</Form.Item>
)}
<Form.Item name="status" label="状态">
<Select options={STATUS_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
);
}
@@ -1,9 +1,9 @@
import { useCallback, useEffect, useState } from 'react';
import {
Button, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message,
Button, Checkbox, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { PARTNER_STAFF_ROLE_LABELS } from '@dukang/shared-types';
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
@@ -18,11 +18,12 @@ type AccountTreeRow = {
status: string;
isPrimary: number;
staffRole?: string | null;
permissions?: string[] | null;
parentAccountId?: string | null;
parent?: ParentAccount | null;
createdAt: string;
lastLoginAt?: string | null;
partner?: { id: string; companyName: string };
companyName?: string | null;
children?: AccountTreeRow[];
};
@@ -43,6 +44,11 @@ const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS).map(([value
label,
}));
const PERMISSION_OPTIONS = PARTNER_PERMISSION_KEYS.map((key: PartnerPermissionKey) => ({
value: key,
label: PARTNER_PERMISSION_LABELS[key],
}));
function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): AccountTreeRow[] {
const phoneQ = phone?.trim();
const match = (row: AccountTreeRow) => {
@@ -65,10 +71,6 @@ function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): Ac
return walk(rows);
}
function accountTypeLabel(isPrimary: number) {
return isPrimary === 1 ? '主账号' : '子账号';
}
function staffRoleLabel(role?: string | null) {
if (!role) return '-';
return PARTNER_STAFF_ROLE_LABELS[role as keyof typeof PARTNER_STAFF_ROLE_LABELS] ?? role;
@@ -77,7 +79,6 @@ function staffRoleLabel(role?: string | null) {
export default function PartnerAccountsPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [filters, setFilters] = useState<{ phone?: string; status?: string; partnerId?: string }>({});
const [treeData, setTreeData] = useState<AccountTreeRow[]>([]);
@@ -85,7 +86,6 @@ export default function PartnerAccountsPage() {
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
const [detail, setDetail] = useState<Detail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [subParent, setSubParent] = useState<AccountTreeRow | null>(null);
const [partners, setPartners] = useState<PartnerOption[]>([]);
@@ -117,6 +117,7 @@ export default function PartnerAccountsPage() {
name: detail.name,
phone: detail.phone,
status: detail.status,
permissions: detail.permissions ?? [],
});
}, [drawerOpen, detail, editForm]);
@@ -153,7 +154,7 @@ export default function PartnerAccountsPage() {
function openAddSub(parent: AccountTreeRow) {
setSubParent(parent);
subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL' });
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
setSubOpen(true);
}
@@ -179,9 +180,7 @@ export default function PartnerAccountsPage() {
secondary={
row.parentAccountId
? `子账号 · ${staffRoleLabel(row.staffRole)}`
: row.isPrimary === 1
? '主账号'
: '合伙人账号'
: '主账号'
}
/>
),
@@ -191,15 +190,14 @@ export default function PartnerAccountsPage() {
title: '开城合伙人',
width: 140,
ellipsis: true,
render: (_, row) => row.partner?.companyName || '—',
render: (_, row) => row.companyName || row.parent?.name || '—',
},
{
title: '账号类型',
dataIndex: 'isPrimary',
width: 90,
render: (v, row) => (
<Tag color={row.parentAccountId ? 'default' : v === 1 ? 'blue' : 'default'}>
{row.parentAccountId ? '子账号' : accountTypeLabel(v)}
render: (_, row) => (
<Tag color={row.parentAccountId ? 'default' : 'blue'}>
{row.parentAccountId ? '子账号' : '主账号'}
</Tag>
),
},
@@ -213,9 +211,9 @@ export default function PartnerAccountsPage() {
title: '所属主账号',
width: 140,
render: (_, row) => (
row.isPrimary === 1 || !row.parentAccountId
? '-'
: (row.parent ? `${row.parent.name} / ${row.parent.phone}` : '-')
row.parentAccountId && row.parent
? `${row.parent.name} / ${row.parent.phone}`
: '-'
),
},
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
@@ -226,7 +224,7 @@ export default function PartnerAccountsPage() {
render: (_, row) => (
<Space size={0} wrap onClick={(e) => e.stopPropagation()}>
<Button type="link" size="small" onClick={() => void openAccount(row.id)}></Button>
{!row.parentAccountId && row.isPrimary === 1 ? (
{!row.parentAccountId ? (
<Button type="link" size="small" onClick={() => openAddSub(row)}></Button>
) : null}
{row.parentAccountId ? (
@@ -260,10 +258,11 @@ export default function PartnerAccountsPage() {
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">/</Typography.Text>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">
</Typography.Text>
</div>
<Button type="primary" onClick={() => { void loadPartners(); setCreateOpen(true); }}></Button>
</Space>
<Form
form={form}
@@ -315,7 +314,7 @@ export default function PartnerAccountsPage() {
}}
/>
<Drawer
title={detail?.parentAccountId ? '合伙人子账号详情' : '开城合伙人账户详情'}
title={detail?.parentAccountId ? '子账号详情' : '主账号详情(只读)'}
width={720}
open={drawerOpen}
onClose={() => {
@@ -323,7 +322,11 @@ export default function PartnerAccountsPage() {
setDetail(null);
}}
destroyOnClose
extra={<Button type="primary" onClick={() => void saveAccount()}></Button>}
extra={
detail?.parentAccountId
? <Button type="primary" onClick={() => void saveAccount()}></Button>
: null
}
>
{detail && (
<Tabs items={[
@@ -339,32 +342,28 @@ export default function PartnerAccountsPage() {
name: detail.name,
phone: detail.phone,
status: detail.status,
permissions: detail.permissions ?? [],
}}
>
<Form.Item label="开城合伙人">
<Input value={detail.partner?.companyName} disabled />
<Input value={detail.companyName || detail.parent?.name || '—'} disabled />
</Form.Item>
<Form.Item label="账号类型">
<Input
value={
detail.parentAccountId
? `子账号 · ${staffRoleLabel(detail.staffRole)}`
: accountTypeLabel(detail.isPrimary)
: '主账号'
}
disabled
/>
</Form.Item>
{detail.staffRole ? (
<Form.Item label="角色">
<Input value={staffRoleLabel(detail.staffRole)} disabled />
</Form.Item>
) : null}
{detail.parentAccountId && detail.parent ? (
<Form.Item label="所属主账号">
<Input value={`${detail.parent.name} / ${detail.parent.phone}`} disabled />
</Form.Item>
) : null}
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input disabled={!detail.parentAccountId} /></Form.Item>
<Form.Item
name="phone"
label="登录手机"
@@ -373,14 +372,25 @@ export default function PartnerAccountsPage() {
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
]}
>
<Input maxLength={11} />
<Input maxLength={11} disabled={!detail.parentAccountId} />
</Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
<Select disabled={!detail.parentAccountId} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item>
<Typography.Text type="secondary">
H5 使
</Typography.Text>
{detail.parentAccountId ? (
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERMISSION_OPTIONS} />
</Form.Item>
) : null}
{!detail.parentAccountId ? (
<Typography.Text type="secondary">
</Typography.Text>
) : (
<Typography.Text type="secondary">
H5 使
</Typography.Text>
)}
</Form>
),
},
@@ -403,31 +413,6 @@ export default function PartnerAccountsPage() {
]} />
)}
</Drawer>
<Modal title="新建开城合伙人账户" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/partner-accounts', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void loadTree();
}}>
<Form form={createForm} layout="vertical">
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="label" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
</Form.Item>
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item
name="phone"
label="登录手机"
rules={[
{ required: true },
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
]}
>
<Input maxLength={11} />
</Form.Item>
</Form>
</Modal>
<Modal
title={subParent ? `添加子账号 · ${subParent.name}` : '添加子账号'}
open={subOpen}
@@ -438,11 +423,11 @@ export default function PartnerAccountsPage() {
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({
partnerId: subParent.partner?.id,
parentAccountId: subParent.id,
name: v.name,
phone: v.phone,
staffRole: v.staffRole,
permissions: v.permissions,
}),
});
message.success('子账号已创建');
@@ -466,6 +451,9 @@ export default function PartnerAccountsPage() {
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
<Select options={STAFF_ROLE_OPTIONS.filter((o) => o.value !== 'PARTNER')} />
</Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERMISSION_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
+279 -59
View File
@@ -1,39 +1,86 @@
import { useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
Button, Descriptions, Drawer, Form, Input, Modal, Space, Table, Typography, message,
Button,
Cascader,
Drawer,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tabs,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import {
CITY_PARTNER_SCOPE_LABELS,
CityPartnerScopeType,
PARTNER_PERMISSION_KEYS,
PARTNER_PERMISSION_LABELS,
type PartnerPermissionKey,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
type Row = {
id: string; companyName: string; contactPhone: string; address: string;
storeCount: number; accountCount: number; cityCount: number; createdAt: string;
id: string;
companyName: string;
phone: string;
name: string;
contactPhone?: string | null;
cityId?: string | null;
cityName?: string | null;
scopeType?: string;
orderCommissionRate?: number;
redeemCommissionRate?: number;
storeCount: number;
accountCount: number;
createdAt: string;
};
type PartnerDetail = Row & {
address?: string;
districtCodes?: string[] | null;
bindingStatus?: string;
bankAccountName?: string | null;
bankAccountNo?: string | null;
bankBranch?: string | null;
cities?: Array<{ name: string; code: string; status: string }>;
managedWarehouseId?: string | null;
children?: Array<{
id: string;
phone: string;
name: string;
staffRole?: string;
permissions?: string[];
status: string;
}>;
};
function pickPartnerFormValues(d: PartnerDetail) {
return {
companyName: d.companyName,
contactPhone: d.contactPhone,
address: d.address,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
};
type CityOption = { id: string; name: string; code: string };
type WarehouseOption = { id: string; name: string };
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
if (!values?.length) return [];
if (Array.isArray(values[0])) {
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
}
return values as string[];
}
export default function PartnersPage() {
const [form] = Form.useForm();
const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners',
@@ -48,37 +95,86 @@ export default function PartnersPage() {
const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false);
const [cities, setCities] = useState<CityOption[]>([]);
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
const [createCityId, setCreateCityId] = useState<string | undefined>();
const loadCities = useCallback(async () => {
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
setCities(res.items);
}, []);
const loadWarehouses = useCallback(async (cityId: string) => {
const rows = await request<WarehouseOption[]>(`/admin/cities/${cityId}/warehouses`);
setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name })));
}, []);
useEffect(() => {
void loadCities();
}, [loadCities]);
async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d);
editForm.setFieldsValue(pickPartnerFormValues(d));
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
if (d.cityId) await loadWarehouses(d.cityId);
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
companyName: d.companyName,
contactPhone: d.contactPhone ?? d.phone,
address: d.address ?? '',
scopeType: d.scopeType,
districtCodes: d.districtCodes ?? [],
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
bindingStatus: d.bindingStatus,
managedWarehouseId: d.managedWarehouseId,
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '',
bankBranch: d.bankBranch ?? '',
});
setDrawerOpen(true);
}
async function savePartner() {
if (!detail) return;
const v = await editForm.validateFields();
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
const body = {
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
};
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
message.success('已保存');
setDrawerOpen(false);
void reload();
}
const columns: ColumnsType<Row> = [
{ title: '公司名', dataIndex: 'companyName' },
{ title: '联系电话', dataIndex: 'contactPhone', width: 130 },
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '城市', dataIndex: 'cityName', width: 100 },
{ title: '主账号', dataIndex: 'phone', width: 130 },
{
title: '管辖',
dataIndex: 'scopeType',
width: 100,
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
},
{ title: '门店', dataIndex: 'storeCount', width: 70 },
{ title: '账号', dataIndex: 'accountCount', width: 70 },
{ title: '开城城市', dataIndex: 'cityCount', width: 90 },
{ title: '账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 120,
title: '操作',
width: 120,
render: (_, row) => (
<Space>
<Button type="link" size="small" onClick={() => void openPartner(row.id)}></Button>
<Button type="link" size="small" onClick={() => void openPartner(row.id)}></Button>
</Space>
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
</Button>
),
},
];
@@ -87,54 +183,178 @@ export default function PartnersPage() {
<div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button type="primary" onClick={() => setCreateOpen(true)}></Button>
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
</Button>
</Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
}}
/>
<Drawer
title="编辑开城合伙人"
width={560}
title="城合伙人"
width={640}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
extra={<Button type="primary" onClick={() => void savePartner()}></Button>}
>
{detail && (
<>
{Array.isArray(detail.cities) && detail.cities.length > 0 && (
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }} title="关联开城城市">
{detail.cities.map((c) => (
<Descriptions.Item key={c.code} label={c.code}>{c.name} ({c.status})</Descriptions.Item>
))}
</Descriptions>
)}
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
</Form>
</>
<Tabs
items={[
{
key: 'info',
label: '基本信息',
children: (
<Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
</Form.Item>
{editScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县">
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space>
<Form.Item name="managedWarehouseId" label="管仓仓库">
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
</Form.Item>
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
</Form>
),
},
{
key: 'staff',
label: '子账号',
children: (
<>
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => { subForm.resetFields(); setSubOpen(true); }}>
</Button>
<Table
size="small"
rowKey="id"
pagination={false}
dataSource={detail.children ?? []}
columns={[
{ title: '姓名', dataIndex: 'name' },
{ title: '手机', dataIndex: 'phone' },
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
{
title: '权限',
dataIndex: 'permissions',
render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—',
},
]}
/>
</>
),
},
]}
/>
)}
</Drawer>
<Modal title="新建开城合伙人" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/partners', { method: 'POST', body: JSON.stringify(v) });
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}}>
<Form form={createForm} layout="vertical">
<Modal
title="新建城市合伙人"
open={createOpen}
width={560}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields();
await request('/admin/partners', {
method: 'POST',
body: JSON.stringify({
...v,
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
}),
});
message.success('已创建');
setCreateOpen(false);
createForm.resetFields();
void reload();
}}
>
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
<Select
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
onChange={(id) => { setCreateCityId(id); void loadWarehouses(id); }}
/>
</Form.Item>
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
</Form.Item>
{createScopeType === CityPartnerScopeType.DISTRICT && (
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
</Form.Item>
)}
<Space style={{ width: '100%' }} size="large">
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
</Space>
{createCityId && (
<Form.Item name="managedWarehouseId" label="管仓仓库(可选)">
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
</Form.Item>
)}
</Form>
</Modal>
<Modal
title="添加子账号"
open={subOpen}
onCancel={() => setSubOpen(false)}
onOk={async () => {
if (!detail) return;
const v = await subForm.validateFields();
await request('/admin/partner-accounts', {
method: 'POST',
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
});
message.success('已创建');
setSubOpen(false);
void openPartner(detail.id);
}}
>
<Form form={subForm} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="permissions" label="权限">
<Select mode="multiple" options={PERM_OPTIONS} />
</Form.Item>
</Form>
</Modal>
</div>
+39 -16
View File
@@ -8,6 +8,7 @@ import {
Form,
Image,
Input,
InputNumber,
Modal,
Select,
Space,
@@ -57,8 +58,7 @@ type CityOption = {
id: string;
name: string;
code: string;
partnerId?: string | null;
partner?: { id: string; companyName: string };
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
};
export default function StoresPage() {
@@ -87,12 +87,12 @@ export default function StoresPage() {
const [cities, setCities] = useState<CityOption[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false);
const selectedPartnerId = Form.useWatch('partnerId', createForm);
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
const selectedCityId = Form.useWatch('cityId', createForm);
function bindRegionSelection(codes: string[], partnerId?: string) {
const binding = resolveRegionBinding(codes, cities, partnerId ?? selectedPartnerId);
function bindRegionSelection(codes: string[], partnerAccountId?: string) {
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
if (!binding) {
createForm.setFieldsValue({ regionCodes: codes, cityId: undefined });
return;
@@ -114,7 +114,7 @@ export default function StoresPage() {
if (binding.cityId && binding.matchedCity) {
return `已匹配开城城市:${binding.matchedCity.name}(区划 ${binding.cityCode},区县 ${binding.region.districtCode}`;
}
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 开城城市」添加`;
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 城市」添加`;
}, [selectedRegionCodes, cities, selectedPartnerId]);
async function loadOptions() {
@@ -127,8 +127,8 @@ export default function StoresPage() {
]);
setPartners(p.items);
setCities(c.items);
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 开城合伙人」中创建');
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 开城城市」中创建');
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
} catch (e) {
message.error(e instanceof Error ? e.message : '加载合伙人/城市失败');
} finally {
@@ -147,6 +147,7 @@ export default function StoresPage() {
void loadOptions();
createForm.setFieldsValue({
envPhotoUrls: ['', '', ''],
settlementRate: 60,
});
setCreateStep(0);
setCreateError('');
@@ -162,7 +163,7 @@ export default function StoresPage() {
return;
}
try {
await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
await createForm.validateFields(['partnerAccountId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
} catch {
return;
}
@@ -184,7 +185,7 @@ export default function StoresPage() {
await request('/admin/stores', {
method: 'POST',
body: JSON.stringify({
partnerId: values.partnerId,
partnerAccountId: values.partnerAccountId,
cityId: values.cityId,
province: values.province,
city: values.city,
@@ -199,6 +200,7 @@ export default function StoresPage() {
bankAccountName: values.bankAccountName.trim(),
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
bankBranch: values.bankBranch.trim(),
settlementRate: Number(values.settlementRate ?? 60) / 100,
}),
});
message.success('门店已创建');
@@ -208,7 +210,7 @@ export default function StoresPage() {
if (e && typeof e === 'object' && 'errorFields' in e) {
const fields = e as { errorFields?: Array<{ name: string[] }> };
const first = fields.errorFields?.[0]?.name?.[0];
if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') {
if (first === 'partnerAccountId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') {
setCreateStep(0);
}
return;
@@ -239,7 +241,15 @@ export default function StoresPage() {
<Button type="link" size="small" onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/stores/${row.id}`);
setDetail(d);
editForm.setFieldsValue({ name: d.name, phone: d.phone, intro: d.intro, coverUrl: d.coverUrl, address: d.address, district: d.district });
editForm.setFieldsValue({
name: d.name,
phone: d.phone,
intro: d.intro,
coverUrl: d.coverUrl,
address: d.address,
district: d.district,
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
});
setDrawerOpen(true);
}}></Button>
),
@@ -279,7 +289,11 @@ export default function StoresPage() {
}} />
<Button type="primary" onClick={async () => {
const v = await editForm.validateFields();
await request(`/admin/stores/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
const payload = {
...v,
settlementRate: v.settlementRate != null ? Number(v.settlementRate) / 100 : undefined,
};
await request(`/admin/stores/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
message.success('已保存');
setDetail({ ...detail, ...v });
void reload();
@@ -291,6 +305,9 @@ export default function StoresPage() {
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
<Descriptions.Item label="核销结算比例">
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
</Descriptions.Item>
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
<Descriptions.Item label="操作">
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
@@ -312,6 +329,9 @@ export default function StoresPage() {
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
<Form.Item name="district" label="区县"><Input /></Form.Item>
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
</Form.Item>
</Form>
</>
)}
@@ -339,16 +359,16 @@ export default function StoresPage() {
)}
<Form form={createForm} layout="vertical" preserve>
<div style={{ display: createStep === 0 ? 'block' : 'none' }}>
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
<Form.Item name="partnerAccountId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
<Select
showSearch
loading={optionsLoading}
optionFilterProp="label"
placeholder={optionsLoading ? '加载中…' : '请选择开城合伙人'}
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
onChange={(partnerId) => {
onChange={(partnerAccountId) => {
const codes = createForm.getFieldValue('regionCodes') as string[] | undefined;
if (codes?.length === 3) bindRegionSelection(codes, partnerId);
if (codes?.length === 3) bindRegionSelection(codes, partnerAccountId);
else createForm.setFieldValue('cityId', undefined);
}}
/>
@@ -418,6 +438,9 @@ export default function StoresPage() {
<Form.Item name="bankBranch" label="开户支行" rules={[{ required: true, message: '请填写开户支行' }]}>
<Input placeholder="例如:中国工商银行洛阳分行" />
</Form.Item>
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true, message: '请填写结算比例' }]}>
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
</Form.Item>
<Alert
type="info"
showIcon
@@ -48,6 +48,8 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
companyName: profile.companyName ?? '',
isPrimary: profile.isPrimary ?? true,
staffRole: profile.staffRole,
permissions: profile.permissions,
primaryAccountId: profile.primaryAccountId,
};
}
+3 -1
View File
@@ -22,7 +22,7 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [
'/partner/auth/login/wechat',
];
export type PartnerSessionProfile = Pick<PartnerMe, 'id' | 'name' | 'phone' | 'companyName' | 'isPrimary'> & {
export type PartnerSessionProfile = Pick<PartnerMe, 'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId'> & {
staffRole?: PartnerStaffRole;
};
@@ -104,6 +104,8 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
companyName: me.companyName,
isPrimary: me.isPrimary,
staffRole: me.staffRole ?? undefined,
permissions: me.permissions,
primaryAccountId: me.primaryAccountId,
};
}
+10 -1
View File
@@ -1,4 +1,4 @@
import type { PartnerMe } from '@dukang/shared-types';
import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types';
export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
return account?.isPrimary !== false;
@@ -8,6 +8,15 @@ export function isSubAccount(account: PartnerMe | null | undefined): boolean {
return !!account && account.isPrimary === false;
}
export function hasPartnerPermission(
account: PartnerMe | null | undefined,
permission: PartnerPermissionKey,
): boolean {
if (!account) return false;
if (isPrimaryAccount(account)) return true;
return account.permissions?.includes(permission) ?? false;
}
export function partnerHomePath(account: PartnerMe | null | undefined): string {
return isSubAccount(account) ? '/stores/new?step=1' : '/';
}
+9 -4
View File
@@ -1,26 +1,31 @@
name: dukang-v1
# 与 dukang-v36014/6015)隔离;本栈使用 6016/6017
services:
mysql:
image: mysql:8.0
container_name: dukang-mysql
container_name: dukang-v1-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: dukang_haoke
ports:
- '3306:3306'
- '6016:3306'
volumes:
- mysql_data:/var/lib/mysql
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
redis:
image: redis:7-alpine
container_name: dukang-redis
container_name: dukang-v1-redis
restart: unless-stopped
ports:
- '6379:6379'
- '6017:6379'
volumes:
- redis_data:/data
volumes:
mysql_data:
name: dukang-v1_mysql_data
redis_data:
name: dukang-v1_redis_data
+96
View File
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import {
resolveOrderCityPartner,
validatePartnerCityBinding,
validatePartnerCommissionRates,
} from './city-partner';
describe('resolveOrderCityPartner', () => {
const bindings = [
{
id: '1',
partnerAccountId: '10',
scopeType: 'CITY_WIDE' as const,
districtCodes: null,
orderCommissionRate: 0,
redeemCommissionRate: 0.03,
bindingStatus: 'ACTIVE' as const,
},
{
id: '2',
partnerAccountId: '20',
scopeType: 'DISTRICT' as const,
districtCodes: ['410105', '金水区'],
orderCommissionRate: 0.01,
redeemCommissionRate: 0.03,
bindingStatus: 'ACTIVE' as const,
},
];
it('prefers district partner when district matches adcode', () => {
const ref = resolveOrderCityPartner(bindings, '410105');
expect(ref?.partnerAccountId).toBe('20');
expect(ref?.scopeType).toBe('DISTRICT');
});
it('falls back to city-wide partner', () => {
const ref = resolveOrderCityPartner(bindings, '410102');
expect(ref?.partnerAccountId).toBe('10');
expect(ref?.scopeType).toBe('CITY_WIDE');
});
it('returns null when no bindings', () => {
expect(resolveOrderCityPartner([], '410105')).toBeNull();
});
});
describe('validatePartnerCityBinding', () => {
it('rejects duplicate partner', () => {
const result = validatePartnerCityBinding(
[{ id: '1', partnerAccountId: '10', scopeType: 'CITY_WIDE' }],
{ partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] },
);
expect(result.ok).toBe(false);
});
it('rejects second city-wide partner', () => {
const result = validatePartnerCityBinding(
[{ id: '1', partnerAccountId: '10', scopeType: 'CITY_WIDE' }],
{ partnerAccountId: '11', scopeType: 'CITY_WIDE' },
);
expect(result.ok).toBe(false);
});
it('rejects overlapping district codes', () => {
const result = validatePartnerCityBinding(
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: ['410105'] },
);
expect(result.ok).toBe(false);
});
it('allows valid district binding', () => {
const result = validatePartnerCityBinding(
[{ id: '1', partnerAccountId: '10', scopeType: 'DISTRICT', districtCodes: ['410105'] }],
{ partnerAccountId: '11', scopeType: 'DISTRICT', districtCodes: ['410106'] },
);
expect(result.ok).toBe(true);
});
});
describe('validatePartnerCommissionRates', () => {
it('allows sum within default 5% cap', () => {
expect(validatePartnerCommissionRates(0.02, 0.03).ok).toBe(true);
});
it('rejects sum above cap', () => {
const result = validatePartnerCommissionRates(0.03, 0.03, 0.05);
expect(result.ok).toBe(false);
expect(result.message).toContain('5.00%');
});
it('respects custom city cap', () => {
expect(validatePartnerCommissionRates(0.04, 0.04, 0.08).ok).toBe(true);
expect(validatePartnerCommissionRates(0.05, 0.04, 0.08).ok).toBe(false);
});
});
+145
View File
@@ -0,0 +1,145 @@
export type CityPartnerScopeType = 'CITY_WIDE' | 'DISTRICT';
export type CityPartnerStatus = 'ACTIVE' | 'PAUSED';
export interface PartnerCityBindingInput {
id?: string;
partnerAccountId: string;
scopeType: CityPartnerScopeType;
districtCodes?: string[] | null;
bindingStatus?: CityPartnerStatus;
}
export interface PartnerCityResolveRef {
id: string;
partnerAccountId: string;
scopeType: CityPartnerScopeType;
orderCommissionRate: number;
redeemCommissionRate: number;
}
export interface PartnerCityValidationResult {
ok: boolean;
message?: string;
}
function normalizeDistrictCodes(codes?: string[] | null): string[] {
if (!codes?.length) return [];
return [...new Set(codes.map((c) => String(c).trim()).filter(Boolean))];
}
/** 区县 adcode 或名称命中区域合伙;否则全城合伙 */
export function resolveOrderCityPartner(
bindings: Array<{
id: string;
partnerAccountId: string;
scopeType: CityPartnerScopeType;
districtCodes?: string[] | null;
orderCommissionRate: number;
redeemCommissionRate: number;
bindingStatus?: CityPartnerStatus;
}>,
receiverDistrict?: string | null,
): PartnerCityResolveRef | null {
const active = bindings.filter((b) => b.bindingStatus !== 'PAUSED');
if (!active.length) return null;
const districtKey = receiverDistrict?.trim();
if (districtKey) {
const districtHit = active.find((b) => {
if (b.scopeType !== 'DISTRICT') return false;
const codes = normalizeDistrictCodes(b.districtCodes);
return codes.some((code) => code === districtKey || districtKey.includes(code) || code.includes(districtKey));
});
if (districtHit) {
return {
id: districtHit.id,
partnerAccountId: districtHit.partnerAccountId,
scopeType: districtHit.scopeType,
orderCommissionRate: districtHit.orderCommissionRate,
redeemCommissionRate: districtHit.redeemCommissionRate,
};
}
}
const cityWide = active.find((b) => b.scopeType === 'CITY_WIDE');
if (cityWide) {
return {
id: cityWide.id,
partnerAccountId: cityWide.partnerAccountId,
scopeType: cityWide.scopeType,
orderCommissionRate: cityWide.orderCommissionRate,
redeemCommissionRate: cityWide.redeemCommissionRate,
};
}
return null;
}
export function validatePartnerCityBinding(
existing: PartnerCityBindingInput[],
input: PartnerCityBindingInput,
excludeId?: string,
): PartnerCityValidationResult {
const others = existing.filter((b) => b.id !== excludeId);
if (others.some((b) => b.partnerAccountId === input.partnerAccountId)) {
return { ok: false, message: '该合伙人已绑定此城市' };
}
if (input.scopeType === 'CITY_WIDE') {
if (others.some((b) => b.scopeType === 'CITY_WIDE')) {
return { ok: false, message: '每城最多 1 名全城合伙人' };
}
return { ok: true };
}
const districts = normalizeDistrictCodes(input.districtCodes);
if (!districts.length) {
return { ok: false, message: '区域合伙人须至少选择一个区县' };
}
const occupied = new Set<string>();
for (const row of others) {
if (row.scopeType !== 'DISTRICT') continue;
for (const code of normalizeDistrictCodes(row.districtCodes)) {
occupied.add(code);
}
}
for (const code of districts) {
if (occupied.has(code)) {
return { ok: false, message: `区县 ${code} 已被其他区域合伙人占用` };
}
}
return { ok: true };
}
/** @deprecated use validatePartnerCityBinding */
export const validateCityPartnerBinding = validatePartnerCityBinding;
export const DEFAULT_MAX_PARTNER_COMMISSION_RATE = 0.05;
export function resolveMaxPartnerCommissionRate(maxRate?: number | null): number {
if (maxRate == null || Number.isNaN(Number(maxRate))) {
return DEFAULT_MAX_PARTNER_COMMISSION_RATE;
}
return Number(maxRate);
}
/** 订单佣金 + 核销佣金不得超过城市配置上限(默认 5%) */
export function validatePartnerCommissionRates(
orderCommissionRate: number,
redeemCommissionRate: number,
maxSumRate = DEFAULT_MAX_PARTNER_COMMISSION_RATE,
): PartnerCityValidationResult {
const sum = orderCommissionRate + redeemCommissionRate;
const max = resolveMaxPartnerCommissionRate(maxSumRate);
if (sum > max + 1e-9) {
return {
ok: false,
message: `订单佣金与核销佣金合计不得超过 ${(max * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%`,
};
}
return { ok: true };
}
+2
View File
@@ -125,3 +125,5 @@ export function orderTabToStatuses(tab: string): string[] | undefined {
return undefined;
}
}
export * from './city-partner';
+2
View File
@@ -4,6 +4,8 @@ export interface CityDto {
name: string;
province: string;
status: string;
/** 订单+核销佣金合计上限(小数,默认 0.05) */
maxPartnerCommissionRate?: number;
}
export interface ProductDto {
+91
View File
@@ -0,0 +1,91 @@
import type { CityPartnerScopeType, CityPartnerStatus } from './enums';
/** @deprecated use PartnerPrimaryAccountDto */
export type CityPartnerDto = PartnerPrimaryAccountDto;
export interface PartnerPrimaryAccountDto {
id: string;
cityId: string;
cityName?: string | null;
cityCode?: string | null;
companyName: string;
contactPhone?: string | null;
address?: string | null;
phone: string;
name: string;
scopeType: CityPartnerScopeType;
districtCodes: string[] | null;
orderCommissionRate: number;
redeemCommissionRate: number;
bindingStatus: CityPartnerStatus;
managedWarehouseId?: string | null;
status: string;
createdAt: string;
updatedAt: string;
}
export interface CreatePartnerPrimaryInput {
cityId: string;
phone: string;
name: string;
companyName: string;
address: string;
contactPhone?: string;
scopeType: CityPartnerScopeType;
districtCodes?: string[];
orderCommissionRate?: number;
redeemCommissionRate?: number;
bindingStatus?: CityPartnerStatus;
managedWarehouseId?: string;
contractNo?: string;
bankAccountName?: string;
bankAccountNo?: string;
bankBranch?: string;
weeklyStoreTarget?: number;
}
export interface UpdatePartnerPrimaryInput {
name?: string;
phone?: string;
companyName?: string;
address?: string;
contactPhone?: string;
scopeType?: CityPartnerScopeType;
districtCodes?: string[];
orderCommissionRate?: number;
redeemCommissionRate?: number;
bindingStatus?: CityPartnerStatus;
managedWarehouseId?: string | null;
contractNo?: string;
bankAccountName?: string;
bankAccountNo?: string;
bankBranch?: string;
weeklyStoreTarget?: number;
status?: string;
}
export interface PartnerCityBindingRef {
id: string;
partnerAccountId: string;
scopeType: CityPartnerScopeType;
districtCodes: string[] | null;
orderCommissionRate: number;
redeemCommissionRate: number;
bindingStatus: CityPartnerStatus;
}
export const PARTNER_PERMISSION_KEYS = [
'warehouse:manage',
'store:manage',
'store:create',
'order:view',
] as const;
export type PartnerPermissionKey = (typeof PARTNER_PERMISSION_KEYS)[number];
export const PARTNER_PERMISSION_LABELS: Record<PartnerPermissionKey, string> = {
'warehouse:manage': '仓库管理',
'store:manage': '门店管理',
'store:create': '开店管理',
'order:view': '订单查看',
};
@@ -0,0 +1,36 @@
import type { WarehouseManagerType, WarehouseStatus } from './enums';
export interface CityWarehouseDto {
id: string;
cityId: string;
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: WarehouseManagerType;
partnerAccountId: string | null;
partnerCompanyName?: string | null;
status: WarehouseStatus;
createdAt: string;
updatedAt: string;
}
export interface CreateCityWarehouseInput {
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: WarehouseManagerType;
partnerAccountId?: string;
status?: WarehouseStatus;
}
export interface UpdateCityWarehouseInput {
name?: string;
address?: string;
contactName?: string;
contactPhone?: string;
managerType?: WarehouseManagerType;
partnerAccountId?: string | null;
status?: WarehouseStatus;
}
+40
View File
@@ -72,6 +72,46 @@ export enum PartnerStaffRole {
PROMOTER = 'PROMOTER',
}
export enum CityPartnerScopeType {
CITY_WIDE = 'CITY_WIDE',
DISTRICT = 'DISTRICT',
}
export enum CityPartnerStatus {
ACTIVE = 'ACTIVE',
PAUSED = 'PAUSED',
}
export enum WarehouseManagerType {
HQ = 'HQ',
PARTNER = 'PARTNER',
}
export enum WarehouseStatus {
ACTIVE = 'ACTIVE',
PAUSED = 'PAUSED',
}
export const CITY_PARTNER_SCOPE_LABELS: Record<CityPartnerScopeType, string> = {
[CityPartnerScopeType.CITY_WIDE]: '全城合伙人',
[CityPartnerScopeType.DISTRICT]: '区域合伙人',
};
export const WAREHOUSE_MANAGER_LABELS: Record<WarehouseManagerType, string> = {
[WarehouseManagerType.HQ]: '总部直管',
[WarehouseManagerType.PARTNER]: '合伙人管仓',
};
export const CITY_PARTNER_STATUS_LABELS: Record<CityPartnerStatus, string> = {
[CityPartnerStatus.ACTIVE]: '启用',
[CityPartnerStatus.PAUSED]: '暂停',
};
export const WAREHOUSE_STATUS_LABELS: Record<WarehouseStatus, string> = {
[WarehouseStatus.ACTIVE]: '启用',
[WarehouseStatus.PAUSED]: '暂停',
};
export enum AccountStatus {
ACTIVE = 'ACTIVE',
DISABLED = 'DISABLED',
+2
View File
@@ -15,3 +15,5 @@ export * from './partner-log';
export * from './promo';
export * from './hq-permissions';
export * from './partner';
export * from './city-partner';
export * from './city-warehouse';
+14 -1
View File
@@ -1,34 +1,47 @@
export type PartnerLogCategory =
| 'login'
| 'wechat_auth'
| 'account_ops'
| 'store_ops'
| 'shipping'
| 'settlement';
| 'settlement'
| 'warehouse_ops';
export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly string[]> = {
login: ['partner_sms_send', 'partner_sms_login', 'partner_sms_verify_fail', 'partner_login_success'],
wechat_auth: ['partner_wechat_login', 'partner_wechat_bind'],
account_ops: [
'partner_staff_create',
'partner_staff_update',
'partner_staff_delete',
'partner_staff_permission_update',
],
store_ops: ['partner_store_create', 'partner_store_status_change'],
shipping: ['partner_order_ship', 'partner_delivery_advance'],
settlement: ['partner_bill_view', 'partner_bill_detail_view'],
warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'],
};
export const PARTNER_LOG_CATEGORY_OPTIONS: Array<{ value: PartnerLogCategory | ''; label: string }> = [
{ value: '', label: '全部' },
{ value: 'login', label: '登录' },
{ value: 'wechat_auth', label: '微信授权' },
{ value: 'account_ops', label: '子账号管理' },
{ value: 'store_ops', label: '门店操作' },
{ value: 'shipping', label: '发货/配送' },
{ value: 'settlement', label: '结算' },
{ value: 'warehouse_ops', label: '仓库操作' },
];
export const PARTNER_LOG_CATEGORY_LABELS: Record<PartnerLogCategory | '', string> = {
'': '全部',
login: '登录',
wechat_auth: '微信授权',
account_ops: '子账号管理',
store_ops: '门店操作',
shipping: '发货/配送',
settlement: '结算',
warehouse_ops: '仓库操作',
};
export function resolvePartnerLogCategory(eventName: string): PartnerLogCategory | null {
+5
View File
@@ -8,6 +8,8 @@ export interface PartnerMe {
companyName?: string;
hasWechat?: boolean;
staffRole?: PartnerStaffRole;
permissions?: string[];
primaryAccountId?: string;
}
export interface PartnerStaffItem {
@@ -15,6 +17,7 @@ export interface PartnerStaffItem {
name: string;
phone: string;
staffRole: PartnerStaffRole;
permissions?: string[];
status: AccountStatus;
lastLoginAt?: string;
}
@@ -22,11 +25,13 @@ export interface PartnerStaffItem {
export interface CreatePartnerStaffRequest {
phone: string;
name: string;
permissions?: string[];
}
export interface UpdatePartnerStaffRequest {
name?: string;
staffRole?: PartnerStaffRole;
permissions?: string[];
status?: AccountStatus;
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
const DEFAULT_MOCK_CODE = process.env.MOCK_SMS_CODE ?? '123456';
const REDIS_CONTAINER = process.env.REDIS_CONTAINER ?? 'dukang-redis';
const REDIS_CONTAINER = process.env.REDIS_CONTAINER ?? 'dukang-v1-redis';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
+2 -2
View File
@@ -4,8 +4,8 @@
# 服务器:仅使用 .env.productionbash deploy/sync-api-env.sh production
# .env.development — 本地参考模板,不部署到服务器
DATABASE_URL="mysql://root:root@localhost:3306/dukang_haoke"
REDIS_URL="redis://localhost:6379"
DATABASE_URL="mysql://root:root@localhost:6016/dukang_haoke"
REDIS_URL="redis://localhost:6017"
JWT_SECRET="dukang-prev1-dev-secret-change-in-prod"
JWT_EXPIRES_IN="7d"
PORT=3000
+1
View File
@@ -11,6 +11,7 @@
"prisma:migrate": "prisma migrate dev",
"prisma:validate": "prisma validate",
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
"prisma:migrate-city-partner": "ts-node --transpile-only prisma/migrate-city-partner.ts",
"prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
+29
View File
@@ -0,0 +1,29 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
/** 清空开城城市及关联数据(保留 HQ 账户、商品目录等) */
async function main() {
await prisma.logPartnerAnalytics.deleteMany();
await prisma.redeemRecord.deleteMany();
await prisma.benefitCoupon.deleteMany();
await prisma.orderDelivery.deleteMany();
await prisma.order.deleteMany();
await prisma.commonEvent.deleteMany({ where: { refType: { in: ['CITY', 'WAREHOUSE', 'PARTNER', 'PARTNER_ACCOUNT', 'STORE'] } } });
await prisma.storeAccount.deleteMany();
await prisma.store.deleteMany();
await prisma.partnerBill.deleteMany();
await prisma.cityWarehouse.deleteMany();
await prisma.partnerAccount.deleteMany();
const result = await prisma.commonCity.deleteMany();
console.log(`Cleared ${result.count} cities and related open-city data.`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+84 -71
View File
@@ -148,25 +148,7 @@ CREATE TABLE common_promo_code (
UNIQUE KEY uk_common_promo_code_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码';
-- ===================== PARTNER先于 city/store =============================
DROP TABLE IF EXISTS partner_partner;
CREATE TABLE partner_partner (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
company_name VARCHAR(128) NOT NULL,
address VARCHAR(256) NOT NULL,
contact_phone VARCHAR(20) NOT NULL,
contract_no VARCHAR(64) DEFAULT NULL COMMENT '合同编号',
contract_signed_at DATETIME(3) DEFAULT NULL,
contract_expire_at DATETIME(3) DEFAULT NULL,
bank_account_name VARCHAR(64) DEFAULT NULL,
bank_account_no VARCHAR(32) DEFAULT NULL,
bank_branch VARCHAR(128) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_partner_partner_phone (contact_phone)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市合伙人主体';
-- ===================== PARTNER城市合伙人主账号 + 子账号 =============================
DROP TABLE IF EXISTS common_city;
CREATE TABLE common_city (
@@ -175,71 +157,99 @@ CREATE TABLE common_city (
name VARCHAR(64) NOT NULL,
province VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
partner_id BIGINT UNSIGNED DEFAULT NULL,
local_min_qty INT NOT NULL DEFAULT 2,
cross_min_qty INT NOT NULL DEFAULT 6,
max_partner_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0500 COMMENT '订单+核销佣金合计上限',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_common_city_code (code),
KEY idx_common_city_partner (partner_id),
CONSTRAINT fk_common_city_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE SET NULL
UNIQUE KEY uk_common_city_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='开城配置';
DROP TABLE IF EXISTS common_city_commission_rule;
CREATE TABLE common_city_commission_rule (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
city_id BIGINT UNSIGNED NOT NULL,
order_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
redeem_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
partner_profit_rate DECIMAL(5,4) NOT NULL DEFAULT 0.3500,
store_settlement_rate DECIMAL(5,4) NOT NULL DEFAULT 0.6000,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_common_city_commission_city (city_id),
CONSTRAINT fk_common_city_commission_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市佣金规则';
DROP TABLE IF EXISTS partner_account;
CREATE TABLE partner_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
partner_id BIGINT UNSIGNED NOT NULL,
phone VARCHAR(20) NOT NULL,
name VARCHAR(64) NOT NULL,
wx_open_id VARCHAR(64) DEFAULT NULL,
wx_union_id VARCHAR(64) DEFAULT NULL,
is_primary TINYINT NOT NULL DEFAULT 0,
parent_account_id BIGINT UNSIGNED DEFAULT NULL,
staff_role VARCHAR(16) DEFAULT NULL COMMENT 'PARTNER|INTERNAL|PROMOTER',
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
last_login_at DATETIME(3) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
phone VARCHAR(20) NOT NULL,
name VARCHAR(64) NOT NULL,
wx_open_id VARCHAR(64) DEFAULT NULL,
wx_union_id VARCHAR(64) DEFAULT NULL,
is_primary TINYINT NOT NULL DEFAULT 0,
parent_account_id BIGINT UNSIGNED DEFAULT NULL,
staff_role VARCHAR(16) DEFAULT NULL COMMENT 'PARTNER|INTERNAL|PROMOTER',
permissions JSON DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
last_login_at DATETIME(3) DEFAULT NULL,
city_id BIGINT UNSIGNED DEFAULT NULL COMMENT '主账号绑定城市',
scope_type VARCHAR(16) DEFAULT NULL COMMENT 'CITY_WIDE|DISTRICT',
district_codes JSON DEFAULT NULL,
order_commission_rate DECIMAL(5,4) DEFAULT 0.0000,
redeem_commission_rate DECIMAL(5,4) DEFAULT 0.0300,
binding_status VARCHAR(16) DEFAULT 'ACTIVE',
company_name VARCHAR(128) DEFAULT NULL,
address VARCHAR(256) DEFAULT NULL,
contact_phone VARCHAR(20) DEFAULT NULL,
contract_no VARCHAR(64) DEFAULT NULL,
contract_signed_at DATETIME(3) DEFAULT NULL,
contract_expire_at DATETIME(3) DEFAULT NULL,
bank_account_name VARCHAR(64) DEFAULT NULL,
bank_account_no VARCHAR(32) DEFAULT NULL,
bank_branch VARCHAR(128) DEFAULT NULL,
weekly_store_target INT DEFAULT 20,
managed_warehouse_id BIGINT UNSIGNED DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_partner_account_phone (phone),
KEY idx_partner_account_partner (partner_id),
CONSTRAINT fk_partner_account_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE RESTRICT,
UNIQUE KEY uk_partner_account_managed_warehouse (managed_warehouse_id),
KEY idx_partner_account_city_scope (city_id, scope_type),
KEY idx_partner_account_city_primary (city_id, is_primary),
KEY idx_partner_account_parent (parent_account_id),
KEY idx_partner_account_contact_phone (contact_phone),
CONSTRAINT fk_partner_account_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT,
CONSTRAINT fk_partner_account_parent FOREIGN KEY (parent_account_id) REFERENCES partner_account(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合伙人账号';
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市合伙人账号(主账号=实体+城市绑定)';
DROP TABLE IF EXISTS common_city_warehouse;
CREATE TABLE common_city_warehouse (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
city_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(128) NOT NULL,
address VARCHAR(256) NOT NULL,
contact_name VARCHAR(64) NOT NULL,
contact_phone VARCHAR(20) NOT NULL,
manager_type VARCHAR(16) NOT NULL COMMENT 'HQ|PARTNER',
partner_account_id BIGINT UNSIGNED DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY idx_city_warehouse_city (city_id),
KEY idx_city_warehouse_partner_account (partner_account_id),
CONSTRAINT fk_city_warehouse_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE CASCADE,
CONSTRAINT fk_city_warehouse_partner_account FOREIGN KEY (partner_account_id) REFERENCES partner_account(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市仓库';
ALTER TABLE partner_account
ADD CONSTRAINT fk_partner_account_managed_warehouse FOREIGN KEY (managed_warehouse_id) REFERENCES common_city_warehouse(id) ON DELETE SET NULL;
DROP TABLE IF EXISTS partner_bill;
CREATE TABLE partner_bill (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
bill_no VARCHAR(32) NOT NULL,
partner_id BIGINT UNSIGNED NOT NULL,
period_start DATETIME(3) NOT NULL,
period_end DATETIME(3) NOT NULL,
order_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '下单佣金汇总',
redeem_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '核销佣金汇总',
total_amount DECIMAL(10,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
confirmed_at DATETIME(3) DEFAULT NULL,
paid_at DATETIME(3) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
bill_no VARCHAR(32) NOT NULL,
partner_account_id BIGINT UNSIGNED NOT NULL,
period_start DATETIME(3) NOT NULL,
period_end DATETIME(3) NOT NULL,
order_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '下单佣金汇总',
redeem_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '核销佣金汇总',
total_amount DECIMAL(10,2) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
confirmed_at DATETIME(3) DEFAULT NULL,
paid_at DATETIME(3) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_partner_bill_no (bill_no),
KEY idx_partner_bill_partner_status (partner_id, status),
CONSTRAINT fk_partner_bill_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE RESTRICT
KEY idx_partner_bill_account_status (partner_account_id, status),
CONSTRAINT fk_partner_bill_account FOREIGN KEY (partner_account_id) REFERENCES partner_account(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合伙人T+30账单';
-- ===================== HQ =============================
@@ -345,7 +355,7 @@ DROP TABLE IF EXISTS store_store;
CREATE TABLE store_store (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
city_id BIGINT UNSIGNED NOT NULL,
partner_id BIGINT UNSIGNED NOT NULL,
partner_account_id BIGINT UNSIGNED NOT NULL,
category_id BIGINT UNSIGNED DEFAULT NULL,
name VARCHAR(128) NOT NULL,
phone VARCHAR(20) NOT NULL,
@@ -366,13 +376,14 @@ CREATE TABLE store_store (
bank_account_name VARCHAR(64) DEFAULT NULL,
bank_account_no VARCHAR(32) DEFAULT NULL,
bank_branch VARCHAR(128) DEFAULT NULL,
settlement_rate DECIMAL(5,4) NOT NULL DEFAULT 0.6000 COMMENT '门店核销结算比例',
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_store_city_status (city_id, status),
KEY idx_store_store_partner (partner_id),
KEY idx_store_store_partner_account (partner_account_id),
CONSTRAINT fk_store_store_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT,
CONSTRAINT fk_store_store_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE RESTRICT,
CONSTRAINT fk_store_store_partner_account FOREIGN KEY (partner_account_id) REFERENCES partner_account(id) ON DELETE RESTRICT,
CONSTRAINT fk_store_store_category FOREIGN KEY (category_id) REFERENCES common_store_category(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='餐饮门店';
@@ -446,7 +457,9 @@ CREATE TABLE user_order (
shipped_at DATETIME(3) DEFAULT NULL COMMENT '发货时间(冗余=user_order_delivery.shipping_at)',
completed_at DATETIME(3) DEFAULT NULL COMMENT '完成时间',
cancelled_at DATETIME(3) DEFAULT NULL COMMENT '取消时间',
pay_expire_at DATETIME(3) DEFAULT NULL COMMENT '待付款过期时间',
pay_expire_at DATETIME(3) DEFAULT NULL COMMENT '待付款过期时间',
partner_account_id_at_pay BIGINT UNSIGNED DEFAULT NULL,
order_commission_rate_at_pay DECIMAL(5,4) DEFAULT NULL,
remark VARCHAR(512) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
@@ -0,0 +1,64 @@
/**
* 将 legacy common_city.partner_id 迁移至 common_city_partnerCITY_WIDE)。
* 在 prisma db push 移除 partner_id 列之前运行;若列已不存在则跳过数据拷贝。
*
* 用法:cd server/dukang-api && pnpm prisma:migrate-city-partner
*/
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function columnExists(table: string, column: string): Promise<boolean> {
const rows = await prisma.$queryRawUnsafe<Array<{ cnt: bigint }>>(
`SELECT COUNT(*) AS cnt FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
table,
column,
);
return Number(rows[0]?.cnt ?? 0) > 0;
}
async function main() {
const hasPartnerId = await columnExists('common_city', 'partner_id');
if (!hasPartnerId) {
console.log('[migrate-city-partner] common_city.partner_id 已移除,跳过迁移');
return;
}
const cities = await prisma.$queryRawUnsafe<
Array<{ id: bigint; partner_id: bigint | null }>
>(`SELECT id, partner_id FROM common_city WHERE partner_id IS NOT NULL`);
let migrated = 0;
for (const row of cities) {
const cityId = row.id;
const partnerId = row.partner_id!;
const existing = await prisma.cityPartner.findFirst({
where: { cityId, partnerId },
});
if (existing) continue;
const rule = await prisma.commonCityCommissionRule.findUnique({ where: { cityId } });
await prisma.cityPartner.create({
data: {
cityId,
partnerId,
scopeType: 'CITY_WIDE',
orderCommissionRate: rule ? Number((rule as { orderCommissionRate?: unknown }).orderCommissionRate ?? 0) : 0,
redeemCommissionRate: rule ? Number((rule as { redeemCommissionRate?: unknown }).redeemCommissionRate ?? 0.03) : 0.03,
status: 'ACTIVE',
},
});
migrated += 1;
}
console.log(`[migrate-city-partner] 已迁移 ${migrated} 条 CITY_WIDE 绑定`);
}
main()
.catch((err) => {
console.error(err);
process.exit(1);
})
.finally(() => prisma.$disconnect());
@@ -0,0 +1,32 @@
/**
* Partner architecture unification migration stub.
*
* Schema change: Partner / CityPartner / CommonCityCommissionRule removed;
* PartnerAccount is the primary entity; Store.partnerAccountId;
* Order.partnerAccountIdAtPay.
*
* This is a breaking schema change — there is no incremental SQL migration path
* from the legacy v3.1 tables. Apply on a fresh or disposable database:
*
* cd server/dukang-api
* npx prisma db push --force-reset
* pnpm prisma:seed
*
* Do NOT run --force-reset against production.
*/
async function main() {
console.log(`
Partner account unification requires a full schema reset.
Run:
cd server/dukang-api
npx prisma db push --force-reset
pnpm prisma:seed
`);
}
main().catch((error) => {
console.error(error);
process.exit(1);
});
+108 -81
View File
@@ -106,6 +106,26 @@ enum CityStatus {
PAUSED
}
enum CityPartnerScopeType {
CITY_WIDE
DISTRICT
}
enum CityPartnerStatus {
ACTIVE
PAUSED
}
enum WarehouseManagerType {
HQ
PARTNER
}
enum WarehouseStatus {
ACTIVE
PAUSED
}
enum PartnerStaffRole {
PARTNER
INTERNAL
@@ -391,103 +411,109 @@ model CommonCity {
name String @db.VarChar(64)
province String @db.VarChar(32)
status CityStatus @default(PENDING)
partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
localMinQty Int @default(2) @map("local_min_qty")
crossMinQty Int @default(6) @map("cross_min_qty")
maxPartnerCommissionRate Decimal @default(0.05) @map("max_partner_commission_rate") @db.Decimal(5, 4)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
partner Partner? @relation(fields: [partnerId], references: [id], onDelete: SetNull)
commissionRule CommonCityCommissionRule?
stores Store[]
orders Order[]
warehouses CityWarehouse[]
partnerAccounts PartnerAccount[] @relation("PartnerAccountCity")
stores Store[]
orders Order[]
@@index([partnerId])
@@map("common_city")
}
model CommonCityCommissionRule {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @unique @map("city_id") @db.UnsignedBigInt
orderCommissionRate Decimal @default(0) @map("order_commission_rate") @db.Decimal(5, 4)
redeemCommissionRate Decimal @default(0) @map("redeem_commission_rate") @db.Decimal(5, 4)
partnerProfitRate Decimal @default(0.35) @map("partner_profit_rate") @db.Decimal(5, 4)
storeSettlementRate Decimal @default(0.60) @map("store_settlement_rate") @db.Decimal(5, 4)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
model CityWarehouse {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
name String @db.VarChar(128)
address String @db.VarChar(256)
contactName String @map("contact_name") @db.VarChar(64)
contactPhone String @map("contact_phone") @db.VarChar(20)
managerType WarehouseManagerType @map("manager_type")
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
status WarehouseStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
partnerAccount PartnerAccount? @relation("WarehouseManager", fields: [partnerAccountId], references: [id], onDelete: SetNull)
managedBy PartnerAccount? @relation("ManagedWarehouse")
@@map("common_city_commission_rule")
@@index([cityId])
@@index([partnerAccountId])
@@map("common_city_warehouse")
}
// ─── PARTNER ──────────────────────────────────────────
model Partner {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
companyName String @map("company_name") @db.VarChar(128)
address String @db.VarChar(256)
contactPhone String @map("contact_phone") @db.VarChar(20)
contractNo String? @map("contract_no") @db.VarChar(64)
contractSignedAt DateTime? @map("contract_signed_at") @db.DateTime(3)
contractExpireAt DateTime? @map("contract_expire_at") @db.DateTime(3)
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)
weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
cities CommonCity[]
accounts PartnerAccount[]
stores Store[]
bills PartnerBill[]
@@index([contactPhone])
@@map("partner_partner")
}
model PartnerAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
name String @db.VarChar(64)
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
isPrimary Int @default(0) @map("is_primary") @db.TinyInt
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
staffRole PartnerStaffRole? @map("staff_role")
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
phone String @unique @db.VarChar(20)
name String @db.VarChar(64)
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
isPrimary Int @default(0) @map("is_primary") @db.TinyInt
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
staffRole PartnerStaffRole? @map("staff_role")
permissions Json?
status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
cityId BigInt? @map("city_id") @db.UnsignedBigInt
scopeType CityPartnerScopeType? @map("scope_type")
districtCodes Json? @map("district_codes")
orderCommissionRate Decimal? @default(0) @map("order_commission_rate") @db.Decimal(5, 4)
redeemCommissionRate Decimal? @default(0.03) @map("redeem_commission_rate") @db.Decimal(5, 4)
bindingStatus CityPartnerStatus? @default(ACTIVE) @map("binding_status")
companyName String? @map("company_name") @db.VarChar(128)
address String? @db.VarChar(256)
contactPhone String? @map("contact_phone") @db.VarChar(20)
contractNo String? @map("contract_no") @db.VarChar(64)
contractSignedAt DateTime? @map("contract_signed_at") @db.DateTime(3)
contractExpireAt DateTime? @map("contract_expire_at") @db.DateTime(3)
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)
weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
managedWarehouseId BigInt? @unique @map("managed_warehouse_id") @db.UnsignedBigInt
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
parent PartnerAccount? @relation("PartnerAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: SetNull)
children PartnerAccount[] @relation("PartnerAccountHierarchy")
city CommonCity? @relation("PartnerAccountCity", fields: [cityId], references: [id], onDelete: Restrict)
managedWarehouse CityWarehouse? @relation("ManagedWarehouse", fields: [managedWarehouseId], references: [id], onDelete: SetNull)
managedWarehouses CityWarehouse[] @relation("WarehouseManager")
parent PartnerAccount? @relation("PartnerAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: SetNull)
children PartnerAccount[] @relation("PartnerAccountHierarchy")
stores Store[]
bills PartnerBill[]
@@index([partnerId])
@@index([cityId, scopeType])
@@index([cityId, isPrimary])
@@index([parentAccountId])
@@index([wxOpenId])
@@index([contactPhone])
@@map("partner_account")
}
model PartnerBill {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
billNo String @unique @map("bill_no") @db.VarChar(32)
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
periodStart DateTime @map("period_start") @db.DateTime(3)
periodEnd DateTime @map("period_end") @db.DateTime(3)
orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2)
redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
status PartnerBillStatus @default(DRAFT)
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
billNo String @unique @map("bill_no") @db.VarChar(32)
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
periodStart DateTime @map("period_start") @db.DateTime(3)
periodEnd DateTime @map("period_end") @db.DateTime(3)
orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2)
redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
status PartnerBillStatus @default(DRAFT)
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
@@index([partnerId, status])
@@index([partnerAccountId, status])
@@map("partner_bill")
}
@@ -626,7 +652,7 @@ model UserPromoAttribution {
model Store {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @db.UnsignedBigInt
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
categoryId BigInt? @map("category_id") @db.UnsignedBigInt
name String @db.VarChar(128)
phone String @db.VarChar(20)
@@ -647,11 +673,12 @@ model Store {
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)
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
cityRef CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
account StoreAccount?
@@ -660,7 +687,7 @@ model Store {
payouts StorePayout[]
@@index([cityId, status])
@@index([partnerId])
@@index([partnerAccountId])
@@map("store_store")
}
@@ -730,10 +757,12 @@ model Order {
shippedAt DateTime? @map("shipped_at") @db.DateTime(3)
completedAt DateTime? @map("completed_at") @db.DateTime(3)
cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3)
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt
orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4)
remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
@@ -915,8 +944,7 @@ model LogStoreAnalytics {
model LogPartnerAnalytics {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
eventName String @map("event_name") @db.VarChar(64)
clientApp ClientApp? @map("client_app")
refType String? @map("ref_type") @db.VarChar(32)
@@ -924,7 +952,6 @@ model LogPartnerAnalytics {
extraJson Json? @map("extra_json")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@index([partnerId, createdAt])
@@index([partnerAccountId, createdAt])
@@index([eventName, createdAt])
@@map("log_partner_analytics")
+502 -50
View File
@@ -1,421 +1,873 @@
import { PrismaClient, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
import { hashPassword } from '../src/common/crypto/password.util';
import { DEFAULT_PRODUCT_DETAIL_TEMPLATES } from './seeds/product-detail-templates.default';
const prisma = new PrismaClient();
async function createMockResource(
ownerType: ResourceOwnerType,
ownerId: bigint,
bizType: ResourceBizType,
url: string,
) {
return prisma.commonResource.create({
data: {
ownerType,
ownerId,
bizType,
mediaType: ResourceMediaType.IMAGE,
ossBucket: 'mock-dukang',
ossKey: `mock/${ownerType.toLowerCase()}/${ownerId}/${bizType.toLowerCase()}`,
url,
},
});
}
async function main() {
console.log('Seeding v3.1 data...');
await prisma.logUserAnalytics.deleteMany();
await prisma.logPartnerAnalytics.deleteMany();
await prisma.logThirdParty.deleteMany();
await prisma.storePayout.deleteMany();
await prisma.storeRating.deleteMany();
await prisma.redeemRecord.deleteMany();
await prisma.benefitCoupon.deleteMany();
await prisma.orderDelivery.deleteMany();
await prisma.order.deleteMany();
await prisma.commonEvent.deleteMany();
await prisma.commonTicket.deleteMany();
await prisma.userPromoAttribution.deleteMany();
await prisma.userCityPreference.deleteMany();
await prisma.userAddress.deleteMany();
await prisma.user.deleteMany();
await prisma.storeAccount.deleteMany();
await prisma.store.deleteMany();
await prisma.partnerBill.deleteMany();
await prisma.cityWarehouse.deleteMany();
await prisma.partnerAccount.deleteMany();
await prisma.commonCityCommissionRule.deleteMany();
await prisma.commonCity.deleteMany();
await prisma.partner.deleteMany();
await prisma.commonProductItem.deleteMany();
await prisma.commonProductDetailTemplate.deleteMany();
await prisma.commonStoreCategory.deleteMany();
await prisma.commonPromoCode.deleteMany();
await prisma.commonResource.deleteMany();
await prisma.hqAccount.deleteMany();
const partner = await prisma.partner.create({
data: {
companyName: '郑州城市合伙人',
address: '河南省郑州市金水区',
contactPhone: '13700000001',
bankAccountName: '郑州合伙人公司',
bankAccountNo: '6222021234567890',
bankBranch: '工商银行郑州分行',
weeklyStoreTarget: 20,
},
});
const city = await prisma.commonCity.create({
data: {
code: '410100',
name: '郑州市',
province: '河南省',
status: 'ACTIVE',
partnerId: partner.id,
localMinQty: 2,
crossMinQty: 6,
},
});
await prisma.commonCityCommissionRule.create({
const primaryAccount = await prisma.partnerAccount.create({
data: {
phone: '13700000001',
name: '郑州合伙人主账号',
isPrimary: 1,
staffRole: 'PARTNER',
status: 'ACTIVE',
cityId: city.id,
scopeType: 'CITY_WIDE',
orderCommissionRate: 0,
redeemCommissionRate: 0.03,
partnerProfitRate: 0.35,
storeSettlementRate: 0.6,
bindingStatus: 'ACTIVE',
companyName: '郑州城市合伙人',
address: '河南省郑州市金水区',
contactPhone: '13700000001',
bankAccountName: '郑州合伙人公司',
bankAccountNo: '6222021234567890',
bankBranch: '工商银行郑州分行',
weeklyStoreTarget: 20,
},
});
const warehouse = await prisma.cityWarehouse.create({
data: {
cityId: city.id,
name: '郑州中央仓',
address: '河南省郑州市金水区物流园1号',
contactName: '仓管张',
contactPhone: '13700000009',
managerType: 'PARTNER',
partnerAccountId: primaryAccount.id,
status: 'ACTIVE',
},
});
await prisma.partnerAccount.update({
where: { id: primaryAccount.id },
data: { managedWarehouseId: warehouse.id },
});
await prisma.partnerAccount.create({
data: {
phone: '13700000002',
name: '拓店员小李',
isPrimary: 0,
parentAccountId: primaryAccount.id,
staffRole: 'INTERNAL',
permissions: ['store:create', 'store:view'],
status: 'DISABLED',
},
});
const categories = await Promise.all([
prisma.commonStoreCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }),
prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }),
]);
for (const tpl of DEFAULT_PRODUCT_DETAIL_TEMPLATES) {
await prisma.commonProductDetailTemplate.create({
data: {
code: tpl.code,
name: tpl.name,
description: tpl.description,
aromaType: tpl.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG' | null,
storyTitle: tpl.storyTitle,
storyText: tpl.storyText,
features: tpl.features,
detailImageUrls: [],
suggestedDetailImageCount: tpl.suggestedDetailImageCount,
sortOrder: tpl.sortOrder,
status: 'ACTIVE',
},
});
}
const productDefs = [
{ skuCode: 'JZ-10', name: '酒祖杜康(国标特级10', subtitle: '清香型 53度', price: 128, sortOrder: 1, img: 'https://picsum.photos/seed/jiuzu10/400/400' },
{ skuCode: 'JZ-15', name: '酒祖杜康(国标特级15', subtitle: '清香型 53度', price: 168, sortOrder: 2, img: 'https://picsum.photos/seed/jiuzu15/400/400' },
{ skuCode: 'JZ-20', name: '酒祖杜康(国标特级20', subtitle: '清香型 53度', price: 298, sortOrder: 3, img: 'https://picsum.photos/seed/jiuzu20/400/400' },
{ skuCode: 'JZ-30', name: '酒祖杜康(国标特级30', subtitle: '清香型 53度', price: 498, sortOrder: 4, img: 'https://picsum.photos/seed/jiuzu30/400/400' },
];
const products = [];
for (const [i, def] of productDefs.entries()) {
const product = await prisma.commonProductItem.create({
data: {
skuCode: def.skuCode,
barcode69: `69000000000${i + 1}`,
name: def.name,
subtitle: def.subtitle,
aromaType: 'QINGXIANG',
spec: '500ml | 53度',
price: def.price,
benefitAmount: def.price,
status: 'ON_SALE',
sortOrder: def.sortOrder,
},
});
const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img);
await prisma.commonProductItem.update({
where: { id: product.id },
data: { coverResourceId: cover.id },
});
products.push(product);
}
await prisma.partnerAccount.create({
data: {
partnerId: partner.id,
phone: '13700000001',
name: '郑州合伙人主账号',
isPrimary: 1,
staffRole: 'PARTNER',
},
});
const primaryAccount = await prisma.partnerAccount.findUniqueOrThrow({
where: { phone: '13700000001' },
});
await prisma.partnerAccount.create({
data: {
partnerId: partner.id,
phone: '13700000002',
name: '拓店员小李',
isPrimary: 0,
parentAccountId: primaryAccount.id,
staffRole: 'INTERNAL',
status: 'DISABLED',
},
});
const storeDefs = [
{
name: '郑州老城店',
phone: '13910000001',
district: '金水区',
address: '花园路100号',
intro: '正宗河南菜,欢迎核销好客权益',
img: 'https://picsum.photos/seed/store1/400/300',
categoryId: categories[0].id,
settlementRate: 0.6,
withAccount: true,
},
{
name: '郑州美食城店',
phone: '13910000002',
district: '二七区',
address: '大学路200号',
intro: '地方特色餐饮',
img: 'https://picsum.photos/seed/store2/400/300',
categoryId: categories[1].id,
settlementRate: 0.65,
withAccount: false,
},
];
const createdStores: { id: bigint; name: string }[] = [];
for (const def of storeDefs) {
const store = await prisma.store.create({
data: {
cityId: city.id,
partnerId: partner.id,
partnerAccountId: primaryAccount.id,
categoryId: def.categoryId,
name: def.name,
phone: def.phone,
province: '河南省',
cityName: '郑州市',
district: def.district,
address: def.address,
intro: def.intro,
settlementRate: def.settlementRate,
status: 'OPEN',
openTime: '10:00',
closeTime: '22:00',
bankAccountName: def.name,
bankAccountNo: '6222029876543210',
bankBranch: '建设银行郑州分行',
},
});
const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img);
await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
if (def.withAccount) {
await prisma.storeAccount.create({
data: { storeId: store.id, phone: def.phone, name: def.name },
});
}
createdStores.push({ id: store.id, name: def.name });
}
const weekStart = (() => {
const d = new Date();
d.setHours(12, 0, 0, 0);
const day = d.getDay();
const diff = day === 0 ? 6 : day - 1;
d.setDate(d.getDate() - diff);
d.setHours(10, 0, 0, 0);
return d;
})();
const newWeekStore = await prisma.store.create({
data: {
cityId: city.id,
partnerId: partner.id,
partnerAccountId: primaryAccount.id,
categoryId: categories[0].id,
name: '本周新签体验店',
phone: '13910000003',
province: '河南省',
cityName: '郑州市',
district: '中原区',
address: '建设路88号',
intro: '本周新签约门店',
settlementRate: 0.6,
status: 'OPEN',
openTime: '10:00',
closeTime: '22:00',
bankAccountName: '本周新签体验店',
bankAccountNo: '6222029876543211',
bankBranch: '农业银行郑州分行',
createdAt: new Date(weekStart.getTime() + 2 * 24 * 60 * 60 * 1000),
},
});
createdStores.push({ id: newWeekStore.id, name: newWeekStore.name });
await prisma.user.create({
data: {
userNo: 'DK88293401',
phone: '13800000001',
phoneVerifiedAt: new Date(),
nickname: '测试用户',
cityPreference: {
create: {
selectedCityCode: '410100',
selectedDistrict: '郑州市',
locateCityCode: '410100',
locateDistrict: '金水区',
},
},
},
});
await prisma.hqAccount.create({
data: {
phone: '13600000001',
loginName: 'admin',
passwordHash: hashPassword('dukang@123!'),
name: '总部管理员',
adminRole: 'SUPER_ADMIN',
},
});
await prisma.commonPromoCode.create({
data: {
code: 'DKHQ001',
name: '总部品鉴会',
status: 'ACTIVE',
},
});
await prisma.commonPromoCode.create({
data: {
code: 'DKDEMO1',
name: '郑州品鉴会演示',
status: 'ACTIVE',
},
});
const user = await prisma.user.findUniqueOrThrow({ where: { phone: '13800000001' } });
const product = products[0];
const orderDefs = [
{ dayOffset: 0, payAmount: 1198, quantity: 2 },
{ dayOffset: 1, payAmount: 599, quantity: 1 },
{ dayOffset: 2, payAmount: 1760, quantity: 2 },
{ dayOffset: 4, payAmount: 1299, quantity: 1 },
{ dayOffset: 5, payAmount: 880, quantity: 1 },
];
const coupons: { id: bigint; balance: number }[] = [];
for (const [index, def] of orderDefs.entries()) {
const paidAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 14 * 60 * 60 * 1000);
const listAmount = def.payAmount;
const order = await prisma.order.create({
data: {
orderNo: `WK${Date.now()}${index}`,
userId: user.id,
cityId: city.id,
status: 'COMPLETED',
payStatus: 'PAID',
deliveryType: 'LOCAL',
productId: product.id,
barcode69: product.barcode69,
productName: product.name,
productSpec: product.spec,
quantity: def.quantity,
listUnitPrice: product.price,
listAmount,
productAmount: listAmount,
payAmount: listAmount,
benefitAmount: listAmount,
receiverName: '测试用户',
receiverPhone: user.phone,
receiverPhone: user.phone!,
receiverAddress: '郑州市金水区测试路1号',
receiverProvince: '河南省',
receiverCity: '郑州市',
receiverDistrict: '金水区',
paidAt,
completedAt: paidAt,
partnerAccountIdAtPay: primaryAccount.id,
orderCommissionRateAtPay: 0,
},
});
const coupon = await prisma.benefitCoupon.create({
data: {
couponNo: `CPN${Date.now()}${index}`,
userId: user.id,
orderId: order.id,
totalAmount: listAmount,
balance: listAmount,
sourceProduct: product.name,
},
});
coupons.push({ id: coupon.id, balance: listAmount });
}
const redeemDefs = [
{ storeIndex: 0, amount: 42800, dayOffset: 1 },
{ storeIndex: 1, amount: 38400, dayOffset: 2 },
{ storeIndex: 2, amount: 31200, dayOffset: 3 },
{ storeIndex: 0, amount: 42800, dayOffset: 1, settlementRate: 0.6 },
{ storeIndex: 1, amount: 38400, dayOffset: 2, settlementRate: 0.65 },
{ storeIndex: 2, amount: 31200, dayOffset: 3, settlementRate: 0.6 },
];
for (const [index, def] of redeemDefs.entries()) {
const coupon = coupons[index];
const store = createdStores[def.storeIndex];
const createdAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 16 * 60 * 60 * 1000);
const settleAmount = Math.round(def.amount * def.settlementRate * 100) / 100;
await prisma.redeemRecord.create({
data: {
redeemNo: `RD${Date.now()}${index}`,
userId: user.id,
couponId: coupon.id,
storeId: store.id,
amount: def.amount,
settleAmount: Math.round(def.amount * 0.6 * 100) / 100,
settleAmount,
createdAt,
},
});
await prisma.benefitCoupon.update({
where: { id: coupon.id },
data: {
usedAmount: def.amount,
balance: Math.max(0, coupon.balance - def.amount),
},
});
}
const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
await prisma.partnerBill.create({
data: {
billNo: `PB${Date.now()}`,
partnerId: partner.id,
partnerAccountId: primaryAccount.id,
periodStart,
periodEnd,
orderCommission: 1200,
redeemCommission: 800,
totalAmount: 2000,
status: 'CONFIRMED',
confirmedAt: now,
},
});
console.log('Seed complete:', {
city: city.name,
warehouse: warehouse.name,
primaryAccount: primaryAccount.phone,
detailTemplates: DEFAULT_PRODUCT_DETAIL_TEMPLATES.length,
products: products.length,
stores: storeDefs.length,
stores: createdStores.length,
testPhones: {
user: '13800000001',
partner: '13700000001',
partnerStaff: '13700000002',
hq: '13600000001',
},
});
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+2
View File
@@ -15,6 +15,7 @@ import { SettlementModule } from './modules/settlement/settlement.module';
import { AnalyticsModule } from './modules/analytics/analytics.module';
import { JobsModule } from './jobs/jobs.module';
import { OpsModule } from './modules/ops/ops.module';
import { CityScopeModule } from './modules/city-scope/city-scope.module';
import { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
import { CallbacksModule } from './callbacks/callbacks.module';
@@ -41,6 +42,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
AnalyticsModule,
JobsModule,
OpsModule,
CityScopeModule,
CommonModule,
HqOperationModule,
CallbacksModule,
@@ -2,6 +2,13 @@
export const HqOperationAction = {
CITY_CREATE: 'CITY_CREATE',
CITY_UPDATE: 'CITY_UPDATE',
CITY_DELETE: 'CITY_DELETE',
CITY_PARTNER_BIND: 'CITY_PARTNER_BIND',
CITY_PARTNER_UPDATE: 'CITY_PARTNER_UPDATE',
CITY_PARTNER_UNBIND: 'CITY_PARTNER_UNBIND',
WAREHOUSE_CREATE: 'WAREHOUSE_CREATE',
WAREHOUSE_UPDATE: 'WAREHOUSE_UPDATE',
WAREHOUSE_DELETE: 'WAREHOUSE_DELETE',
PARTNER_CREATE: 'PARTNER_CREATE',
PARTNER_UPDATE: 'PARTNER_UPDATE',
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE',
@@ -47,6 +54,13 @@ export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOp
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.CITY_CREATE]: '新增开城城市',
[HqOperationAction.CITY_UPDATE]: '编辑开城城市',
[HqOperationAction.CITY_DELETE]: '删除开城城市',
[HqOperationAction.CITY_PARTNER_BIND]: '绑定城市合伙人',
[HqOperationAction.CITY_PARTNER_UPDATE]: '编辑城市合伙人绑定',
[HqOperationAction.CITY_PARTNER_UNBIND]: '解绑城市合伙人',
[HqOperationAction.WAREHOUSE_CREATE]: '新增城市仓库',
[HqOperationAction.WAREHOUSE_UPDATE]: '编辑城市仓库',
[HqOperationAction.WAREHOUSE_DELETE]: '删除城市仓库',
[HqOperationAction.PARTNER_CREATE]: '新增城市合伙人',
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
@@ -17,8 +17,8 @@ export type TrackStoreEventInput = TrackEventInput & {
};
export type TrackPartnerEventInput = TrackEventInput & {
partnerAccountId?: bigint;
partnerId: bigint;
/** 主账号 ID,用于合伙人维度聚合 */
partnerAccountId: bigint;
};
@Injectable()
@@ -64,21 +64,21 @@ export class AnalyticsService {
}
async trackPartnerOne(
partnerAccountId: bigint | undefined,
actorAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackPartnerEventInput,
) {
await this.prisma.logPartnerAnalytics.create({
data: this.toPartnerRow(partnerAccountId, clientApp, event),
data: this.toPartnerRow(actorAccountId, clientApp, event),
});
}
trackPartnerOneSafe(
partnerAccountId: bigint | undefined,
actorAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackPartnerEventInput,
) {
void this.trackPartnerOne(partnerAccountId, clientApp, event).catch(() => {});
void this.trackPartnerOne(actorAccountId, clientApp, event).catch(() => {});
}
private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
@@ -110,17 +110,16 @@ export class AnalyticsService {
}
private toPartnerRow(
partnerAccountId: bigint | undefined,
actorAccountId: bigint | undefined,
clientApp: ClientApp | string,
event: TrackPartnerEventInput,
) {
return {
partnerAccountId,
partnerId: event.partnerId,
partnerAccountId: event.partnerAccountId,
eventName: event.eventName,
clientApp: clientApp as ClientApp,
refType: event.refType,
refId: event.refId,
refId: event.refId ?? actorAccountId,
extraJson: event.extraJson as never,
};
}
@@ -10,10 +10,28 @@ export class CatalogService {
async listCities() {
const cities = await this.prisma.commonCity.findMany({
where: { status: 'ACTIVE' },
include: { partner: { select: { companyName: true } } },
include: {
partnerAccounts: {
where: { isPrimary: 1, bindingStatus: 'ACTIVE' },
orderBy: { createdAt: 'asc' },
select: { id: true, companyName: true, scopeType: true },
},
},
orderBy: { name: 'asc' },
});
return serializeBigInt(cities);
return serializeBigInt(
cities.map((city) => ({
...city,
partnerBindingCount: city.partnerAccounts.length,
partnerBindings: city.partnerAccounts.map((bp) => ({
partnerAccountId: bp.id.toString(),
partnerId: bp.id.toString(),
companyName: bp.companyName,
scopeType: bp.scopeType,
})),
partnerAccounts: undefined,
})),
);
}
async listProducts(aromaType?: string, cityCode?: string) {
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { PartnerCityService } from './partner-city.service';
import { CityWarehouseService } from './city-warehouse.service';
@Module({
providers: [PartnerCityService, CityWarehouseService],
exports: [PartnerCityService, CityWarehouseService],
})
export class CityScopeModule {}
@@ -0,0 +1,207 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, WarehouseManagerType, WarehouseStatus } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from './partner-city.service';
import type { AdminCityWarehousesQueryDto } from '../ops/dto/admin-query.dto';
export type CreateCityWarehouseInput = {
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: WarehouseManagerType;
partnerAccountId?: bigint;
status?: WarehouseStatus;
};
export type UpdateCityWarehouseInput = Partial<CreateCityWarehouseInput>;
@Injectable()
export class CityWarehouseService {
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async listByCity(cityId: bigint) {
const rows = await this.prisma.cityWarehouse.findMany({
where: { cityId },
include: {
partnerAccount: { select: { id: true, companyName: true } },
},
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toDto(row));
}
async listAll(query: AdminCityWarehousesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CityWarehouseWhereInput = {};
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.name) where.name = { contains: query.name };
if (query.managerType) where.managerType = query.managerType as Prisma.EnumWarehouseManagerTypeFilter['equals'];
if (query.status) where.status = query.status as Prisma.EnumWarehouseStatusFilter['equals'];
const [rows, total] = await Promise.all([
this.prisma.cityWarehouse.findMany({
where,
skip: (page - 1) * pageSize,
take: pageSize,
include: {
partnerAccount: { select: { id: true, companyName: true } },
city: { select: { id: true, name: true, code: true } },
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.cityWarehouse.count({ where }),
]);
return serializeBigInt({
items: rows.map((row) => ({
...this.toDto(row),
cityName: row.city.name,
cityCode: row.city.code,
})),
total,
page,
pageSize,
});
}
async create(cityId: bigint, input: CreateCityWarehouseInput) {
await this.assertCityExists(cityId);
await this.validateManager(input.managerType, input.partnerAccountId, cityId);
const row = await this.prisma.cityWarehouse.create({
data: {
cityId,
name: input.name.trim(),
address: input.address.trim(),
contactName: input.contactName.trim(),
contactPhone: input.contactPhone.trim(),
managerType: input.managerType,
partnerAccountId: input.managerType === 'PARTNER' ? input.partnerAccountId : null,
status: input.status ?? 'ACTIVE',
},
include: {
partnerAccount: { select: { id: true, companyName: true } },
},
});
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
return this.toDto(row);
}
async update(id: bigint, input: UpdateCityWarehouseInput) {
const current = await this.prisma.cityWarehouse.findUnique({ where: { id } });
if (!current) throw new NotFoundException('仓库不存在');
const managerType = input.managerType ?? (current.managerType as WarehouseManagerType);
const partnerAccountId =
managerType === 'PARTNER'
? input.partnerAccountId ?? current.partnerAccountId ?? undefined
: null;
await this.validateManager(managerType, partnerAccountId ?? undefined, current.cityId);
const row = await this.prisma.cityWarehouse.update({
where: { id },
data: {
...(input.name !== undefined ? { name: input.name.trim() } : {}),
...(input.address !== undefined ? { address: input.address.trim() } : {}),
...(input.contactName !== undefined ? { contactName: input.contactName.trim() } : {}),
...(input.contactPhone !== undefined ? { contactPhone: input.contactPhone.trim() } : {}),
...(input.managerType !== undefined ? { managerType: input.managerType } : {}),
...(input.managerType !== undefined || input.partnerAccountId !== undefined
? { partnerAccountId: managerType === 'PARTNER' ? partnerAccountId : null }
: {}),
...(input.status !== undefined ? { status: input.status } : {}),
},
include: {
partnerAccount: { select: { id: true, companyName: true } },
},
});
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
return this.toDto(row);
}
async remove(id: bigint) {
const current = await this.prisma.cityWarehouse.findUnique({ where: { id } });
if (!current) throw new NotFoundException('仓库不存在');
await this.prisma.partnerAccount.updateMany({
where: { managedWarehouseId: id },
data: { managedWarehouseId: null },
});
await this.prisma.cityWarehouse.delete({ where: { id } });
return { ok: true, id: id.toString() };
}
private async syncManagedWarehouse(
warehouseId: bigint,
managerType: WarehouseManagerType,
partnerAccountId: bigint | null,
) {
await this.prisma.partnerAccount.updateMany({
where: { managedWarehouseId: warehouseId },
data: { managedWarehouseId: null },
});
if (managerType === 'PARTNER' && partnerAccountId) {
await this.prisma.partnerAccount.updateMany({
where: { id: partnerAccountId, managedWarehouseId: { not: warehouseId } },
data: { managedWarehouseId: null },
});
await this.prisma.partnerAccount.update({
where: { id: partnerAccountId },
data: { managedWarehouseId: warehouseId },
});
}
}
private validateManager(
managerType: WarehouseManagerType,
partnerAccountId: bigint | undefined,
cityId: bigint,
) {
if (managerType === 'PARTNER') {
if (!partnerAccountId) throw new BadRequestException('合伙人管仓须指定合伙人');
return this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, cityId);
}
}
private async assertCityExists(cityId: bigint) {
const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } });
if (!city) throw new NotFoundException('开城城市不存在');
}
private toDto(row: {
id: bigint;
cityId: bigint;
name: string;
address: string;
contactName: string;
contactPhone: string;
managerType: string;
partnerAccountId: bigint | null;
status: string;
createdAt: Date;
updatedAt: Date;
partnerAccount?: { id: bigint; companyName: string | null } | null;
}) {
return serializeBigInt({
id: row.id.toString(),
cityId: row.cityId.toString(),
name: row.name,
address: row.address,
contactName: row.contactName,
contactPhone: row.contactPhone,
managerType: row.managerType,
partnerAccountId: row.partnerAccountId?.toString() ?? null,
partnerCompanyName: row.partnerAccount?.companyName ?? null,
status: row.status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
}
}
@@ -0,0 +1,164 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
import { resolveOrderCityPartner, validatePartnerCityBinding } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
const PRIMARY_WHERE = { isPrimary: 1 } as const;
@Injectable()
export class PartnerCityService {
constructor(private readonly prisma: PrismaService) {}
async listByCity(cityId: bigint) {
const rows = await this.prisma.partnerAccount.findMany({
where: { ...PRIMARY_WHERE, cityId },
include: { city: { select: { id: true, code: true, name: true } } },
orderBy: [{ scopeType: 'asc' }, { createdAt: 'asc' }],
});
return rows.map((row) => this.toDto(row));
}
async listCityIdsForPartnerAccount(partnerAccountId: bigint): Promise<bigint[]> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
if (!primary.cityId) return [];
return [primary.cityId];
}
async assertPartnerAccountBoundToCity(partnerAccountId: bigint, cityId: bigint) {
const row = await this.prisma.partnerAccount.findFirst({
where: {
id: partnerAccountId,
...PRIMARY_WHERE,
cityId,
bindingStatus: 'ACTIVE',
},
});
if (!row) {
throw new BadRequestException('合伙人未绑定该开城城市');
}
return row;
}
/** @deprecated */
async assertPartnerBoundToCity(partnerAccountId: bigint, cityId: bigint) {
return this.assertPartnerAccountBoundToCity(partnerAccountId, cityId);
}
async resolveForOrder(cityId: bigint, receiverDistrict?: string | null) {
const bindings = await this.prisma.partnerAccount.findMany({
where: { ...PRIMARY_WHERE, cityId, bindingStatus: 'ACTIVE' },
});
const ref = resolveOrderCityPartner(
bindings.map((b) => ({
id: b.id.toString(),
partnerAccountId: b.id.toString(),
scopeType: b.scopeType as CityPartnerScopeType,
districtCodes: this.parseDistrictCodes(b.districtCodes),
orderCommissionRate: Number(b.orderCommissionRate ?? 0),
redeemCommissionRate: Number(b.redeemCommissionRate ?? 0.03),
bindingStatus: b.bindingStatus as CityPartnerStatus,
})),
receiverDistrict,
);
if (!ref) return null;
return {
partnerAccountId: BigInt(ref.partnerAccountId),
orderCommissionRate: ref.orderCommissionRate,
redeemCommissionRate: ref.redeemCommissionRate,
};
}
async validatePrimaryBinding(
cityId: bigint,
input: {
partnerAccountId?: string;
scopeType: CityPartnerScopeType;
districtCodes?: string[];
},
excludeId?: bigint,
) {
const existing = await this.prisma.partnerAccount.findMany({
where: { ...PRIMARY_WHERE, cityId, ...(excludeId ? { NOT: { id: excludeId } } : {}) },
});
const validation = validatePartnerCityBinding(
existing.map((r) => ({
id: r.id.toString(),
partnerAccountId: r.id.toString(),
scopeType: r.scopeType as CityPartnerScopeType,
districtCodes: this.parseDistrictCodes(r.districtCodes),
})),
{
partnerAccountId: input.partnerAccountId ?? 'new',
scopeType: input.scopeType,
districtCodes: input.districtCodes,
},
excludeId?.toString(),
);
if (!validation.ok) throw new BadRequestException(validation.message);
}
async buildPartnerOrderWhere(partnerAccountId: bigint): Promise<Prisma.OrderWhereInput> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
if (!primary.cityId) return { id: -1n };
return { cityId: primary.cityId };
}
async buildPartnerCityWhere(partnerAccountId: bigint): Promise<Prisma.CommonCityWhereInput> {
const primary = await this.resolvePrimaryAccount(partnerAccountId);
if (!primary.cityId) return { id: -1n };
return { id: primary.cityId };
}
async resolvePrimaryAccount(accountId: bigint) {
const account = await this.prisma.partnerAccount.findUnique({ where: { id: accountId } });
if (!account) throw new NotFoundException('合伙人账号不存在');
if (account.isPrimary === 1) return account;
if (!account.parentAccountId) {
throw new BadRequestException('子账号缺少主账号');
}
return this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: account.parentAccountId } });
}
parseDistrictCodes(value: Prisma.JsonValue | null): string[] | null {
if (!value || !Array.isArray(value)) return null;
return value.map((v) => String(v));
}
toDto(row: {
id: bigint;
cityId: bigint | null;
companyName: string | null;
phone: string;
name: string;
scopeType: string | null;
districtCodes: Prisma.JsonValue | null;
orderCommissionRate: Prisma.Decimal | null;
redeemCommissionRate: Prisma.Decimal | null;
bindingStatus: string | null;
managedWarehouseId: bigint | null;
status: string;
createdAt: Date;
updatedAt: Date;
city?: { id: bigint; code: string; name: string } | null;
}) {
return serializeBigInt({
id: row.id.toString(),
cityId: row.cityId?.toString() ?? null,
cityName: row.city?.name ?? null,
cityCode: row.city?.code ?? null,
companyName: row.companyName,
phone: row.phone,
name: row.name,
scopeType: row.scopeType,
districtCodes: this.parseDistrictCodes(row.districtCodes),
orderCommissionRate: Number(row.orderCommissionRate ?? 0),
redeemCommissionRate: Number(row.redeemCommissionRate ?? 0.03),
bindingStatus: row.bindingStatus,
managedWarehouseId: row.managedWarehouseId?.toString() ?? null,
status: row.status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
}
}
@@ -168,15 +168,15 @@ export class AuthService {
}
private trackPartnerEvent(
partnerAccountId: bigint | undefined,
partnerId: bigint,
actorAccountId: bigint | undefined,
primaryAccountId: bigint,
clientApp: ClientApp | string,
eventName: string,
extraJson?: Record<string, unknown>,
ref?: { refType?: string; refId?: bigint },
) {
this.analyticsService.trackPartnerOneSafe(partnerAccountId, clientApp, {
partnerId,
this.analyticsService.trackPartnerOneSafe(actorAccountId, clientApp, {
partnerAccountId: primaryAccountId,
eventName,
refType: ref?.refType,
refId: ref?.refId,
@@ -184,10 +184,42 @@ export class AuthService {
});
}
private async resolvePrimaryAccount(accountId: bigint) {
const account = await this.prisma.partnerAccount.findUnique({ where: { id: accountId } });
if (!account) throw new NotFoundException('合伙人账号不存在');
if (account.isPrimary === 1) return account;
if (!account.parentAccountId) {
throw new BadRequestException('子账号缺少主账号');
}
return this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: account.parentAccountId } });
}
private partnerTokenPayload(
account: {
id: bigint;
name: string;
phone: string;
isPrimary: number;
staffRole: string | null;
permissions?: unknown;
},
primary: { id: bigint; companyName: string | null },
) {
return {
id: account.id.toString(),
primaryAccountId: primary.id.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
staffRole: account.staffRole ?? undefined,
companyName: primary.companyName ?? undefined,
permissions: Array.isArray(account.permissions) ? account.permissions : undefined,
};
}
private async assertPartnerAccountByPhone(phone: string) {
const account = await this.prisma.partnerAccount.findUnique({
where: { phone },
include: { partner: true },
});
if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
@@ -197,11 +229,12 @@ export class AuthService {
async checkPartnerPhone(phone: string) {
const normalizedPhone = this.assertMobilePhone(phone);
const account = await this.assertPartnerAccountByPhone(normalizedPhone);
const primary = await this.resolvePrimaryAccount(account.id);
return {
ok: true,
maskedPhone: this.maskPhone(normalizedPhone),
name: account.name,
companyName: account.partner.companyName,
companyName: primary.companyName,
};
}
@@ -306,12 +339,13 @@ export class AuthService {
) {
const partnerAccount = await this.prisma.partnerAccount.findUnique({
where: { id: actorRef.refId },
select: { id: true, partnerId: true },
select: { id: true, isPrimary: true, parentAccountId: true },
});
if (partnerAccount) {
const primary = await this.resolvePrimaryAccount(partnerAccount.id);
this.trackPartnerEvent(
partnerAccount.id,
partnerAccount.partnerId,
primary.id,
clientApp,
'partner_sms_send',
{
@@ -415,20 +449,12 @@ export class AuthService {
private async buildPartnerSessionResponse(accountId: bigint, clientApp: ClientApp) {
const account = await this.prisma.partnerAccount.findUnique({
where: { id: accountId },
include: { partner: true },
});
if (!account || account.status !== 'ACTIVE') {
throw new UnauthorizedException('Invalid refresh token');
}
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
id: account.id.toString(),
partnerId: account.partnerId.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
staffRole: account.staffRole ?? undefined,
companyName: account.partner.companyName,
});
const primary = await this.resolvePrimaryAccount(account.id);
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
}
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
@@ -601,7 +627,8 @@ export class AuthService {
} catch (err) {
const account = await this.prisma.partnerAccount.findUnique({ where: { phone: normalizedPhone } });
if (account) {
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_sms_verify_fail', {
const primary = await this.resolvePrimaryAccount(account.id);
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_sms_verify_fail', {
phone: this.maskPhone(normalizedPhone),
reason: err instanceof BadRequestException ? err.message : '验证码错误',
});
@@ -610,29 +637,21 @@ export class AuthService {
}
const account = await this.prisma.partnerAccount.findUnique({
where: { phone: normalizedPhone },
include: { partner: true },
});
if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
const primary = await this.resolvePrimaryAccount(account.id);
await this.prisma.partnerAccount.update({
where: { id: account.id },
data: { lastLoginAt: new Date() },
});
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_sms_login', {
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_sms_login', {
phone: this.maskPhone(normalizedPhone),
});
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_login_success', {
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_login_success', {
method: 'sms',
});
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
id: account.id.toString(),
partnerId: account.partnerId.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
staffRole: account.staffRole ?? undefined,
companyName: account.partner.companyName,
});
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
}
async loginHq(phone: string, code: string, clientApp: ClientApp) {
@@ -734,9 +753,14 @@ export class AuthService {
if (actorType === 'PARTNER') {
const account = await this.prisma.partnerAccount.findUnique({
where: { id: actorId },
include: { partner: true },
});
return serializeBigInt(account);
if (!account) return null;
const primary = await this.resolvePrimaryAccount(account.id);
return serializeBigInt({
...account,
primaryAccountId: primary.id,
companyName: primary.companyName,
});
}
if (actorType === 'HQ') {
const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } });
@@ -1102,7 +1126,6 @@ export class AuthService {
const account = await this.prisma.partnerAccount.findUnique({
where: { id: partnerAccountId },
include: { partner: true },
});
if (!account) throw new BadRequestException('合伙人账号不存在');
@@ -1120,19 +1143,12 @@ export class AuthService {
wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(),
},
include: { partner: true },
});
const primary = await this.resolvePrimaryAccount(updated.id);
this.trackPartnerEvent(updated.id, updated.partnerId, clientApp, 'partner_wechat_bind', { platform });
this.trackPartnerEvent(updated.id, primary.id, clientApp, 'partner_wechat_bind', { platform });
return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, {
id: updated.id.toString(),
partnerId: updated.partnerId.toString(),
name: updated.name,
phone: updated.phone,
isPrimary: updated.isPrimary === 1,
companyName: updated.partner.companyName,
});
return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(updated, primary));
}
async loginPartnerWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
@@ -1144,7 +1160,6 @@ export class AuthService {
let account = await this.prisma.partnerAccount.findFirst({
where: { wxOpenId: session.openId },
include: { partner: true },
});
if (!account && this.wechatProvider.isMock()) {
@@ -1152,7 +1167,6 @@ export class AuthService {
account = await this.prisma.partnerAccount.findFirst({
where: { status: 'ACTIVE' },
orderBy: [{ isPrimary: 'desc' }, { id: 'asc' }],
include: { partner: true },
});
}
@@ -1167,23 +1181,15 @@ export class AuthService {
wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(),
},
include: { partner: true },
});
const primary = await this.resolvePrimaryAccount(account.id);
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_wechat_login', { platform });
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_login_success', {
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_wechat_login', { platform });
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_login_success', {
method: 'wechat',
});
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
id: account.id.toString(),
partnerId: account.partnerId.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
staffRole: account.staffRole ?? undefined,
companyName: account.partner.companyName,
});
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
}
private async mergeUsers(guestId: bigint, primaryId: bigint): Promise<UserRow> {
@@ -1,4 +1,4 @@
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { AccountStatus, PartnerStaffRole } from '@dukang/shared-types';
export class CreatePartnerStaffDto {
@@ -15,6 +15,11 @@ export class CreatePartnerStaffDto {
@IsString()
@IsIn(Object.values(PartnerStaffRole))
staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
}
export class UpdatePartnerStaffDto {
@@ -27,6 +32,11 @@ export class UpdatePartnerStaffDto {
@IsOptional()
staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
@IsString()
@IsIn(Object.values(AccountStatus))
@IsOptional()
@@ -17,7 +17,7 @@ export class PartnerStaffController {
@Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreatePartnerStaffDto) {
return this.staffService.createStaff(user.actorId, dto);
return this.staffService.createStaff(user, dto);
}
@Put(':id')
@@ -26,11 +26,11 @@ export class PartnerStaffController {
@Param('id') id: string,
@Body() dto: UpdatePartnerStaffDto,
) {
return this.staffService.updateStaff(user.actorId, BigInt(id), dto);
return this.staffService.updateStaff(user, BigInt(id), dto);
}
@Delete(':id')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.staffService.deleteStaff(user.actorId, BigInt(id));
return this.staffService.deleteStaff(user, BigInt(id));
}
}
@@ -1,114 +1,182 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PartnerStaffRole } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
@Injectable()
export class PartnerStaffService {
constructor(private readonly prisma: PrismaService) {}
async listStaff(parentAccountId: bigint) {
const rows = await this.prisma.partnerAccount.findMany({
where: { parentAccountId },
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toStaffItem(row));
}
async createStaff(parentAccountId: bigint, dto: CreatePartnerStaffDto) {
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId },
});
if (parent.isPrimary !== 1) {
throw new BadRequestException('仅主账号可添加子账号');
}
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号码');
}
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (existing) throw new BadRequestException('该手机号已被使用');
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
const account = await this.prisma.partnerAccount.create({
data: {
partnerId: parent.partnerId,
phone,
name,
staffRole: (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL,
isPrimary: 0,
parentAccountId: parent.id,
status: 'DISABLED',
},
});
return this.toStaffItem(account);
}
async updateStaff(parentAccountId: bigint, staffId: bigint, dto: UpdatePartnerStaffDto) {
const staff = await this.assertStaffOwned(parentAccountId, staffId);
const data: Record<string, unknown> = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
data.name = name;
}
if (dto.staffRole !== undefined) {
data.staffRole = dto.staffRole as PartnerStaffRole;
}
if (dto.status !== undefined) {
data.status = dto.status;
}
const updated = await this.prisma.partnerAccount.update({
where: { id: staff.id },
data,
});
return this.toStaffItem(updated);
}
async deleteStaff(parentAccountId: bigint, staffId: bigint) {
const staff = await this.assertStaffOwned(parentAccountId, staffId);
await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
return { ok: true };
}
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
const staff = await this.prisma.partnerAccount.findFirst({
where: { id: staffId, parentAccountId },
});
if (!staff) throw new NotFoundException('子账号不存在');
return staff;
}
private toStaffItem(row: {
id: bigint;
name: string;
phone: string;
staffRole: string | null;
status: string;
lastLoginAt: Date | null;
}) {
return serializeBigInt({
id: row.id.toString(),
name: row.name,
phone: this.maskPhone(row.phone),
staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL,
status: row.status,
lastLoginAt: row.lastLoginAt?.toISOString(),
});
}
private maskPhone(phone: string): string {
if (phone.length !== 11) return phone;
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
}
}
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PartnerStaffRole } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AnalyticsService } from '../analytics/analytics.service';
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
@Injectable()
export class PartnerStaffService {
constructor(
private readonly prisma: PrismaService,
private readonly analytics: AnalyticsService,
) {}
async listStaff(parentAccountId: bigint) {
const rows = await this.prisma.partnerAccount.findMany({
where: { parentAccountId },
orderBy: { createdAt: 'desc' },
});
return rows.map((row) => this.toStaffItem(row));
}
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
const parentAccountId = actor.actorId;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId },
});
if (parent.isPrimary !== 1) {
throw new BadRequestException('仅主账号可添加子账号');
}
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号码');
}
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (existing) throw new BadRequestException('该手机号已被使用');
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
const account = await this.prisma.partnerAccount.create({
data: {
phone,
name,
staffRole,
permissions: dto.permissions ?? undefined,
isPrimary: 0,
parentAccountId: parent.id,
status: 'DISABLED',
},
});
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
name,
phone: this.maskPhone(phone),
staffRole,
status: account.status,
});
return this.toStaffItem(account);
}
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) {
const parentAccountId = actor.actorId;
const staff = await this.assertStaffOwned(parentAccountId, staffId);
const before = {
name: staff.name,
staffRole: staff.staffRole,
status: staff.status,
};
const data: Record<string, unknown> = {};
if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
data.name = name;
}
if (dto.staffRole !== undefined) {
data.staffRole = dto.staffRole as PartnerStaffRole;
}
if (dto.permissions !== undefined) {
data.permissions = dto.permissions;
}
@@ -1,13 +1,18 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type { AdminCitiesQueryDto } from './dto/admin-query.dto';
import type { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
@Injectable()
export class AdminCitiesService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async list(query: AdminCitiesQueryDto) {
const page = query.page ?? 1;
@@ -16,7 +21,9 @@ export class AdminCitiesService {
if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code };
if (query.status) where.status = query.status as Prisma.EnumCityStatusFilter['equals'];
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.partnerId) {
where.partnerAccounts = { some: { id: BigInt(query.partnerId), isPrimary: 1 } };
}
const [items, total] = await Promise.all([
this.prisma.commonCity.findMany({
@@ -25,8 +32,12 @@ export class AdminCitiesService {
skip: (page - 1) * pageSize,
take: pageSize,
include: {
partner: { select: { id: true, companyName: true } },
_count: { select: { stores: true, orders: true } },
partnerAccounts: {
where: { isPrimary: 1 },
select: { id: true, companyName: true, scopeType: true, bindingStatus: true },
orderBy: { createdAt: 'asc' },
},
_count: { select: { stores: true, orders: true, partnerAccounts: true, warehouses: true } },
},
}),
this.prisma.commonCity.count({ where }),
@@ -34,8 +45,18 @@ export class AdminCitiesService {
return serializeBigInt({
items: items.map((c) => ({
...c,
partnerBindings: c.partnerAccounts.map((bp) => ({
id: bp.id.toString(),
partnerAccountId: bp.id.toString(),
partnerCompanyName: bp.companyName,
scopeType: bp.scopeType,
status: bp.bindingStatus,
})),
partnerAccounts: undefined,
storeCount: c._count.stores,
orderCount: c._count.orders,
partnerBindingCount: c.partnerAccounts.length,
warehouseCount: c._count.warehouses,
_count: undefined,
})),
total,
@@ -48,13 +69,22 @@ export class AdminCitiesService {
const city = await this.prisma.commonCity.findUnique({
where: { id },
include: {
partner: true,
commissionRule: true,
warehouses: {
include: { partnerAccount: { select: { id: true, companyName: true } } },
orderBy: { createdAt: 'desc' },
},
_count: { select: { stores: true, orders: true } },
},
});
if (!city) throw new NotFoundException('开城城市不存在');
return serializeBigInt(city);
const cityPartners = await this.partnerCityService.listByCity(id);
return serializeBigInt({
...city,
cityPartners,
storeCount: city._count.stores,
orderCount: city._count.orders,
_count: undefined,
});
}
async create(dto: CreateCityDto) {
@@ -65,31 +95,48 @@ export class AdminCitiesService {
code: dto.code,
name: dto.name,
province: dto.province,
partnerId: dto.partnerId ? BigInt(dto.partnerId) : null,
status: (dto.status ?? 'PENDING') as 'PENDING' | 'ACTIVE' | 'PAUSED',
commissionRule: {
create: {
orderCommissionRate: 0,
redeemCommissionRate: 0.03,
},
},
},
});
return serializeBigInt(city);
}
async update(id: bigint, dto: UpdateCityDto) {
if (dto.maxPartnerCommissionRate !== undefined) {
const maxRate = resolveMaxPartnerCommissionRate(dto.maxPartnerCommissionRate);
const partners = await this.prisma.partnerAccount.findMany({
where: { cityId: id, isPrimary: 1 },
select: {
companyName: true,
orderCommissionRate: true,
redeemCommissionRate: true,
},
});
for (const partner of partners) {
const check = validatePartnerCommissionRates(
Number(partner.orderCommissionRate ?? 0),
Number(partner.redeemCommissionRate ?? 0.03),
maxRate,
);
if (!check.ok) {
throw new BadRequestException(
`无法保存:合伙人「${partner.companyName ?? '—'}${check.message}`,
);
}
}
}
const city = await this.prisma.commonCity.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.province !== undefined ? { province: dto.province } : {}),
...(dto.partnerId !== undefined
? { partnerId: dto.partnerId ? BigInt(dto.partnerId) : null }
: {}),
...(dto.status !== undefined ? { status: dto.status as 'PENDING' | 'ACTIVE' | 'PAUSED' } : {}),
...(dto.localMinQty !== undefined ? { localMinQty: dto.localMinQty } : {}),
...(dto.crossMinQty !== undefined ? { crossMinQty: dto.crossMinQty } : {}),
...(dto.maxPartnerCommissionRate !== undefined
? { maxPartnerCommissionRate: dto.maxPartnerCommissionRate }
: {}),
},
});
return serializeBigInt(city);
@@ -0,0 +1,83 @@
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
import { CityWarehouseService } from '../city-scope/city-warehouse.service';
import { CreateCityWarehouseDto, UpdateCityWarehouseDto } from './dto/admin-mutate.dto';
import { AdminCityWarehousesQueryDto } from './dto/admin-query.dto';
import type { WarehouseManagerType, WarehouseStatus } from '@prisma/client';
@Controller('admin/cities/:cityId/warehouses')
@UseGuards(HqAuthGuard)
export class AdminCityWarehousesController {
constructor(private readonly service: CityWarehouseService) {}
@Get()
list(@Param('cityId') cityId: string) {
return this.service.listByCity(BigInt(cityId));
}
@Post()
@HqOperation({
action: HqOperationAction.WAREHOUSE_CREATE,
refType: 'WAREHOUSE',
refIdField: 'id',
includeBody: true,
})
create(@Param('cityId') cityId: string, @Body() dto: CreateCityWarehouseDto) {
return this.service.create(BigInt(cityId), {
name: dto.name,
address: dto.address,
contactName: dto.contactName,
contactPhone: dto.contactPhone,
managerType: dto.managerType as WarehouseManagerType,
partnerAccountId: dto.partnerAccountId ? BigInt(dto.partnerAccountId) : undefined,
status: dto.status as WarehouseStatus | undefined,
});
}
}
@Controller('admin/city-warehouses')
@UseGuards(HqAuthGuard)
export class AdminCityWarehouseMutationsController {
constructor(private readonly service: CityWarehouseService) {}
@Get()
listAll(@Query() query: AdminCityWarehousesQueryDto) {
return this.service.listAll(query);
}
@Put(':id')
@HqOperation({
action: HqOperationAction.WAREHOUSE_UPDATE,
refType: 'WAREHOUSE',
refIdParam: 'id',
includeBody: true,
})
update(@Param('id') id: string, @Body() dto: UpdateCityWarehouseDto) {
return this.service.update(BigInt(id), {
name: dto.name,
address: dto.address,
contactName: dto.contactName,
contactPhone: dto.contactPhone,
managerType: dto.managerType as WarehouseManagerType | undefined,
partnerAccountId:
dto.partnerAccountId === null
? undefined
: dto.partnerAccountId
? BigInt(dto.partnerAccountId)
: undefined,
status: dto.status as WarehouseStatus | undefined,
});
}
@Delete(':id')
@HqOperation({
action: HqOperationAction.WAREHOUSE_DELETE,
refType: 'WAREHOUSE',
refIdParam: 'id',
})
remove(@Param('id') id: string) {
return this.service.remove(BigInt(id));
}
}
@@ -38,7 +38,7 @@ export class AdminDashboardService {
_count: { status: true },
}),
this.prisma.store.count(),
this.prisma.partner.count(),
this.prisma.partnerAccount.count({ where: { isPrimary: 1 } }),
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
this.prisma.orderDelivery.count(),
this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
@@ -15,12 +15,12 @@ export class AdminPartnerLogsService {
async list(query: AdminPartnerLogsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const partnerIds = await this.resolvePartnerIds(query);
if (partnerIds && partnerIds.length === 0) {
const partnerAccountIds = await this.resolvePartnerAccountIds(query);
if (partnerAccountIds && partnerAccountIds.length === 0) {
return { items: [], total: 0, page, pageSize };
}
const where = this.buildWhere(query, partnerIds);
const where = this.buildWhere(query, partnerAccountIds);
const [rows, total] = await Promise.all([
this.prisma.logPartnerAnalytics.findMany({
where,
@@ -44,10 +44,10 @@ export class AdminPartnerLogsService {
private buildWhere(
query: AdminPartnerLogsQueryDto,
partnerIds?: bigint[],
partnerAccountIds?: bigint[],
): Prisma.LogPartnerAnalyticsWhereInput {
const where: Prisma.LogPartnerAnalyticsWhereInput = {};
if (partnerIds) where.partnerId = { in: partnerIds };
if (partnerAccountIds) where.partnerAccountId = { in: partnerAccountIds };
if (query.partnerAccountId) where.partnerAccountId = BigInt(query.partnerAccountId);
const categoryEvents = query.eventName
? [query.eventName]
@@ -64,40 +64,23 @@ export class AdminPartnerLogsService {
return where;
}
private async resolvePartnerIds(query: AdminPartnerLogsQueryDto): Promise<bigint[] | undefined> {
private async resolvePartnerAccountIds(
query: AdminPartnerLogsQueryDto,
): Promise<bigint[] | undefined> {
if (query.partnerAccountId) return [BigInt(query.partnerAccountId)];
if (query.partnerId) return [BigInt(query.partnerId)];
const partnerWhere: Prisma.PartnerWhereInput = {};
if (query.companyName) partnerWhere.companyName = { contains: query.companyName };
const accountWhere: Prisma.PartnerAccountWhereInput = { isPrimary: 1 };
if (query.companyName) accountWhere.companyName = { contains: query.companyName };
if (query.phone) accountWhere.phone = { contains: query.phone };
if (query.partnerAccountId || query.phone) {
const accountWhere: Prisma.PartnerAccountWhereInput = {};
if (query.partnerAccountId) accountWhere.id = BigInt(query.partnerAccountId);
if (query.phone) accountWhere.phone = { contains: query.phone };
if (query.companyName || query.phone) {
const accounts = await this.prisma.partnerAccount.findMany({
where: accountWhere,
select: { partnerId: true },
take: 100,
});
if (accounts.length === 0) return [];
const ids = [...new Set(accounts.map((a) => a.partnerId))];
if (query.companyName) {
const partners = await this.prisma.partner.findMany({
where: { id: { in: ids }, ...partnerWhere },
select: { id: true },
});
return partners.map((p) => p.id);
}
return ids;
}
if (query.companyName) {
const partners = await this.prisma.partner.findMany({
where: partnerWhere,
select: { id: true },
take: 100,
});
return partners.map((p) => p.id);
return accounts.map((a) => a.id);
}
return undefined;
@@ -106,8 +89,7 @@ export class AdminPartnerLogsService {
private async enrichRows(
rows: Array<{
id: bigint;
partnerAccountId: bigint | null;
partnerId: bigint;
partnerAccountId: bigint;
eventName: string;
clientApp: string | null;
refType: string | null;
@@ -116,37 +98,25 @@ export class AdminPartnerLogsService {
createdAt: Date;
}>,
) {
const partnerIds = [...new Set(rows.map((r) => r.partnerId))];
const accountIds = [...new Set(rows.map((r) => r.partnerAccountId).filter((id): id is bigint => id != null))];
const accountIds = [...new Set(rows.map((r) => r.partnerAccountId))];
const accounts = accountIds.length
? await this.prisma.partnerAccount.findMany({
where: { id: { in: accountIds } },
select: { id: true, name: true, phone: true, companyName: true },
})
: [];
const [partners, accounts] = await Promise.all([
partnerIds.length
? this.prisma.partner.findMany({
where: { id: { in: partnerIds } },
select: { id: true, companyName: true },
})
: Promise.resolve([]),
accountIds.length
? this.prisma.partnerAccount.findMany({
where: { id: { in: accountIds } },
select: { id: true, name: true, phone: true },
})
: Promise.resolve([]),
]);
const partnerMap = new Map(partners.map((p) => [p.id.toString(), p] as const));
const accountMap = new Map(accounts.map((a) => [a.id.toString(), a] as const));
return rows.map((row) => {
const partner = partnerMap.get(row.partnerId.toString());
const account = row.partnerAccountId ? accountMap.get(row.partnerAccountId.toString()) : undefined;
const account = accountMap.get(row.partnerAccountId.toString());
return {
id: row.id.toString(),
partnerId: row.partnerId.toString(),
partnerAccountId: row.partnerAccountId?.toString() ?? null,
partnerId: row.partnerAccountId.toString(),
partnerAccountId: row.partnerAccountId.toString(),
accountName: account?.name ?? null,
accountPhone: account?.phone ?? null,
companyName: partner?.companyName ?? null,
companyName: account?.companyName ?? null,
category: resolvePartnerLogCategory(row.eventName),
eventName: row.eventName,
clientApp: row.clientApp,
@@ -1,7 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto';
import type {
CreatePartnerAccountDto,
@@ -10,77 +12,36 @@ import type {
UpdatePartnerDto,
} from './dto/admin-mutate.dto';
const PRIMARY_WHERE = { isPrimary: 1 } as const;
function assertPartnerCommissionRates(
city: { maxPartnerCommissionRate: Prisma.Decimal | number | null },
orderCommissionRate: number,
redeemCommissionRate: number,
) {
const maxRate = resolveMaxPartnerCommissionRate(
city.maxPartnerCommissionRate != null ? Number(city.maxPartnerCommissionRate) : null,
);
const check = validatePartnerCommissionRates(orderCommissionRate, redeemCommissionRate, maxRate);
if (!check.ok) throw new BadRequestException(check.message);
}
@Injectable()
export class AdminPartnersService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async listPartners(query: AdminPartnersQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerWhereInput = {};
const where: Prisma.PartnerAccountWhereInput = { ...PRIMARY_WHERE };
if (query.companyName) where.companyName = { contains: query.companyName };
if (query.contactPhone) where.contactPhone = { contains: query.contactPhone };
const [items, total] = await Promise.all([
this.prisma.partner.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
_count: { select: { stores: true, accounts: true, cities: true } },
},
}),
this.prisma.partner.count({ where }),
]);
return serializeBigInt({
items: items.map((p) => ({
...p,
storeCount: p._count.stores,
accountCount: p._count.accounts,
cityCount: p._count.cities,
_count: undefined,
})),
total,
page,
pageSize,
});
}
async detailPartner(id: bigint) {
const partner = await this.prisma.partner.findUnique({
where: { id },
include: {
cities: { select: { id: true, code: true, name: true, status: true } },
accounts: { select: { id: true, phone: true, name: true, isPrimary: true, status: true } },
stores: { select: { id: true, name: true, status: true }, take: 10, orderBy: { createdAt: 'desc' } },
_count: { select: { stores: true, accounts: true } },
},
});
if (!partner) throw new NotFoundException('开城合伙人不存在');
return serializeBigInt(partner);
}
async createPartner(dto: CreatePartnerDto) {
const partner = await this.prisma.partner.create({ data: dto });
return serializeBigInt(partner);
}
async updatePartner(id: bigint, dto: UpdatePartnerDto) {
const partner = await this.prisma.partner.update({ where: { id }, data: dto });
return serializeBigInt(partner);
}
async listPartnerAccounts(query: AdminPartnerAccountsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerAccountWhereInput = {};
if (query.phone) where.phone = { contains: query.phone };
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
if (query.isPrimary === '0' || query.isPrimary === '1') {
where.isPrimary = Number(query.isPrimary);
}
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.id = BigInt(query.partnerId);
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
@@ -89,8 +50,257 @@ export class AdminPartnersService {
skip: (page - 1) * pageSize,
take: pageSize,
include: {
partner: { select: { id: true, companyName: true } },
parent: { select: { id: true, name: true, phone: true } },
city: { select: { id: true, code: true, name: true, status: true, maxPartnerCommissionRate: true } },
managedWarehouse: { select: { id: true, name: true } },
children: {
select: {
id: true,
phone: true,
name: true,
staffRole: true,
permissions: true,
status: true,
},
orderBy: { createdAt: 'asc' },
},
_count: { select: { stores: true, children: true } },
},
}),
this.prisma.partnerAccount.count({ where }),
]);
return serializeBigInt({
items: items.map((p) => ({
id: p.id.toString(),
companyName: p.companyName,
contactPhone: p.contactPhone,
phone: p.phone,
name: p.name,
cityId: p.cityId?.toString() ?? null,
cityName: p.city?.name ?? null,
maxPartnerCommissionRate:
p.city?.maxPartnerCommissionRate != null ? Number(p.city.maxPartnerCommissionRate) : null,
scopeType: p.scopeType,
orderCommissionRate: Number(p.orderCommissionRate ?? 0),
redeemCommissionRate: Number(p.redeemCommissionRate ?? 0.03),
bindingStatus: p.bindingStatus,
managedWarehouseId: p.managedWarehouseId?.toString() ?? null,
managedWarehouseName: p.managedWarehouse?.name ?? null,
storeCount: p._count.stores,
accountCount: p._count.children + 1,
children: p.children.map((c) => ({
id: c.id.toString(),
phone: c.phone,
name: c.name,
staffRole: c.staffRole,
permissions: c.permissions,
status: c.status,
})),
createdAt: p.createdAt,
})),
total,
page,
pageSize,
});
}
async detailPartner(id: bigint) {
const account = await this.prisma.partnerAccount.findFirst({
where: { id, ...PRIMARY_WHERE },
include: {
city: { select: { id: true, code: true, name: true, status: true, maxPartnerCommissionRate: true } },
managedWarehouse: { select: { id: true, name: true } },
children: {
where: { isPrimary: 0 },
select: {
id: true,
phone: true,
name: true,
staffRole: true,
permissions: true,
status: true,
createdAt: true,
},
orderBy: { createdAt: 'asc' },
},
stores: { select: { id: true, name: true, status: true }, take: 10, orderBy: { createdAt: 'desc' } },
_count: { select: { stores: true, children: true } },
},
});
if (!account) throw new NotFoundException('开城合伙人不存在');
return serializeBigInt({
...this.partnerCityService.toDto({
...account,
city: account.city,
}),
contactPhone: account.contactPhone,
address: account.address,
bankAccountName: account.bankAccountName,
bankAccountNo: account.bankAccountNo,
bankBranch: account.bankBranch,
managedWarehouseName: account.managedWarehouse?.name ?? null,
accountCount: account._count.children + 1,
maxPartnerCommissionRate:
account.city?.maxPartnerCommissionRate != null
? Number(account.city.maxPartnerCommissionRate)
: null,
children: account.children.map((c) => ({
id: c.id.toString(),
phone: c.phone,
name: c.name,
staffRole: c.staffRole,
permissions: c.permissions,
status: c.status,
createdAt: c.createdAt,
})),
});
}
async createPartner(dto: CreatePartnerDto) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的登录手机号');
}
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (phoneTaken) throw new BadRequestException('该手机号已被使用');
const cityId = BigInt(dto.cityId);
const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } });
if (!city) throw new NotFoundException('开城城市不存在');
await this.partnerCityService.validatePrimaryBinding(cityId, {
scopeType: dto.scopeType as CityPartnerScopeType,
districtCodes: dto.districtCodes,
});
const orderCommissionRate = dto.orderCommissionRate ?? 0;
const redeemCommissionRate = dto.redeemCommissionRate ?? 0.03;
assertPartnerCommissionRates(city, orderCommissionRate, redeemCommissionRate);
const account = await this.prisma.partnerAccount.create({
data: {
phone,
name: dto.name.trim(),
isPrimary: 1,
staffRole: 'PARTNER',
status: 'ACTIVE',
cityId,
scopeType: dto.scopeType as CityPartnerScopeType,
districtCodes:
dto.scopeType === 'DISTRICT' ? (dto.districtCodes ?? []) : Prisma.JsonNull,
orderCommissionRate: orderCommissionRate,
redeemCommissionRate: redeemCommissionRate,
bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus,
companyName: dto.companyName.trim(),
address: dto.address.trim(),
contactPhone: dto.contactPhone?.trim() ?? phone,
contractNo: dto.contractNo,
bankAccountName: dto.bankAccountName,
bankAccountNo: dto.bankAccountNo,
bankBranch: dto.bankBranch,
weeklyStoreTarget: dto.weeklyStoreTarget ?? 20,
},
include: { city: { select: { id: true, code: true, name: true } } },
});
return serializeBigInt(this.partnerCityService.toDto(account));
}
async updatePartner(id: bigint, dto: UpdatePartnerDto) {
const existing = await this.prisma.partnerAccount.findFirst({
where: { id, ...PRIMARY_WHERE },
});
if (!existing) throw new NotFoundException('开城合伙人不存在');
const city = await this.prisma.commonCity.findUniqueOrThrow({ where: { id: existing.cityId! } });
const cityId = existing.cityId!;
const scopeType = (dto.scopeType ?? existing.scopeType) as CityPartnerScopeType;
const districtCodes =
scopeType === 'DISTRICT'
? dto.districtCodes ?? this.partnerCityService.parseDistrictCodes(existing.districtCodes)
: null;
await this.partnerCityService.validatePrimaryBinding(
cityId,
{
partnerAccountId: id.toString(),
scopeType,
districtCodes: districtCodes ?? undefined,
},
id,
);
if (dto.phone !== undefined) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的登录手机号');
}
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (phoneTaken && phoneTaken.id !== id) {
throw new BadRequestException('该手机号已被使用');
}
}
const orderCommissionRate =
dto.orderCommissionRate !== undefined
? dto.orderCommissionRate
: Number(existing.orderCommissionRate ?? 0);
const redeemCommissionRate =
dto.redeemCommissionRate !== undefined
? dto.redeemCommissionRate
: Number(existing.redeemCommissionRate ?? 0.03);
if (dto.orderCommissionRate !== undefined || dto.redeemCommissionRate !== undefined) {
assertPartnerCommissionRates(city, orderCommissionRate, redeemCommissionRate);
}
const account = await this.prisma.partnerAccount.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}),
...(dto.companyName !== undefined ? { companyName: dto.companyName.trim() } : {}),
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
...(dto.contactPhone !== undefined ? { contactPhone: dto.contactPhone.trim() } : {}),
...(dto.scopeType !== undefined ? { scopeType: dto.scopeType as CityPartnerScopeType } : {}),
...(dto.scopeType !== undefined || dto.districtCodes !== undefined
? {
districtCodes:
scopeType === 'DISTRICT'
? ((districtCodes ?? []) as Prisma.InputJsonValue)
: Prisma.JsonNull,
}
: {}),
...(dto.orderCommissionRate !== undefined ? { orderCommissionRate: dto.orderCommissionRate } : {}),
...(dto.redeemCommissionRate !== undefined ? { redeemCommissionRate: dto.redeemCommissionRate } : {}),
...(dto.bindingStatus !== undefined ? { bindingStatus: dto.bindingStatus as CityPartnerStatus } : {}),
...(dto.contractNo !== undefined ? { contractNo: dto.contractNo } : {}),
...(dto.bankAccountName !== undefined ? { bankAccountName: dto.bankAccountName } : {}),
...(dto.bankAccountNo !== undefined ? { bankAccountNo: dto.bankAccountNo } : {}),
...(dto.bankBranch !== undefined ? { bankBranch: dto.bankBranch } : {}),
...(dto.weeklyStoreTarget !== undefined ? { weeklyStoreTarget: dto.weeklyStoreTarget } : {}),
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
},
include: { city: { select: { id: true, code: true, name: true } } },
});
return serializeBigInt(this.partnerCityService.toDto(account));
}
async listPartnerAccounts(query: AdminPartnerAccountsQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerAccountWhereInput = { isPrimary: 0 };
if (query.phone) where.phone = { contains: query.phone };
if (query.partnerId) where.parentAccountId = BigInt(query.partnerId);
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: {
parent: { select: { id: true, name: true, phone: true, companyName: true } },
},
}),
this.prisma.partnerAccount.count({ where }),
@@ -98,14 +308,14 @@ export class AdminPartnersService {
return serializeBigInt({ items, total, page, pageSize });
}
async listPartnerAccountTree(partnerId?: bigint) {
const where: Prisma.PartnerAccountWhereInput = {};
if (partnerId) where.partnerId = partnerId;
async listPartnerAccountTree(primaryAccountId?: bigint) {
const where: Prisma.PartnerAccountWhereInput = primaryAccountId
? { OR: [{ id: primaryAccountId }, { parentAccountId: primaryAccountId }] }
: { isPrimary: 1 };
const accounts = await this.prisma.partnerAccount.findMany({
where,
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
include: { partner: { select: { id: true, companyName: true } } },
});
type TreeNode = (typeof accounts)[number] & { children: TreeNode[] };
@@ -122,7 +332,7 @@ export class AdminPartnersService {
const parent = nodeMap.get(account.parentAccountId.toString());
if (parent) parent.children.push(node);
else roots.push(node);
} else {
} else if (account.isPrimary === 1) {
roots.push(node);
}
}
@@ -134,8 +344,9 @@ export class AdminPartnersService {
status: node.status,
isPrimary: node.isPrimary,
staffRole: node.staffRole,
permissions: node.permissions,
parentAccountId: node.parentAccountId,
partner: node.partner,
companyName: node.companyName,
createdAt: node.createdAt,
lastLoginAt: node.lastLoginAt,
children: node.children.length ? node.children.map(mapNode) : undefined,
@@ -148,27 +359,21 @@ export class AdminPartnersService {
const account = await this.prisma.partnerAccount.findUnique({
where: { id },
include: {
partner: {
select: {
id: true,
companyName: true,
contactPhone: true,
address: true,
},
},
parent: { select: { id: true, name: true, phone: true } },
parent: { select: { id: true, name: true, phone: true, companyName: true } },
},
});
if (!account) throw new NotFoundException('开城合伙人账号不存在');
if (!account) throw new NotFoundException('合伙人账号不存在');
const primary = await this.partnerCityService.resolvePrimaryAccount(id);
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
const [bills, orders] = await Promise.all([
this.prisma.partnerBill.findMany({
where: { partnerId: account.partnerId },
where: { partnerAccountId: primary.id },
orderBy: { createdAt: 'desc' },
take: 50,
}),
this.prisma.order.findMany({
where: { city: { partnerId: account.partnerId } },
where: orderWhere,
orderBy: { createdAt: 'desc' },
take: 50,
select: {
@@ -182,10 +387,14 @@ export class AdminPartnersService {
}),
]);
return serializeBigInt({ ...account, bills, orders });
return serializeBigInt({ ...account, primaryAccountId: primary.id, bills, orders });
}
async createPartnerAccount(dto: CreatePartnerAccountDto) {
if (!dto.parentAccountId) {
throw new BadRequestException('请指定主账号 parentAccountId 创建子账号');
}
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号码');
@@ -193,53 +402,40 @@ export class AdminPartnersService {
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (phoneTaken) throw new BadRequestException('该手机号已被使用');
if (dto.parentAccountId) {
const parent = await this.prisma.partnerAccount.findUnique({
where: { id: BigInt(dto.parentAccountId) },
});
if (!parent) throw new BadRequestException('主账号不存在');
if (parent.isPrimary !== 1) throw new BadRequestException('仅可向主账号添加子账号');
if (dto.partnerId && dto.partnerId !== parent.partnerId.toString()) {
throw new BadRequestException('开城合伙人与主账号不匹配');
}
const account = await this.prisma.partnerAccount.create({
data: {
partnerId: parent.partnerId,
phone,
name: dto.name.trim(),
staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER',
isPrimary: 0,
parentAccountId: parent.id,
status: 'ACTIVE',
},
include: { partner: { select: { id: true, companyName: true } } },
});
return serializeBigInt(account);
const parent = await this.prisma.partnerAccount.findUnique({
where: { id: BigInt(dto.parentAccountId) },
});
if (!parent) throw new BadRequestException('主账号不存在');
if (parent.isPrimary !== 1) {
throw new BadRequestException('仅可向主账号添加子账号,不支持多级子账号');
}
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId!) } });
if (!partner) throw new BadRequestException('开城合伙人不存在');
const account = await this.prisma.partnerAccount.create({
data: {
partnerId: partner.id,
phone,
name: dto.name.trim(),
staffRole: dto.staffRole ? (dto.staffRole as 'PARTNER' | 'INTERNAL' | 'PROMOTER') : undefined,
staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER',
permissions: dto.permissions ?? undefined,
isPrimary: 0,
parentAccountId: parent.id,
status: 'ACTIVE',
},
include: { partner: { select: { id: true, companyName: true } } },
});
return serializeBigInt(account);
}
async updatePartnerAccount(id: bigint, dto: UpdatePartnerAccountDto) {
const existing = await this.prisma.partnerAccount.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('开城合伙人账号不存在');
if (!existing) throw new NotFoundException('合伙人账号不存在');
if (existing.isPrimary === 1) {
throw new BadRequestException('请通过开城合伙人接口编辑主账号');
}
const data: Prisma.PartnerAccountUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name;
if (dto.status !== undefined) data.status = dto.status as 'ACTIVE' | 'DISABLED';
if (dto.staffRole !== undefined) data.staffRole = dto.staffRole as 'PARTNER' | 'INTERNAL' | 'PROMOTER';
if (dto.permissions !== undefined) data.permissions = dto.permissions;
if (dto.phone !== undefined) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
@@ -258,7 +454,7 @@ export class AdminPartnersService {
async deletePartnerSubAccount(id: bigint) {
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
if (!account) throw new NotFoundException('开城合伙人账号不存在');
if (!account) throw new NotFoundException('合伙人账号不存在');
if (!account.parentAccountId) {
throw new BadRequestException('仅可删除子账号');
}
@@ -40,7 +40,7 @@ export class AdminRedeemService {
where: { id },
include: {
user: true,
store: { include: { partner: { select: { id: true, companyName: true } } } },
store: { include: { partnerAccount: { select: { id: true, companyName: true } } } },
coupon: true,
payout: true,
},
@@ -4,6 +4,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat';
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type {
CreateStoreAccountDto,
CreateStoreDto,
@@ -16,7 +17,10 @@ import type {
@Injectable()
export class AdminStoresService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async listStores(query: AdminStoresQueryDto) {
const page = query.page ?? 1;
@@ -25,7 +29,7 @@ export class AdminStoresService {
if (query.name) where.name = { contains: query.name };
if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals'];
if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
if (query.phone) where.phone = { contains: query.phone };
const [items, total] = await Promise.all([
@@ -36,7 +40,7 @@ export class AdminStoresService {
take: pageSize,
include: {
cityRef: { select: { id: true, name: true, code: true } },
partner: { select: { id: true, companyName: true } },
partnerAccount: { select: { id: true, companyName: true } },
account: { select: { id: true, phone: true, name: true, status: true } },
coverResource: { select: { id: true, url: true } },
},
@@ -56,7 +60,7 @@ export class AdminStoresService {
where: { id },
include: {
cityRef: true,
partner: true,
partnerAccount: true,
category: true,
account: true,
coverResource: true,
@@ -123,6 +127,7 @@ export class AdminStoresService {
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.address !== undefined ? { address: dto.address } : {}),
...(dto.district !== undefined ? { district: dto.district } : {}),
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
},
});
@@ -162,18 +167,22 @@ export class AdminStoresService {
});
if (existingAccount) throw new BadRequestException('该手机号已绑定门店');
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } });
if (!partner) throw new BadRequestException('开城合伙人不存在');
const partnerAccountId = BigInt(dto.partnerAccountId);
const partnerAccount = await this.prisma.partnerAccount.findUnique({
where: { id: partnerAccountId },
});
if (!partnerAccount || partnerAccount.isPrimary !== 1) {
throw new BadRequestException('开城合伙人不存在');
}
const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } });
if (!city) throw new BadRequestException('开城城市不存在');
if (city.partnerId && city.partnerId !== partner.id) {
throw new BadRequestException('开城城市与合伙人不匹配');
}
await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
const store = await this.prisma.store.create({
data: {
cityId: city.id,
partnerId: partner.id,
partnerAccountId,
settlementRate: dto.settlementRate ?? 0.6,
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
name: dto.name,
phone: normalizedPhone,
@@ -359,7 +368,7 @@ export class AdminStoresService {
async detailStoreAccount(id: bigint) {
const account = await this.prisma.storeAccount.findUnique({
where: { id },
include: { store: { include: { cityRef: true, partner: true } } },
include: { store: { include: { cityRef: true, partnerAccount: true } } },
});
if (!account) throw new NotFoundException('门店账号不存在');
return serializeBigInt(account);
@@ -4,6 +4,7 @@ import { BenefitService } from '../benefit/benefit.service';
import { TradeService } from '../trade/trade.service';
import { TicketService } from '../common/ticket.service';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type { TicketListQueryDto } from '../common/dto/common-query.dto';
@Injectable()
@@ -13,6 +14,7 @@ export class AdminTicketsService {
private readonly ticketService: TicketService,
private readonly tradeService: TradeService,
private readonly benefitService: BenefitService,
private readonly partnerCityService: PartnerCityService,
) {}
list(query: TicketListQueryDto) {
@@ -82,15 +84,9 @@ export class AdminTicketsService {
}
async listPartnerReshipments(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const cities = await this.prisma.commonCity.findMany({
where: { partnerId: account.partnerId },
select: { id: true },
});
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(partnerAccountId);
const orders = await this.prisma.order.findMany({
where: { cityId: { in: cities.map((c) => c.id) } },
where: orderWhere,
select: { id: true },
});
const orderIds = orders.map((o) => o.id);
@@ -213,11 +213,23 @@ export class AdminWechatBindingsService {
const accounts = await this.prisma.partnerAccount.findMany({
where,
include: { partner: { select: { id: true, companyName: true } } },
select: {
id: true,
phone: true,
name: true,
wxOpenId: true,
wxUnionId: true,
lastLoginAt: true,
status: true,
isPrimary: true,
parentAccountId: true,
companyName: true,
},
});
for (const a of accounts) {
if (!a.wxOpenId) continue;
const refId = a.isPrimary === 1 ? a.id : (a.parentAccountId ?? a.id);
rows.push({
actorType: 'PARTNER',
actorId: a.id,
@@ -225,8 +237,8 @@ export class AdminWechatBindingsService {
name: a.name,
wxOpenId: a.wxOpenId,
wxUnionId: a.wxUnionId,
refId: a.partnerId,
refLabel: a.partner.companyName,
refId,
refLabel: a.companyName,
lastLoginAt: a.lastLoginAt,
status: a.status,
});
@@ -9,6 +9,7 @@ import {
IsOptional,
IsString,
Min,
Max,
MinLength,
ValidateIf,
} from 'class-validator';
@@ -22,7 +23,7 @@ export class UpdateStoreStatusDto {
export class CreateStoreDto {
@IsString()
@IsNotEmpty()
partnerId: string;
partnerAccountId: string;
@IsString()
@IsNotEmpty()
@@ -88,6 +89,11 @@ export class CreateStoreDto {
@IsOptional()
@IsString()
contractUrl?: string;
@IsOptional()
@IsNumber()
@Min(0)
settlementRate?: number;
}
export class UpdateStoreDto {
@@ -114,6 +120,11 @@ export class UpdateStoreDto {
@IsOptional()
@IsString()
district?: string;
@IsOptional()
@IsNumber()
@Min(0)
settlementRate?: number;
}
export class CreateStoreAccountDto {
@@ -145,6 +156,18 @@ export class UpdateStoreAccountDto {
}
export class CreatePartnerDto {
@IsString()
@IsNotEmpty()
cityId: string;
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
companyName: string;
@@ -153,9 +176,40 @@ export class CreatePartnerDto {
@IsNotEmpty()
address: string;
@IsOptional()
@IsString()
@IsNotEmpty()
contactPhone: string;
contactPhone?: string;
@IsString()
@IsIn(['CITY_WIDE', 'DISTRICT'])
scopeType: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
districtCodes?: string[];
@IsOptional()
@IsNumber()
@Min(0)
orderCommissionRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
redeemCommissionRate?: number;
@IsOptional()
@IsIn(['ACTIVE', 'PAUSED'])
bindingStatus?: string;
@IsOptional()
@IsString()
managedWarehouseId?: string;
@IsOptional()
@IsString()
contractNo?: string;
@IsOptional()
@IsString()
@@ -168,9 +222,21 @@ export class CreatePartnerDto {
@IsOptional()
@IsString()
bankBranch?: string;
@IsOptional()
@IsNumber()
weeklyStoreTarget?: number;
}
export class UpdatePartnerDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
companyName?: string;
@@ -183,6 +249,38 @@ export class UpdatePartnerDto {
@IsString()
contactPhone?: string;
@IsOptional()
@IsIn(['CITY_WIDE', 'DISTRICT'])
scopeType?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
districtCodes?: string[];
@IsOptional()
@IsNumber()
@Min(0)
orderCommissionRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
redeemCommissionRate?: number;
@IsOptional()
@IsIn(['ACTIVE', 'PAUSED'])
bindingStatus?: string;
@IsOptional()
@ValidateIf((_, v) => v !== null)
@IsString()
managedWarehouseId?: string | null;
@IsOptional()
@IsString()
contractNo?: string;
@IsOptional()
@IsString()
bankAccountName?: string;
@@ -194,6 +292,14 @@ export class UpdatePartnerDto {
@IsOptional()
@IsString()
bankBranch?: string;
@IsOptional()
@IsNumber()
weeklyStoreTarget?: number;
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
}
export class UpdatePartnerAccountDto {
@@ -208,13 +314,21 @@ export class UpdatePartnerAccountDto {
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
@IsOptional()
@IsIn(['PARTNER', 'INTERNAL', 'PROMOTER'])
staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
}
export class CreatePartnerAccountDto {
@ValidateIf((o: CreatePartnerAccountDto) => !o.parentAccountId)
@IsString()
@IsNotEmpty()
partnerId?: string;
parentAccountId: string;
@IsString()
@IsNotEmpty()
@@ -228,10 +342,10 @@ export class CreatePartnerAccountDto {
@IsIn(['PARTNER', 'INTERNAL', 'PROMOTER'])
staffRole?: string;
/** 主账号 ID;传入则创建子账号 */
@IsOptional()
@IsString()
parentAccountId?: string;
@IsArray()
@IsString({ each: true })
permissions?: string[];
}
export class CreateCityDto {
@@ -247,10 +361,6 @@ export class CreateCityDto {
@IsNotEmpty()
province: string;
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional()
@IsIn(['PENDING', 'ACTIVE', 'PAUSED'])
status?: string;
@@ -265,10 +375,6 @@ export class UpdateCityDto {
@IsString()
province?: string;
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional()
@IsIn(['PENDING', 'ACTIVE', 'PAUSED'])
status?: string;
@@ -278,6 +384,127 @@ export class UpdateCityDto {
@IsOptional()
crossMinQty?: number;
@IsOptional()
@IsNumber()
@Min(0)
@Max(1)
maxPartnerCommissionRate?: number;
}
export class BindCityPartnerDto {
@IsString()
@IsNotEmpty()
partnerId: string;
@IsString()
@IsIn(['CITY_WIDE', 'DISTRICT'])
scopeType: 'CITY_WIDE' | 'DISTRICT';
@IsOptional()
@IsArray()
@IsString({ each: true })
districtCodes?: string[];
@IsOptional()
@IsNumber()
@Min(0)
orderCommissionRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
redeemCommissionRate?: number;
@IsOptional()
@IsIn(['ACTIVE', 'PAUSED'])
status?: 'ACTIVE' | 'PAUSED';
}
export class UpdateCityPartnerDto {
@IsOptional()
@IsIn(['CITY_WIDE', 'DISTRICT'])
scopeType?: 'CITY_WIDE' | 'DISTRICT';
@IsOptional()
@IsArray()
@IsString({ each: true })
districtCodes?: string[];
@IsOptional()
@IsNumber()
@Min(0)
orderCommissionRate?: number;
@IsOptional()
@IsNumber()
@Min(0)
redeemCommissionRate?: number;
@IsOptional()
@IsIn(['ACTIVE', 'PAUSED'])
status?: 'ACTIVE' | 'PAUSED';
}
export class CreateCityWarehouseDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
address: string;
@IsString()
@IsNotEmpty()
contactName: string;
@IsString()
@IsNotEmpty()
contactPhone: string;
@IsString()
@IsIn(['HQ', 'PARTNER'])
managerType: 'HQ' | 'PARTNER';
@IsOptional()
@IsString()
partnerAccountId?: string;
@IsOptional()
@IsIn(['ACTIVE', 'PAUSED'])
status?: 'ACTIVE' | 'PAUSED';
}
export class UpdateCityWarehouseDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
address?: string;
@IsOptional()
@IsString()
contactName?: string;
@IsOptional()
@IsString()
contactPhone?: string;
@IsOptional()
@IsIn(['HQ', 'PARTNER'])
managerType?: 'HQ' | 'PARTNER';
@IsOptional()
@ValidateIf((_, v) => v !== null)
@IsString()
partnerAccountId?: string | null;
@IsOptional()
@IsIn(['ACTIVE', 'PAUSED'])
status?: 'ACTIVE' | 'PAUSED';
}
export class CreateStoreMediaDto {
@@ -109,6 +109,36 @@ export class AdminPartnersQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
contactPhone?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional()
@IsString()
cityId?: string;
@IsOptional()
@IsString()
partnerId?: string;
}
export class AdminCityWarehousesQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
cityId?: string;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
managerType?: string;
@IsOptional()
@IsString()
status?: string;
}
export class AdminPartnerAccountsQueryDto extends PaginationQueryDto {
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { IamModule } from '../iam/iam.module';
import { TradeModule } from '../trade/trade.module';
import { AdminDashboardController } from './admin-dashboard.controller';
@@ -12,6 +13,7 @@ import { AdminStoresService } from './admin-stores.service';
import { AdminPartnersController, AdminPartnerAccountsController } from './admin-partners.controller';
import { AdminPartnersService } from './admin-partners.service';
import { AdminCitiesController } from './admin-cities.controller';
import { AdminCityWarehousesController, AdminCityWarehouseMutationsController } from './admin-city-warehouses.controller';
import { AdminCitiesService } from './admin-cities.service';
import { AdminBenefitCouponsController, AdminBenefitLedgersController } from './admin-benefit.controller';
import { AdminBenefitService } from './admin-benefit.service';
@@ -50,7 +52,7 @@ import { AdminHqPermissionsController } from './admin-hq-permissions.controller'
import { AdminHqPermissionsService } from './admin-hq-permissions.service';
@Module({
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
imports: [CityScopeModule, IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
controllers: [
AdminDashboardController,
AdminUsersController,
@@ -61,6 +63,8 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
AdminPartnersController,
AdminPartnerAccountsController,
AdminCitiesController,
AdminCityWarehousesController,
AdminCityWarehouseMutationsController,
AdminBenefitCouponsController,
AdminBenefitLedgersController,
AdminRedeemRecordsController,
@@ -104,5 +108,6 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
AdminHqPermissionsService,
SuperAdminGuard,
],
exports: [CityScopeModule],
})
export class OpsModule {}
@@ -236,10 +236,7 @@ export class RedeemService {
}
const amount = tokenAmount;
const cityRule = await this.prisma.commonCityCommissionRule.findUnique({
where: { cityId: account.store.cityId },
});
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
const settlementRate = Number(account.store.settlementRate);
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
let record;
@@ -152,15 +152,22 @@ export class PartnerMeController {
async me(@CurrentUser() user: AuthUser) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: user.actorId },
include: { partner: true },
});
let primary = account;
if (account.isPrimary !== 1 && account.parentAccountId) {
primary = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: account.parentAccountId },
});
}
return {
id: account.id.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
staffRole: account.staffRole ?? undefined,
companyName: account.partner.companyName,
permissions: Array.isArray(account.permissions) ? account.permissions : undefined,
primaryAccountId: primary.id.toString(),
companyName: primary.companyName ?? undefined,
hasWechat: !!account.wxOpenId,
};
}
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { AnalyticsModule } from '../analytics/analytics.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { SettlementService } from './settlement.service';
import {
AdminPartnerBillController,
@@ -11,7 +12,7 @@ import {
} from './settlement.controller';
@Module({
imports: [IamModule, AnalyticsModule],
imports: [IamModule, AnalyticsModule, CityScopeModule],
controllers: [
SettlementController,
PartnerMeController,
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
function generateBillNo() {
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
@@ -13,6 +14,7 @@ export class SettlementService {
constructor(
private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
) {}
async createStorePayout(
@@ -152,16 +154,13 @@ export class SettlementService {
return results;
}
async listPartnerBills(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
async listPartnerBills(partnerAccountId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const bills = await this.prisma.partnerBill.findMany({
where: { partnerId: account.partnerId },
where: { partnerAccountId: primary.id },
orderBy: { createdAt: 'desc' },
});
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
partnerAccountId: primary.id,
eventName: 'partner_bill_view',
extraJson: { count: bills.length },
});
@@ -178,7 +177,7 @@ export class SettlementService {
const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerBillWhereInput = {};
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
const [items, total] = await Promise.all([
this.prisma.partnerBill.findMany({
@@ -186,7 +185,7 @@ export class SettlementService {
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
include: { partner: { select: { companyName: true } } },
include: { partnerAccount: { select: { companyName: true } } },
}),
this.prisma.partnerBill.count({ where }),
]);
@@ -196,20 +195,21 @@ export class SettlementService {
async getAdminPartnerBill(id: bigint) {
const bill = await this.prisma.partnerBill.findUnique({
where: { id },
include: { partner: true },
include: { partnerAccount: true },
});
if (!bill) throw new NotFoundException('账单不存在');
return serializeBigInt(bill);
}
async generatePartnerBill(body: { partnerId: string; year: number; month: number }) {
const partnerId = BigInt(body.partnerId);
const partnerAccountId = BigInt(body.partnerId);
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const periodStart = new Date(body.year, body.month - 1, 1);
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
const existing = await this.prisma.partnerBill.findFirst({
where: {
partnerId,
partnerAccountId: primary.id,
periodStart,
status: { not: 'DRAFT' },
},
@@ -218,31 +218,33 @@ export class SettlementService {
throw new BadRequestException('该月账单已确认,不可重复生成');
}
const cities = await this.prisma.commonCity.findMany({
where: { partnerId },
include: { commissionRule: true },
});
const cityIds = cities.map((c) => c.id);
const defaultOrderRate = cities[0]?.commissionRule?.orderCommissionRate
? Number(cities[0].commissionRule.orderCommissionRate)
: 0;
const defaultRedeemRate = cities[0]?.commissionRule?.redeemCommissionRate
? Number(cities[0].commissionRule.redeemCommissionRate)
: 0.03;
if (!primary.cityId) {
throw new BadRequestException('合伙人未绑定开城城市');
}
const orderCommissionRate = Number(primary.orderCommissionRate ?? 0);
const redeemCommissionRate = Number(primary.redeemCommissionRate ?? 0.03);
const orders = await this.prisma.order.findMany({
where: {
cityId: { in: cityIds },
cityId: primary.cityId,
payStatus: 'PAID',
paidAt: { gte: periodStart, lte: periodEnd },
},
});
const orderCommission = orders.reduce(
(sum, o) => sum + Number(o.payAmount) * defaultOrderRate,
0,
);
const orderCommission = orders.reduce((sum, o) => {
if (o.partnerAccountIdAtPay) {
if (o.partnerAccountIdAtPay !== primary.id) return sum;
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
return sum + Number(o.payAmount) * rate;
}
return sum + Number(o.payAmount) * orderCommissionRate;
}, 0);
const stores = await this.prisma.store.findMany({ where: { partnerId }, select: { id: true } });
const stores = await this.prisma.store.findMany({
where: { partnerAccountId: primary.id },
select: { id: true },
});
const storeIds = stores.map((s) => s.id);
const redeems = await this.prisma.redeemRecord.findMany({
where: {
@@ -251,14 +253,14 @@ export class SettlementService {
},
});
const redeemCommission = redeems.reduce(
(sum, r) => sum + Number(r.amount) * defaultRedeemRate,
(sum, r) => sum + Number(r.amount) * redeemCommissionRate,
0,
);
const totalAmount = Math.round((orderCommission + redeemCommission) * 100) / 100;
const draft = await this.prisma.partnerBill.findFirst({
where: { partnerId, periodStart, status: 'DRAFT' },
where: { partnerAccountId: primary.id, periodStart, status: 'DRAFT' },
});
const bill = draft
@@ -269,7 +271,7 @@ export class SettlementService {
: await this.prisma.partnerBill.create({
data: {
billNo: generateBillNo(),
partnerId,
partnerAccountId: primary.id,
periodStart,
periodEnd,
orderCommission,
@@ -310,12 +312,12 @@ export class SettlementService {
async exportPartnerBills(query: { partnerId?: string; status?: string }) {
const where: Prisma.PartnerBillWhereInput = {};
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
const bills = await this.prisma.partnerBill.findMany({
where,
include: { partner: { select: { companyName: true } } },
include: { partnerAccount: { select: { companyName: true } } },
orderBy: { createdAt: 'desc' },
});
@@ -323,7 +325,7 @@ export class SettlementService {
const rows = bills.map((b) =>
[
b.billNo,
b.partner.companyName,
b.partnerAccount.companyName,
b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10),
Number(b.orderCommission),
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module';
import { RedeemModule } from '../redeem/redeem.module';
import { AnalyticsModule } from '../analytics/analytics.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { StoreService } from './store.service';
import {
PartnerDashboardController,
@@ -13,7 +14,7 @@ import {
} from './store.controller';
@Module({
imports: [IamModule, AnalyticsModule, forwardRef(() => RedeemModule)],
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
controllers: [
PublicStoreController,
PartnerStoreController,
@@ -11,6 +11,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { mapStoreCompat } from '../../common/compat/v31-compat';
import { parseBigIntParam } from '../../common/parse-bigint';
import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
@Injectable()
export class StoreService {
@@ -19,6 +20,7 @@ export class StoreService {
constructor(
private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
) {}
async listOpenStores(cityCode?: string) {
@@ -48,9 +50,15 @@ export class StoreService {
return serializeBigInt(mapStoreCompat({ ...store, media }));
}
private async resolvePartnerScope(actorAccountId: bigint) {
const account = await this.getPartnerAccount(actorAccountId);
const primaryId = await this.getPartnerPrimaryId(actorAccountId);
return { account, primaryId };
}
async partnerListStores(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
const where: { partnerId: bigint; id?: { in: bigint[] } } = { partnerId: account.partnerId };
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
const where: { partnerAccountId: bigint; id?: { in: bigint[] } } = { partnerAccountId: primaryId };
if (this.isSubAccount(account)) {
const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId);
if (storeIds.length === 0) return [];
@@ -65,9 +73,9 @@ export class StoreService {
}
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerId: account.partnerId },
where: { id: storeId, partnerAccountId: primaryId },
include: { category: true, coverResource: true },
});
if (!store) throw new NotFoundException('门店不存在');
@@ -88,10 +96,11 @@ export class StoreService {
}
async partnerListCities(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
const cityWhere = await this.partnerCityService.buildPartnerCityWhere(primaryId);
const cities = await this.prisma.commonCity.findMany({
where: { partnerId: account.partnerId },
select: { id: true, name: true, code: true, province: true, partnerId: true },
where: cityWhere,
select: { id: true, name: true, code: true, province: true },
orderBy: { createdAt: 'desc' },
});
return serializeBigInt(cities);
@@ -115,11 +124,11 @@ export class StoreService {
}
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
const account = await this.getPartnerAccount(partnerAccountId);
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
const normalizedPhone = String(body.phone).trim();
await this.assertStorePhoneAvailable(normalizedPhone);
const city = await this.resolvePartnerCity(account.partnerId, body.cityId);
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
const envPhotoUrls = Array.isArray(body.envPhotoUrls)
? body.envPhotoUrls.map((u) => String(u).trim()).filter(Boolean)
@@ -133,7 +142,7 @@ export class StoreService {
const store = await this.prisma.store.create({
data: {
cityId: city.id,
partnerId: account.partnerId,
partnerAccountId: primaryId,
categoryId: body.categoryId ? parseBigIntParam(body.categoryId, '分类ID') : null,
name: String(body.name),
phone: normalizedPhone,
@@ -220,7 +229,7 @@ export class StoreService {
});
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
partnerAccountId: primaryId,
eventName: 'partner_store_create',
refType: 'STORE',
refId: store.id,
@@ -240,10 +249,10 @@ export class StoreService {
storeId: bigint,
status: 'OPEN' | 'PAUSED' | 'CLOSED',
) {
const account = await this.getPartnerAccount(partnerAccountId);
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerId: account.partnerId },
where: { id: storeId, partnerAccountId: primaryId },
});
if (!store) throw new NotFoundException('门店不存在');
if (store.status === 'CLOSED') {
@@ -259,7 +268,7 @@ export class StoreService {
include: { coverResource: true },
});
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
partnerAccountId: primaryId,
eventName: 'partner_store_status_change',
refType: 'STORE',
refId: storeId,
@@ -276,10 +285,10 @@ export class StoreService {
storeId: bigint,
body: Record<string, unknown>,
) {
const account = await this.getPartnerAccount(partnerAccountId);
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account);
const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerId: account.partnerId },
where: { id: storeId, partnerAccountId: primaryId },
});
if (!store) throw new NotFoundException('门店不存在');
if (store.status === 'CLOSED') {
@@ -343,20 +352,20 @@ export class StoreService {
}
async partnerDashboard(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId);
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account);
const partnerStoreIds = await this.prisma.store.findMany({
where: { partnerId: account.partnerId },
where: { partnerAccountId: primaryId },
select: { id: true },
});
const storeIds = partnerStoreIds.map((s) => s.id);
const [storeCount, orderCount, recentStores, pendingAuditCount] = await Promise.all([
this.prisma.store.count({ where: { partnerId: account.partnerId } }),
this.prisma.store.count({ where: { partnerAccountId: primaryId } }),
this.prisma.order.count({
where: { city: { partnerId: account.partnerId } },
where: await this.partnerCityService.buildPartnerOrderWhere(primaryId),
}),
this.prisma.store.findMany({
where: { partnerId: account.partnerId },
where: { partnerAccountId: primaryId },
select: { id: true, name: true, status: true, createdAt: true },
orderBy: { createdAt: 'desc' },
take: 10,
@@ -375,18 +384,17 @@ export class StoreService {
return {
storeCount,
orderCount,
companyName: account.partner.companyName,
companyName: account.companyName ?? '',
recentStores: serializeBigInt(recentStores),
pendingAuditCount,
};
}
async partnerLeaderboard(partnerAccountId: bigint, period: PartnerLeaderboardPeriod = 'total') {
const account = await this.getPartnerAccount(partnerAccountId);
this.assertPrimaryAccount(account);
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
const accounts = await this.prisma.partnerAccount.findMany({
where: { partnerId: account.partnerId },
where: { OR: [{ id: primaryId }, { parentAccountId: primaryId }] },
orderBy: { id: 'asc' },
});
@@ -457,10 +465,9 @@ export class StoreService {
return { period, list, self };
}
async partnerWeeklyReport(partnerAccountId: bigint, startDate?: string) {
const account = await this.getPartnerAccount(partnerAccountId);
async partnerWeeklyReport(actorAccountId: bigint, startDate?: string) {
const { account, primaryId } = await this.resolvePartnerScope(actorAccountId);
this.assertPrimaryAccount(account);
const partnerId = account.partnerId;
const currentWeekStart = this.startOfWeekMonday(new Date());
const periodStart =
@@ -472,16 +479,16 @@ export class StoreService {
const prevPeriodEnd = periodStart;
const newStoreTarget =
account.partner.weeklyStoreTarget ??
account.weeklyStoreTarget ??
Number(process.env.PARTNER_WEEKLY_STORE_TARGET ?? 20);
const orderWhere = {
city: { partnerId },
...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)),
payStatus: 'PAID' as const,
paidAt: { gte: periodStart, lt: periodEnd },
};
const prevOrderWhere = {
city: { partnerId },
...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)),
payStatus: 'PAID' as const,
paidAt: { gte: prevPeriodStart, lt: prevPeriodEnd },
};
@@ -498,16 +505,16 @@ export class StoreService {
] = await Promise.all([
this.prisma.order.aggregate({ where: orderWhere, _sum: { payAmount: true } }),
this.prisma.order.count({ where: orderWhere }),
this.prisma.store.count({ where: { partnerId } }),
this.prisma.store.count({ where: { partnerAccountId: primaryId } }),
this.prisma.store.count({
where: { partnerId, createdAt: { gte: periodStart, lt: periodEnd } },
where: { partnerAccountId: primaryId, createdAt: { gte: periodStart, lt: periodEnd } },
}),
this.prisma.order.aggregate({ where: prevOrderWhere, _sum: { payAmount: true } }),
this.prisma.redeemRecord.groupBy({
by: ['storeId'],
where: {
createdAt: { gte: periodStart, lt: periodEnd },
store: { partnerId },
store: { partnerAccountId: primaryId },
},
_sum: { amount: true },
orderBy: { _sum: { amount: 'desc' } },
@@ -516,7 +523,7 @@ export class StoreService {
this.prisma.redeemRecord.findMany({
where: {
createdAt: { gte: periodStart, lt: periodEnd },
store: { partnerId },
store: { partnerAccountId: primaryId },
},
select: { storeId: true },
distinct: ['storeId'],
@@ -677,22 +684,29 @@ export class StoreService {
return ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][index] ?? '';
}
private async getPartnerPrimaryId(actorAccountId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(actorAccountId);
return primary.id;
}
private async getPartnerAccount(partnerAccountId: bigint) {
return this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
include: { partner: true },
});
}
private async resolvePartnerCity(partnerId: bigint, cityId: unknown) {
private async resolvePartnerCity(actorAccountId: bigint, cityId: unknown) {
const primaryId = await this.getPartnerPrimaryId(actorAccountId);
if (cityId) {
const city = await this.prisma.commonCity.findFirst({
where: { id: parseBigIntParam(cityId, '城市ID'), partnerId },
});
const id = parseBigIntParam(cityId, '城市ID');
await this.partnerCityService.assertPartnerAccountBoundToCity(primaryId, id);
const city = await this.prisma.commonCity.findUnique({ where: { id } });
if (!city) throw new BadRequestException('所选地区未匹配到开城城市');
return city;
}
const city = await this.prisma.commonCity.findFirst({ where: { partnerId } });
const cityIds = await this.partnerCityService.listCityIdsForPartnerAccount(primaryId);
if (!cityIds.length) throw new BadRequestException('合伙人未绑定开城');
const city = await this.prisma.commonCity.findFirst({ where: { id: { in: cityIds } } });
if (!city) throw new BadRequestException('合伙人未绑定开城');
return city;
}
@@ -5,11 +5,12 @@ import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module';
import { CatalogModule } from '../catalog/catalog.module';
import { CommonModule } from '../common/common.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller';
import { TradeService } from './trade.service';
@Module({
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, forwardRef(() => BenefitModule), CommonModule],
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, CityScopeModule, forwardRef(() => BenefitModule), CommonModule],
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
providers: [TradeService],
exports: [TradeService],
@@ -17,6 +17,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { AnalyticsService } from '../analytics/analytics.service';
import { CatalogService } from '../catalog/catalog.service';
import { BenefitService } from '../benefit/benefit.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { TicketService } from '../common/ticket.service';
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
import { IPayProvider } from '../../integrations/pay/pay.interface';
@@ -39,6 +40,7 @@ export class TradeService {
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
@@ -220,6 +222,10 @@ export class TradeService {
const { externalNo } = payResult;
const now = new Date();
const paySnapshot = await this.partnerCityService.resolveForOrder(
order.cityId,
order.receiverDistrict,
);
await this.prisma.$transaction(async (tx) => {
await tx.order.update({
@@ -229,6 +235,8 @@ export class TradeService {
payStatus: 'PAID',
paidAt: now,
payExternalNo: externalNo,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
},
});
await tx.logThirdParty.create({
@@ -300,6 +308,10 @@ export class TradeService {
}
const now = new Date();
const paySnapshot = await this.partnerCityService.resolveForOrder(
order.cityId,
order.receiverDistrict,
);
await this.prisma.$transaction(async (tx) => {
const current = await tx.order.findUnique({ where: { id: order.id } });
if (!current || current.payStatus === 'PAID') return;
@@ -311,6 +323,8 @@ export class TradeService {
payStatus: 'PAID',
paidAt: now,
payExternalNo: params.transactionId,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
},
});
await tx.logThirdParty.create({
@@ -450,15 +464,10 @@ export class TradeService {
}
async listPartnerReshipments(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const cities = await this.prisma.commonCity.findMany({
where: { partnerId: account.partnerId },
select: { id: true },
});
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
const orders = await this.prisma.order.findMany({
where: { cityId: { in: cities.map((c) => c.id) } },
where: orderWhere,
select: { id: true },
});
const tickets = await this.prisma.commonTicket.findMany({
@@ -473,12 +482,8 @@ export class TradeService {
}
async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const cities = await this.prisma.commonCity.findMany({ where: { partnerId: account.partnerId } });
const cityIds = cities.map((c) => c.id);
const where = { cityId: { in: cityIds } };
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const where = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
const [list, total] = await Promise.all([
this.prisma.order.findMany({
where,
@@ -493,12 +498,10 @@ export class TradeService {
}
async getPartnerOrder(partnerAccountId: bigint, orderId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const cities = await this.prisma.commonCity.findMany({ where: { partnerId: account.partnerId } });
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
const order = await this.prisma.order.findFirst({
where: { id: orderId, cityId: { in: cities.map((c) => c.id) } },
where: { id: orderId, ...partnerOrderWhere },
include: { delivery: true, user: true, imageResource: true },
});
if (!order) throw new NotFoundException('订单不存在');
@@ -510,13 +513,12 @@ export class TradeService {
}
async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
const order = await this.prisma.order.findFirst({
where: {
id: orderId,
city: { partnerId: account.partnerId },
...partnerOrderWhere,
},
include: { delivery: true },
});
@@ -524,7 +526,7 @@ export class TradeService {
await this.applyStatusTransition(order.id, order.status, targetStatus);
if (targetStatus === 'SHIPPING') {
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
partnerAccountId: primary.id,
eventName: 'partner_order_ship',
refType: 'ORDER',
refId: orderId,
@@ -532,7 +534,7 @@ export class TradeService {
});
}
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId,
partnerAccountId: primary.id,
eventName: 'partner_delivery_advance',
refType: 'ORDER',
refId: orderId,
@@ -0,0 +1,124 @@
# 杜康好客 · V3 城市 / 仓库 / 日志架构
> **版本**2026-07-12
> **状态**P1 城市多合伙 + P2 仓库 **已实现**(2026-07-12);合伙人端管仓日志仍待 Wave 3
> **分工**:刘景尧任务暂由 `jacy-dukang` 代管(见 `AGENTS.md`
---
## 1. 目标数据模型(城市顶层)
```
CommonCity (开城)
├── PartnerAccount[] 1:N 主账号(isPrimary=1,含城市绑定 + 购酒/核销佣金)
├── CityWarehouse[] 1:N 城市仓库(HQ 管 / 合伙人管)
└── CatalogProduct[] 按城市上架
PartnerAccount (主账号 = 城市合伙人主体)
├── cityId / scopeType / districtCodes / bindingStatus
├── orderCommissionRate / redeemCommissionRate
├── companyName / 银行 / 合同等主体信息
├── managedWarehouseId? 可选管仓
└── PartnerAccount[] 子账号(parentAccountIdpermissions JSON
CityWarehouse
├── cityId
├── managerType: HQ | PARTNER
└── partnerAccountId? 合伙人管仓时绑定主账号
Store
├── partnerAccountId FK → 主账号
└── settlementRate 门店核销结算比例(默认 0.60)
```
**迁移要点(2026-07**:删除 `partner_partner``common_city_partner``common_city_commission_rule`;城市绑定与购酒分佣合并至 `partner_account` 主账号;门店结算比例下沉至 `store_store.settlementRate`;订单快照字段为 `partnerAccountIdAtPay` + `orderCommissionRateAtPay`
---
## 2. 日志表与职责划分
| 操作主体 | 日志表 | eventType / eventName | 查询入口 |
|----------|--------|----------------------|----------|
| **HQ WebAdmin** 写操作 | `common_event` | `HQ_OPERATION` + `param1=action` | admin-web `/logs/hq` |
| **合伙人端** 行为 | `log_partner_analytics` | `partner_*` eventName | admin-web `/logs/partner` |
| **门店端** 行为 | `log_store_analytics` | `store_*` | admin-web `/logs/store` |
| **C 端** 行为 | `log_user_analytics` | 埋点 eventName | admin-web `/logs/user` |
| 权益/订单状态机 | `common_event` | `BENEFIT_LEDGER` / `ORDER_STATUS` | 领域查询 |
**原则**HQ 侧 **CRUD / 审核 / 结算确认** 一律 `@HqOperation``common_event`;端侧 **登录 / 业务操作** 走对应 `log_*_analytics`
---
## 3. CRUD → 日志映射(新业务)
### 3.1 HQ 侧(`common_event.HQ_OPERATION`
| 实体 | refType | action 常量 | 装饰器状态 |
|------|---------|-------------|------------|
| 开城城市 | `CITY` | `CITY_CREATE` / `CITY_UPDATE` | ✅ 已接入 |
| 开城城市 | `CITY` | `CITY_DELETE` | ⏳ 待 API |
| 城市仓库 | `WAREHOUSE` | `WAREHOUSE_CREATE` / `UPDATE` / `DELETE` | ✅ 已接入 |
| 城市合伙人(主账号) | `PARTNER` | `PARTNER_CREATE` / `PARTNER_UPDATE` | ✅ 已接入 |
| 合伙人账号(HQ | `PARTNER_ACCOUNT` | `PARTNER_ACCOUNT_CREATE` / `UPDATE` / `DELETE` | ✅ 已接入 |
| HQ 管理员 | `HQ_ACCOUNT` | `HQ_ACCOUNT_CREATE` / `UPDATE` | ✅ 已接入 |
| HQ 权限 | `HQ_PERMISSION` | `HQ_PERMISSION_UPDATE` | ✅ 已接入 |
实现约定:
- Controller 方法加 `@HqOperation({ action, refType, refIdField|refIdParam, includeBody })`
- `extraJson` 自动写入 `requestBody`(脱敏 password+ `response` 摘要
- 常量定义:`server/.../hq-operation.constants.ts`admin 筛选项:`apps/admin-web/src/lib/hq-log.ts`
### 3.2 合伙人端子账号(`log_partner_analytics`
| 操作 | eventName | refType | 状态 |
|------|-----------|---------|------|
| 主账号新增子账号 | `partner_staff_create` | `PARTNER_ACCOUNT` | ✅ `PartnerStaffService` |
| 编辑子账号 | `partner_staff_update` | `PARTNER_ACCOUNT` | ✅ |
| 仅改角色/权限 | `partner_staff_permission_update` | `PARTNER_ACCOUNT` | ✅ |
| 删除子账号 | `partner_staff_delete` | `PARTNER_ACCOUNT` | ✅ |
| 仓库查看/维护(合伙人管仓) | `partner_warehouse_view` / `partner_warehouse_update` | `WAREHOUSE` | ⏳ Wave 3 |
分类:`packages/shared-types/src/partner-log.ts``account_ops` / `warehouse_ops`
### 3.3 门店子账号
| 操作 | 日志表 | action / eventName | 状态 |
|------|--------|-------------------|------|
| HQ 创建/编辑门店账号 | `common_event` | `STORE_ACCOUNT_CREATE` / `UPDATE` | ✅ |
| 门店端自助(若有) | `log_store_analytics` | 待定义 `store_staff_*` | ⏳ |
---
## 4. 实现检查清单(DoD
每条 CRUD 合并前确认:
- [ ] HQ 写接口有 `@HqOperation`,且 `HqOperationAction` + `hq-log.ts` 标签已同步
- [ ] 合伙人端写接口调用 `AnalyticsService.trackPartnerOneSafe`eventName 已登记在 `partner-log.ts`
- [ ] `extraJson` 不含明文密码/令牌;手机号脱敏
- [ ] admin-web 日志页可按 action / category 筛选到新事件
- [ ] 跨模块不直写他人日志表(经 Analytics / HqOperationLogService
---
## 5. 分阶段交付
| 阶段 | 内容 | 依赖 |
|------|------|------|
| **P0 日志补全** | 子账号 CRUD 落 `log_partner_analytics`;扩展 HQ action 常量 | 无 |
| **P1 城市架构** | `city_partner` 表、迁移 `partner_id`、HQ CRUD + `@HqOperation` | ✅ 2026-07-12 |
| **P2 仓库** | `city_warehouse` 表、HQ CRUD + 合伙人管仓校验 | ✅ 2026-07-12 |
| **P3 权限 JSON** | `PartnerAccount.permissions` 替代纯 `staffRole`;权限变更双写 HQ/合伙人日志 | P1 |
---
## 6. 相关文件
| 路径 | 说明 |
|------|------|
| `server/dukang-api/src/common/hq-operation/` | HQ 审计装饰器 + 拦截器 |
| `server/dukang-api/src/modules/iam/partner-staff.service.ts` | 合伙人子账号 + 日志 |
| `packages/shared-types/src/partner-log.ts` | 合伙人日志分类 |
| `apps/admin-web/src/pages/HqLogsPage.tsx` | HQ 操作日志 |
| `apps/admin-web/src/pages/PartnerLogsPage.tsx` | 合伙人日志 |
+4 -4
View File
@@ -74,7 +74,7 @@
| C10 | 门店主动提现 | 自动 StorePayout | 📋 P2 | 待提现申请流 |
| C11 | 工单四类型 | 3 enum | 📋 P3 | 待扩展 `TicketType` |
| C12 | 现场提货 | 无 DeliveryType | 📋 P2 | 待 `ON_SITE_PICKUP` |
| C13 | 多合伙人 | 单 partnerId | 📋 P2~3 | 待管辖/快照模型 |
| C13 | 多合伙人 | 单 partnerId | ✅ 已修复 | 主账号 `partner_account` 合并城市绑定;`partner_partner`/`city_partner` 已删 |
| C14 | 文档残留旧口径 | 5min/5 Tab | ✅ 已修复 | `apps/AGENTS.md`、rules、agents、skills |
### 1.3 P0 已改文件清单
@@ -196,8 +196,8 @@
| **REQ-H-014a** 提现白名单配置 | 无 |
| **REQ-S-017~019** 门店子账号 + 多店选店 | 无店员角色、无选店列表 |
| **REQ-P-008** 一号多店确认弹窗 | 当前直接拒绝(见 C9) |
| **REQ-H-004a / REQ-H-007** 多合伙人管辖与佣金 | 无全城/区域、区县互斥、佣金快照 |
| **ACC-P21~P28** 多合伙人全套 | 数据模型与 UI 均未做 |
| **REQ-H-004a / REQ-H-007** 多合伙人管辖与佣金 | 主账号 `partner_account` + HQ 合伙人页;支付写 `partnerAccountIdAtPay` 快照 | 🔶 基础已做;ACC-P21~P28 全套验收待补 |
| **ACC-P21~P28** 多合伙人全套 | 主账号模型 + HQ UI + 域规则单测 | 🔶 部分完成 |
| **推广码完整归因(W2 门禁)** | 后端有部分 API**admin-web 缺推广码管理页** |
| **合伙人 T+30 独立确认打款** | 有 bill generate;合伙人确认/打款流不完整 |
@@ -207,7 +207,7 @@
|------------|------|
| **REQ-P-021 / REQ-H-012** 代下单 | 无 |
| **REQ-S-008a / REQ-S-020 / REQ-H-020a** 弱网 5 次拍照兜底 | 无待处理核销单模型与 UI |
| **REQ-H-004b / REQ-P-026** 一城多仓 + 管仓合伙人 | schema 无 warehouse |
| **REQ-H-004b / REQ-P-026** 一城多仓 + 管仓合伙人 | `city_warehouse` + admin 仓库 Tab 已做;工单协同待 Wave 3 |
| **REQ-U-009 + SC-03** 跨城完整履约 | 后端有 `CROSS_CITY` 检测;总部物流 UX/佣金归总部未闭环 |
| **REQ-U-021 OPT-001** 发票 | 无模块 |
| **REQ-U-022** 四类型工单用户端 | 仅 refund-request |
+11 -4
View File
@@ -24,8 +24,8 @@ V3 必须达到可业务验收状态:
| 负责人 | 主责端 | 主责后端/公共范围 | 说明 |
|---|---|---|---|
| `jacy-dukang` | `apps/h5-user``apps/admin-web` | `packages/*``iam``catalog``trade``benefit``settlement``ops``callbacks``jobs``integrations`、Prisma | Tech lead,负责架构、主交易链路、支付退款、后台运营、交付验收 |
| `刘景尧` | `apps/h5-shop``apps/h5-partner` | `store``redeem`,并配合 `settlement`、配送/核销联调 | 负责门店、合伙人、录店、核销、门店体验与辖区履约 |
| `jacy-dukang` | **全部四端**(含 `h5-shop``h5-partner` | **全部模块** + `packages/*`、Prisma | Tech lead**2026-07 起暂代刘景尧 B+D 职责** |
| `刘景尧` | ~~`apps/h5-shop``apps/h5-partner`~~ | ~~`store``redeem`~~ | **暂停分工**,恢复前由 jacy 代管 |
### 2.1 四端交付形态(工程口径)
@@ -47,9 +47,16 @@ V3 必须达到可业务验收状态:
- `apps/*` 只走 HTTP API 与 `packages/shared-types`,禁止 import `server/*` 或其他 app。
- 后端跨模块只调用 exported Service,禁止为了赶进度直接写他人领域表。
- 涉及 API、枚举、DTO、业务规则变更,必须同步 `packages/shared-types``packages/domain` 与本手册。
- Prisma 迁移由 `jacy-dukang` 主导;涉及 `store` / `redeem` 表或核销流程时 `刘景尧` 必须 Review。
- Prisma 迁移由 `jacy-dukang` 主导;涉及 `store` / `redeem` 表或核销流程时 `刘景尧` 必须 Review(**恢复分工前由 jacy 全权**)
### 2.2 日志与审计(新业务)
城市 / 仓库 / 账号 / 子账号 CRUD 须落入对应日志表,规范见 [`杜康好客-v3-城市仓库与日志架构.md`](./杜康好客-v3-城市仓库与日志架构.md)
- **HQ 写操作** → `common_event(HQ_OPERATION)`,经 `@HqOperation` 装饰器
- **合伙人端子账号** → `log_partner_analytics``partner_staff_*`
- **城市多合伙、仓库表** → schema 待建;action 常量已预留
---
## 3. V3 核销规则(已替代 V2 的 ¥500 上限)