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

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 人团队) ## 边界(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}/` | | jacy-dukang | `apps/*`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, 全部 `modules/*` |
| 刘京尧 | `apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/` | | ~~刘京尧~~ | ~~`apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/`~~(暂代管) |
- 跨模块只 inject **exported Service**,禁止 `prisma` 写他人表 - 跨模块只 inject **exported Service**,禁止 `prisma` 写他人表
- `apps/*` 禁止 import `server/*` 或其他 app - `apps/*` 禁止 import `server/*` 或其他 app
+8 -6
View File
@@ -19,7 +19,7 @@
```bash ```bash
# 基础设施 # 基础设施
cd deploy && docker compose up -d cd deploy && docker compose up -d # dukang-v1MySQL :6016 Redis :6017
# 依赖与数据库 # 依赖与数据库
pnpm install pnpm install
@@ -60,16 +60,18 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
| 负责人 | Git 账号 | 职责 | | 负责人 | Git 账号 | 职责 |
|--------|----------|------| |--------|----------|------|
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review | | **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 | | 逻辑 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` | | **A + C + Lead + B + D** | jacy-dukang | `apps/*`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | 全部后端模块 |
| **B + D** | 刘景尧 | `apps/h5-partner/`, `apps/h5-shop/` | `store`, `redeem` | | ~~B + D~~ | ~~刘景尧~~ | — | 恢复分工前由 jacy 代管 |
**Prisma 迁移**jacy-dukang 主 Review若改 `store_*` / 核销相关表,需刘景尧共同 Review。 **Prisma 迁移**jacy-dukang 主 Review涉及 `store_*` / 核销表时仍建议刘景尧知会(恢复分工后共同 Review
### 跨模块规则(R1–R8 摘要) ### 跨模块规则(R1–R8 摘要)
+2 -2
View File
@@ -8,7 +8,7 @@
```bash ```bash
cd deploy cd deploy
docker compose up -d docker compose up -d # MySQL :6016 Redis :6017(与 dukang-v3 的 6014/6015 隔离)
``` ```
### 2. 安装依赖 ### 2. 安装依赖
@@ -48,7 +48,7 @@ pnpm dev:partner # http://localhost:5175
|----|--------| |----|--------|
| C端用户 | 13800000001 | | C端用户 | 13800000001 |
| 合伙人 | 13700000001 | | 合伙人 | 13700000001 |
| HQ | 13600000001 | | HQ | 13600000001(密码登录:`admin` / `dukang@123!`;短信验证码 123456 |
门店登录使用录入门店时绑定的手机号;seed 仅预置「郑州老城店」联调账号 `13910000001` 门店登录使用录入门店时绑定的手机号;seed 仅预置「郑州老城店」联调账号 `13910000001`
+6 -4
View File
@@ -7,8 +7,6 @@ import UsersPage from './pages/UsersPage';
import OrdersPage from './pages/OrdersPage'; import OrdersPage from './pages/OrdersPage';
import StoresPage from './pages/StoresPage'; import StoresPage from './pages/StoresPage';
import StoreAccountsPage from './pages/StoreAccountsPage'; import StoreAccountsPage from './pages/StoreAccountsPage';
import PartnersPage from './pages/PartnersPage';
import PartnerAccountsPage from './pages/PartnerAccountsPage';
import BenefitCouponsPage from './pages/BenefitCouponsPage'; import BenefitCouponsPage from './pages/BenefitCouponsPage';
import BenefitLedgersPage from './pages/BenefitLedgersPage'; import BenefitLedgersPage from './pages/BenefitLedgersPage';
import RedeemRecordsPage from './pages/RedeemRecordsPage'; import RedeemRecordsPage from './pages/RedeemRecordsPage';
@@ -17,6 +15,8 @@ import DeliveriesPage from './pages/DeliveriesPage';
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage'; import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
import HqAccountsPage from './pages/HqAccountsPage'; import HqAccountsPage from './pages/HqAccountsPage';
import CitiesPage from './pages/CitiesPage'; import CitiesPage from './pages/CitiesPage';
import CityPartnersPage from './pages/CityPartnersPage';
import CityWarehousesPage from './pages/CityWarehousesPage';
import StoreMediaPage from './pages/StoreMediaPage'; import StoreMediaPage from './pages/StoreMediaPage';
import ProductsPage from './pages/ProductsPage'; import ProductsPage from './pages/ProductsPage';
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage'; import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
@@ -58,9 +58,11 @@ export default function App() {
<Route path="/store-accounts" element={<StoreAccountsPage />} /> <Route path="/store-accounts" element={<StoreAccountsPage />} />
<Route path="/store-media" element={<StoreMediaPage />} /> <Route path="/store-media" element={<StoreMediaPage />} />
<Route path="/resources" element={<ResourcesPage />} /> <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="/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/coupons" element={<BenefitCouponsPage />} />
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} /> <Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
<Route path="/redeem-records" element={<RedeemRecordsPage />} /> <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 />, icon: <TeamOutlined />,
label: '开城', label: '开城',
children: [ children: [
{ key: '/partners', label: '开城合伙人' }, { key: '/cities', label: '城市' },
{ key: '/cities', label: '开城城市' }, { key: '/city-partners', label: '城市合伙人' },
{ key: '/partner-accounts', label: '开城合伙人账户' }, { key: '/city-warehouses', label: '仓库' },
{ key: '/partner-bills', 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 { 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 = { export type OpenCityRef = {
id: string; id: string;
name: string; name: string;
code: string; code: string;
partnerId?: string | null; partnerBindings?: Array<{ partnerAccountId: string }>;
partner?: { id: 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 = { export type ParsedChinaRegion = {
province: string; province: string;
city: string; city: string;
@@ -19,6 +40,39 @@ export type ParsedChinaRegion = {
districtCode: string; 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 */ /** 区县 adcode → 地级市 adcode(如 410105 → 410100 */
export function districtCodeToCityCode(districtCode: string): string { export function districtCodeToCityCode(districtCode: string): string {
if (districtCode.length < 6) return districtCode; if (districtCode.length < 6) return districtCode;
@@ -42,14 +96,11 @@ export function formatRegionLabel(region: ParsedChinaRegion): string {
export function matchOpenCityId( export function matchOpenCityId(
cities: OpenCityRef[], cities: OpenCityRef[],
districtCode: string, districtCode: string,
partnerId?: string, partnerAccountId?: string,
): string | undefined { ): string | undefined {
const cityCode = districtCodeToCityCode(districtCode); const cityCode = districtCodeToCityCode(districtCode);
const scoped = partnerId const scoped = partnerAccountId
? cities.filter((c) => { ? cities.filter((c) => cityBoundPartnerIds(c).includes(String(partnerAccountId)))
const pid = c.partnerId ?? c.partner?.id;
return !pid || String(pid) === partnerId;
})
: cities; : cities;
return ( return (
scoped.find((c) => c.code === cityCode)?.id scoped.find((c) => c.code === cityCode)?.id
@@ -61,11 +112,11 @@ export function matchOpenCityId(
export function resolveRegionBinding( export function resolveRegionBinding(
codes: string[], codes: string[],
cities: OpenCityRef[], cities: OpenCityRef[],
partnerId?: string, partnerAccountId?: string,
) { ) {
const region = parseRegionCodes(codes); const region = parseRegionCodes(codes);
if (!region) return null; 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; const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined;
return { return {
region, region,
+7
View File
@@ -1,6 +1,13 @@
export const HQ_OPERATION_ACTION_OPTIONS = [ export const HQ_OPERATION_ACTION_OPTIONS = [
{ value: 'CITY_CREATE', label: '新增开城城市' }, { value: 'CITY_CREATE', label: '新增开城城市' },
{ value: 'CITY_UPDATE', 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_CREATE', label: '新增城市合伙人' },
{ value: 'PARTNER_UPDATE', label: '编辑城市合伙人' }, { value: 'PARTNER_UPDATE', label: '编辑城市合伙人' },
{ value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' }, { value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' },
+5 -4
View File
@@ -1,5 +1,5 @@
export type StoreCreateForm = { export type StoreCreateForm = {
partnerId: string; partnerAccountId: string;
cityId: string; cityId: string;
regionCodes?: string[]; regionCodes?: string[];
province?: string; province?: string;
@@ -16,17 +16,18 @@ export type StoreCreateForm = {
bankAccountName: string; bankAccountName: string;
bankAccountNo: string; bankAccountNo: string;
bankBranch: string; bankBranch: string;
settlementRate?: number;
}; };
const PHONE_RE = /^1\d{10}$/; const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/; const BANK_RE = /^\d{16,19}$/;
export function validateStoreCreateStep1( 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 { ): string | null {
if (!form.partnerId) return '请选择开城合伙人'; if (!form.partnerAccountId) return '请选择开城合伙人';
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县'; if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 开城城市」配置对应区划'; if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
if (!form.name?.trim()) return '请填写门店名称'; if (!form.name?.trim()) return '请填写门店名称';
if (!form.phone?.trim()) return '请填写门店手机号'; if (!form.phone?.trim()) return '请填写门店手机号';
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号'; if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
+317 -45
View File
@@ -1,24 +1,63 @@
import { useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { 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'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api'; 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 { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList'; import { useAdminList } from '../lib/useAdminList';
import CityPartnersPanel from '../components/CityPartnersPanel';
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
type Row = { type WarehouseRow = {
id: string; code: string; name: string; province: string; status: string; id: string;
storeCount: number; orderCount: number; createdAt: string; name: string;
partner?: { id: string; companyName: 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() { export default function CitiesPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [warehouseForm] = Form.useForm();
const [warehouseEditForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/cities', '/admin/cities',
@@ -32,37 +71,132 @@ export default function CitiesPage() {
[filters], [filters],
); );
const [detail, setDetail] = useState<Record<string, unknown> | null>(null); const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [warehouses, setWarehouses] = useState<WarehouseRow[]>([]);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = 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 [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 loadPartners = useCallback(async (cityId: string) => {
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
setPartners(res.items); 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> = [ const columns: ColumnsType<Row> = [
{ title: '编码', dataIndex: 'code', width: 90 }, { title: '编码', dataIndex: 'code', width: 90 },
{ title: '城市', dataIndex: 'name', width: 100 }, { title: '城市', dataIndex: 'name', width: 100 },
{ title: '省份', dataIndex: 'province', width: 90 }, { title: '省份', dataIndex: 'province', width: 90 },
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> }, { 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: 'storeCount', width: 70 },
{ title: '订单', dataIndex: 'orderCount', width: 70 }, { title: '订单', dataIndex: 'orderCount', width: 70 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', width: 80, title: '操作',
width: 80,
render: (_, row) => ( render: (_, row) => (
<Button type="link" size="small" onClick={async () => { <Button type="link" size="small" onClick={() => void openDetail(row)}>
const d = await request<Record<string, unknown>>(`/admin/cities/${row.id}`);
setDetail(d); </Button>
editForm.setFieldsValue({ ),
...d, },
partnerId: (d.partner as { id?: string } | null)?.id ?? (d as { partnerId?: string }).partnerId, ];
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));
},
}); });
void loadPartners(); }}
setDrawerOpen(true); >
}}></Button>
</Button>
</Space>
), ),
}, },
]; ];
@@ -70,8 +204,8 @@ export default function CitiesPage() {
return ( return (
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Button type="primary" onClick={() => { void loadPartners(); setCreateOpen(true); }}></Button> <Button type="primary" onClick={openCreateModal}></Button>
</Space> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item> <Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
@@ -81,59 +215,197 @@ export default function CitiesPage() {
</Form.Item> </Form.Item>
<Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item> <Form.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </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); } }} /> 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 && ( <Drawer title="城市管理" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
<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>
)}>
{detail && ( {detail && (
<Tabs
items={[
{
key: 'basic',
label: '基本信息',
children: (
<> <>
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}> <Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="编码">{String(detail.code)}</Descriptions.Item> <Descriptions.Item label="编码">{String(detail.code)}</Descriptions.Item>
<Descriptions.Item label="门店数">{String(detail.storeCount ?? (detail as { _count?: { stores?: number } })._count?.stores ?? '—')}</Descriptions.Item> <Descriptions.Item label="合伙人数">{String(detail.partnerBindingCount ?? '—')}</Descriptions.Item>
</Descriptions> </Descriptions>
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></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="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="状态"> <Form.Item name="status" label="状态">
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> <Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item> </Form.Item>
<Form.Item name="localMinQty" label="同城起订量"><InputNumber min={1} style={{ width: '100%' }} /></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="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> </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> </Drawer>
<Modal title="新建开城城市" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => { <Modal title="新建开城城市" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
const v = await createForm.validateFields(); 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('已创建'); message.success('已创建');
setCreateOpen(false); setCreateOpen(false);
createForm.resetFields(); createForm.resetFields();
setCreateRegionPreview(null);
void reload(); void reload();
}}> }}>
<Form form={createForm} layout="vertical"> <Form form={createForm} layout="vertical">
<Form.Item name="code" label="城市编码" rules={[{ required: true }]}><Input placeholder="如 ZZ" /></Form.Item> <Form.Item
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item> name="regionCodes"
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item> label="所在地区"
<Form.Item name="partnerId" label="开城合伙人"> rules={[{ required: true, message: '请选择省 / 市' }]}
<Select allowClear placeholder="选择合伙人" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} /> >
<ChinaProvinceCityCascader />
</Form.Item> </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"> <Form.Item name="status" label="状态" initialValue="PENDING">
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} /> <Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </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> </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 { useCallback, useEffect, useState } from 'react';
import { 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'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; 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 { request, type Paginated } from '../lib/api';
import { AdminCellLine } from '../components/AdminCellLine'; import { AdminCellLine } from '../components/AdminCellLine';
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants'; 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; status: string;
isPrimary: number; isPrimary: number;
staffRole?: string | null; staffRole?: string | null;
permissions?: string[] | null;
parentAccountId?: string | null; parentAccountId?: string | null;
parent?: ParentAccount | null; parent?: ParentAccount | null;
createdAt: string; createdAt: string;
lastLoginAt?: string | null; lastLoginAt?: string | null;
partner?: { id: string; companyName: string }; companyName?: string | null;
children?: AccountTreeRow[]; children?: AccountTreeRow[];
}; };
@@ -43,6 +44,11 @@ const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS).map(([value
label, 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[] { function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): AccountTreeRow[] {
const phoneQ = phone?.trim(); const phoneQ = phone?.trim();
const match = (row: AccountTreeRow) => { const match = (row: AccountTreeRow) => {
@@ -65,10 +71,6 @@ function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): Ac
return walk(rows); return walk(rows);
} }
function accountTypeLabel(isPrimary: number) {
return isPrimary === 1 ? '主账号' : '子账号';
}
function staffRoleLabel(role?: string | null) { function staffRoleLabel(role?: string | null) {
if (!role) return '-'; if (!role) return '-';
return PARTNER_STAFF_ROLE_LABELS[role as keyof typeof PARTNER_STAFF_ROLE_LABELS] ?? role; 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() { export default function PartnerAccountsPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [createForm] = Form.useForm();
const [subForm] = Form.useForm(); const [subForm] = Form.useForm();
const [filters, setFilters] = useState<{ phone?: string; status?: string; partnerId?: string }>({}); const [filters, setFilters] = useState<{ phone?: string; status?: string; partnerId?: string }>({});
const [treeData, setTreeData] = useState<AccountTreeRow[]>([]); const [treeData, setTreeData] = useState<AccountTreeRow[]>([]);
@@ -85,7 +86,6 @@ export default function PartnerAccountsPage() {
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]); const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
const [detail, setDetail] = useState<Detail | null>(null); const [detail, setDetail] = useState<Detail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [subOpen, setSubOpen] = useState(false); const [subOpen, setSubOpen] = useState(false);
const [subParent, setSubParent] = useState<AccountTreeRow | null>(null); const [subParent, setSubParent] = useState<AccountTreeRow | null>(null);
const [partners, setPartners] = useState<PartnerOption[]>([]); const [partners, setPartners] = useState<PartnerOption[]>([]);
@@ -117,6 +117,7 @@ export default function PartnerAccountsPage() {
name: detail.name, name: detail.name,
phone: detail.phone, phone: detail.phone,
status: detail.status, status: detail.status,
permissions: detail.permissions ?? [],
}); });
}, [drawerOpen, detail, editForm]); }, [drawerOpen, detail, editForm]);
@@ -153,7 +154,7 @@ export default function PartnerAccountsPage() {
function openAddSub(parent: AccountTreeRow) { function openAddSub(parent: AccountTreeRow) {
setSubParent(parent); setSubParent(parent);
subForm.resetFields(); subForm.resetFields();
subForm.setFieldsValue({ staffRole: 'INTERNAL' }); subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
setSubOpen(true); setSubOpen(true);
} }
@@ -179,9 +180,7 @@ export default function PartnerAccountsPage() {
secondary={ secondary={
row.parentAccountId row.parentAccountId
? `子账号 · ${staffRoleLabel(row.staffRole)}` ? `子账号 · ${staffRoleLabel(row.staffRole)}`
: row.isPrimary === 1 : '主账号'
? '主账号'
: '合伙人账号'
} }
/> />
), ),
@@ -191,15 +190,14 @@ export default function PartnerAccountsPage() {
title: '开城合伙人', title: '开城合伙人',
width: 140, width: 140,
ellipsis: true, ellipsis: true,
render: (_, row) => row.partner?.companyName || '—', render: (_, row) => row.companyName || row.parent?.name || '—',
}, },
{ {
title: '账号类型', title: '账号类型',
dataIndex: 'isPrimary',
width: 90, width: 90,
render: (v, row) => ( render: (_, row) => (
<Tag color={row.parentAccountId ? 'default' : v === 1 ? 'blue' : 'default'}> <Tag color={row.parentAccountId ? 'default' : 'blue'}>
{row.parentAccountId ? '子账号' : accountTypeLabel(v)} {row.parentAccountId ? '子账号' : '主账号'}
</Tag> </Tag>
), ),
}, },
@@ -213,9 +211,9 @@ export default function PartnerAccountsPage() {
title: '所属主账号', title: '所属主账号',
width: 140, width: 140,
render: (_, row) => ( render: (_, row) => (
row.isPrimary === 1 || !row.parentAccountId row.parentAccountId && row.parent
? '-' ? `${row.parent.name} / ${row.parent.phone}`
: (row.parent ? `${row.parent.name} / ${row.parent.phone}` : '-') : '-'
), ),
}, },
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> }, { title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
@@ -226,7 +224,7 @@ export default function PartnerAccountsPage() {
render: (_, row) => ( render: (_, row) => (
<Space size={0} wrap onClick={(e) => e.stopPropagation()}> <Space size={0} wrap onClick={(e) => e.stopPropagation()}>
<Button type="link" size="small" onClick={() => void openAccount(row.id)}></Button> <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> <Button type="link" size="small" onClick={() => openAddSub(row)}></Button>
) : null} ) : null}
{row.parentAccountId ? ( {row.parentAccountId ? (
@@ -260,10 +258,11 @@ export default function PartnerAccountsPage() {
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<div> <div>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Text type="secondary">/</Typography.Text> <Typography.Text type="secondary">
</Typography.Text>
</div> </div>
<Button type="primary" onClick={() => { void loadPartners(); setCreateOpen(true); }}></Button>
</Space> </Space>
<Form <Form
form={form} form={form}
@@ -315,7 +314,7 @@ export default function PartnerAccountsPage() {
}} }}
/> />
<Drawer <Drawer
title={detail?.parentAccountId ? '合伙人子账号详情' : '开城合伙人账户详情'} title={detail?.parentAccountId ? '子账号详情' : '主账号详情(只读)'}
width={720} width={720}
open={drawerOpen} open={drawerOpen}
onClose={() => { onClose={() => {
@@ -323,7 +322,11 @@ export default function PartnerAccountsPage() {
setDetail(null); setDetail(null);
}} }}
destroyOnClose destroyOnClose
extra={<Button type="primary" onClick={() => void saveAccount()}></Button>} extra={
detail?.parentAccountId
? <Button type="primary" onClick={() => void saveAccount()}></Button>
: null
}
> >
{detail && ( {detail && (
<Tabs items={[ <Tabs items={[
@@ -339,32 +342,28 @@ export default function PartnerAccountsPage() {
name: detail.name, name: detail.name,
phone: detail.phone, phone: detail.phone,
status: detail.status, status: detail.status,
permissions: detail.permissions ?? [],
}} }}
> >
<Form.Item label="开城合伙人"> <Form.Item label="开城合伙人">
<Input value={detail.partner?.companyName} disabled /> <Input value={detail.companyName || detail.parent?.name || '—'} disabled />
</Form.Item> </Form.Item>
<Form.Item label="账号类型"> <Form.Item label="账号类型">
<Input <Input
value={ value={
detail.parentAccountId detail.parentAccountId
? `子账号 · ${staffRoleLabel(detail.staffRole)}` ? `子账号 · ${staffRoleLabel(detail.staffRole)}`
: accountTypeLabel(detail.isPrimary) : '主账号'
} }
disabled disabled
/> />
</Form.Item> </Form.Item>
{detail.staffRole ? (
<Form.Item label="角色">
<Input value={staffRoleLabel(detail.staffRole)} disabled />
</Form.Item>
) : null}
{detail.parentAccountId && detail.parent ? ( {detail.parentAccountId && detail.parent ? (
<Form.Item label="所属主账号"> <Form.Item label="所属主账号">
<Input value={`${detail.parent.name} / ${detail.parent.phone}`} disabled /> <Input value={`${detail.parent.name} / ${detail.parent.phone}`} disabled />
</Form.Item> </Form.Item>
) : null} ) : 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 <Form.Item
name="phone" name="phone"
label="登录手机" label="登录手机"
@@ -373,14 +372,25 @@ export default function PartnerAccountsPage() {
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' }, { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
]} ]}
> >
<Input maxLength={11} /> <Input maxLength={11} disabled={!detail.parentAccountId} />
</Form.Item> </Form.Item>
<Form.Item name="status" label="状态" rules={[{ required: true }]}> <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> </Form.Item>
{detail.parentAccountId ? (
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERMISSION_OPTIONS} />
</Form.Item>
) : null}
{!detail.parentAccountId ? (
<Typography.Text type="secondary"> <Typography.Text type="secondary">
H5 使
</Typography.Text> </Typography.Text>
) : (
<Typography.Text type="secondary">
H5 使
</Typography.Text>
)}
</Form> </Form>
), ),
}, },
@@ -403,31 +413,6 @@ export default function PartnerAccountsPage() {
]} /> ]} />
)} )}
</Drawer> </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 <Modal
title={subParent ? `添加子账号 · ${subParent.name}` : '添加子账号'} title={subParent ? `添加子账号 · ${subParent.name}` : '添加子账号'}
open={subOpen} open={subOpen}
@@ -438,11 +423,11 @@ export default function PartnerAccountsPage() {
await request('/admin/partner-accounts', { await request('/admin/partner-accounts', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
partnerId: subParent.partner?.id,
parentAccountId: subParent.id, parentAccountId: subParent.id,
name: v.name, name: v.name,
phone: v.phone, phone: v.phone,
staffRole: v.staffRole, staffRole: v.staffRole,
permissions: v.permissions,
}), }),
}); });
message.success('子账号已创建'); message.success('子账号已创建');
@@ -466,6 +451,9 @@ export default function PartnerAccountsPage() {
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}> <Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
<Select options={STAFF_ROLE_OPTIONS.filter((o) => o.value !== 'PARTNER')} /> <Select options={STAFF_ROLE_OPTIONS.filter((o) => o.value !== 'PARTNER')} />
</Form.Item> </Form.Item>
<Form.Item name="permissions" label="权限">
<Checkbox.Group options={PERMISSION_OPTIONS} />
</Form.Item>
</Form> </Form>
</Modal> </Modal>
</div> </div>
+266 -46
View File
@@ -1,39 +1,86 @@
import { useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { 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'; } from 'antd';
import type { ColumnsType } from 'antd/es/table'; import type { ColumnsType } from 'antd/es/table';
import { request } from '../lib/api'; import {
import { fmtTime } from '../lib/constants'; 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'; import { useAdminList } from '../lib/useAdminList';
type Row = { type Row = {
id: string; companyName: string; contactPhone: string; address: string; id: string;
storeCount: number; accountCount: number; cityCount: number; createdAt: 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 & { type PartnerDetail = Row & {
address?: string;
districtCodes?: string[] | null;
bindingStatus?: string;
bankAccountName?: string | null; bankAccountName?: string | null;
bankAccountNo?: string | null; bankAccountNo?: string | null;
bankBranch?: 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) { type CityOption = { id: string; name: string; code: string };
return { type WarehouseOption = { id: string; name: string };
companyName: d.companyName,
contactPhone: d.contactPhone, const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
address: d.address, const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
bankAccountName: d.bankAccountName ?? '',
bankAccountNo: d.bankAccountNo ?? '', function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
bankBranch: d.bankBranch ?? '', 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() { export default function PartnersPage() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [editForm] = Form.useForm(); const [editForm] = Form.useForm();
const [createForm] = Form.useForm(); const [createForm] = Form.useForm();
const [subForm] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({}); const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>( const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partners', '/admin/partners',
@@ -48,37 +95,86 @@ export default function PartnersPage() {
const [detail, setDetail] = useState<PartnerDetail | null>(null); const [detail, setDetail] = useState<PartnerDetail | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [createOpen, setCreateOpen] = 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) { async function openPartner(id: string) {
const d = await request<PartnerDetail>(`/admin/partners/${id}`); const d = await request<PartnerDetail>(`/admin/partners/${id}`);
setDetail(d); 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); setDrawerOpen(true);
} }
async function savePartner() { async function savePartner() {
if (!detail) return; if (!detail) return;
const v = await editForm.validateFields(); 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('已保存'); message.success('已保存');
setDrawerOpen(false); setDrawerOpen(false);
void reload(); void reload();
} }
const columns: ColumnsType<Row> = [ const columns: ColumnsType<Row> = [
{ title: '公司名', dataIndex: 'companyName' }, { title: '公司名', dataIndex: 'companyName', ellipsis: true },
{ title: '联系电话', dataIndex: 'contactPhone', width: 130 }, { 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: 'storeCount', width: 70 },
{ title: '账号', dataIndex: 'accountCount', width: 70 }, { title: '账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) },
{ title: '开城城市', dataIndex: 'cityCount', width: 90 },
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
{ {
title: '操作', width: 120, title: '操作',
width: 120,
render: (_, row) => ( render: (_, row) => (
<Space> <Button type="link" size="small" onClick={() => void openPartner(row.id)}>
<Button type="link" size="small" onClick={() => void openPartner(row.id)}></Button>
<Button type="link" size="small" onClick={() => void openPartner(row.id)}></Button> </Button>
</Space>
), ),
}, },
]; ];
@@ -87,54 +183,178 @@ export default function PartnersPage() {
<div> <div>
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}> <Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title> <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> </Space>
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}> <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="companyName" label="公司"><Input allowClear /></Form.Item>
<Form.Item name="contactPhone" 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.Item><Button type="primary" htmlType="submit"></Button></Form.Item>
</Form> </Form>
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} <Table
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} /> 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 <Drawer
title="编辑开城合伙人" title="城合伙人"
width={560} width={640}
open={drawerOpen} open={drawerOpen}
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
extra={<Button type="primary" onClick={() => void savePartner()}></Button>} extra={<Button type="primary" onClick={() => void savePartner()}></Button>}
> >
{detail && ( {detail && (
<> <Tabs
{Array.isArray(detail.cities) && detail.cities.length > 0 && ( items={[
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }} title="关联开城城市"> {
{detail.cities.map((c) => ( key: 'info',
<Descriptions.Item key={c.code} label={c.code}>{c.name} ({c.status})</Descriptions.Item> label: '基本信息',
))} children: (
</Descriptions>
)}
<Form form={editForm} layout="vertical"> <Form form={editForm} layout="vertical">
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></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="contactPhone" label="业务联系电话"><Input /></Form.Item>
<Form.Item name="address" 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) => 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="bankAccountName" label="户名"><Input /></Form.Item>
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item> <Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item> <Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
</Form> </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> </Drawer>
<Modal title="新建开城合伙人" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
<Modal
title="新建城市合伙人"
open={createOpen}
width={560}
onCancel={() => setCreateOpen(false)}
onOk={async () => {
const v = await createForm.validateFields(); const v = await createForm.validateFields();
await request('/admin/partners', { method: 'POST', body: JSON.stringify(v) }); 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('已创建'); message.success('已创建');
setCreateOpen(false); setCreateOpen(false);
createForm.resetFields(); createForm.resetFields();
void reload(); void reload();
}}> }}
<Form form={createForm} layout="vertical"> >
<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="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="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> </Form>
</Modal> </Modal>
</div> </div>
+39 -16
View File
@@ -8,6 +8,7 @@ import {
Form, Form,
Image, Image,
Input, Input,
InputNumber,
Modal, Modal,
Select, Select,
Space, Space,
@@ -57,8 +58,7 @@ type CityOption = {
id: string; id: string;
name: string; name: string;
code: string; code: string;
partnerId?: string | null; partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
partner?: { id: string; companyName: string };
}; };
export default function StoresPage() { export default function StoresPage() {
@@ -87,12 +87,12 @@ export default function StoresPage() {
const [cities, setCities] = useState<CityOption[]>([]); const [cities, setCities] = useState<CityOption[]>([]);
const [optionsLoading, setOptionsLoading] = useState(false); const [optionsLoading, setOptionsLoading] = useState(false);
const selectedPartnerId = Form.useWatch('partnerId', createForm); const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
const selectedRegionCodes = Form.useWatch('regionCodes', createForm); const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
const selectedCityId = Form.useWatch('cityId', createForm); const selectedCityId = Form.useWatch('cityId', createForm);
function bindRegionSelection(codes: string[], partnerId?: string) { function bindRegionSelection(codes: string[], partnerAccountId?: string) {
const binding = resolveRegionBinding(codes, cities, partnerId ?? selectedPartnerId); const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
if (!binding) { if (!binding) {
createForm.setFieldsValue({ regionCodes: codes, cityId: undefined }); createForm.setFieldsValue({ regionCodes: codes, cityId: undefined });
return; return;
@@ -114,7 +114,7 @@ export default function StoresPage() {
if (binding.cityId && binding.matchedCity) { if (binding.cityId && binding.matchedCity) {
return `已匹配开城城市:${binding.matchedCity.name}(区划 ${binding.cityCode},区县 ${binding.region.districtCode}`; return `已匹配开城城市:${binding.matchedCity.name}(区划 ${binding.cityCode},区县 ${binding.region.districtCode}`;
} }
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 开城城市」添加`; return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 城市」添加`;
}, [selectedRegionCodes, cities, selectedPartnerId]); }, [selectedRegionCodes, cities, selectedPartnerId]);
async function loadOptions() { async function loadOptions() {
@@ -127,8 +127,8 @@ export default function StoresPage() {
]); ]);
setPartners(p.items); setPartners(p.items);
setCities(c.items); setCities(c.items);
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 开城合伙人」中创建'); if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 开城城市」中创建'); if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
} catch (e) { } catch (e) {
message.error(e instanceof Error ? e.message : '加载合伙人/城市失败'); message.error(e instanceof Error ? e.message : '加载合伙人/城市失败');
} finally { } finally {
@@ -147,6 +147,7 @@ export default function StoresPage() {
void loadOptions(); void loadOptions();
createForm.setFieldsValue({ createForm.setFieldsValue({
envPhotoUrls: ['', '', ''], envPhotoUrls: ['', '', ''],
settlementRate: 60,
}); });
setCreateStep(0); setCreateStep(0);
setCreateError(''); setCreateError('');
@@ -162,7 +163,7 @@ export default function StoresPage() {
return; return;
} }
try { try {
await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'address']); await createForm.validateFields(['partnerAccountId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
} catch { } catch {
return; return;
} }
@@ -184,7 +185,7 @@ export default function StoresPage() {
await request('/admin/stores', { await request('/admin/stores', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
partnerId: values.partnerId, partnerAccountId: values.partnerAccountId,
cityId: values.cityId, cityId: values.cityId,
province: values.province, province: values.province,
city: values.city, city: values.city,
@@ -199,6 +200,7 @@ export default function StoresPage() {
bankAccountName: values.bankAccountName.trim(), bankAccountName: values.bankAccountName.trim(),
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''), bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
bankBranch: values.bankBranch.trim(), bankBranch: values.bankBranch.trim(),
settlementRate: Number(values.settlementRate ?? 60) / 100,
}), }),
}); });
message.success('门店已创建'); message.success('门店已创建');
@@ -208,7 +210,7 @@ export default function StoresPage() {
if (e && typeof e === 'object' && 'errorFields' in e) { if (e && typeof e === 'object' && 'errorFields' in e) {
const fields = e as { errorFields?: Array<{ name: string[] }> }; const fields = e as { errorFields?: Array<{ name: string[] }> };
const first = fields.errorFields?.[0]?.name?.[0]; 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); setCreateStep(0);
} }
return; return;
@@ -239,7 +241,15 @@ export default function StoresPage() {
<Button type="link" size="small" onClick={async () => { <Button type="link" size="small" onClick={async () => {
const d = await request<Record<string, unknown>>(`/admin/stores/${row.id}`); const d = await request<Record<string, unknown>>(`/admin/stores/${row.id}`);
setDetail(d); 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); setDrawerOpen(true);
}}></Button> }}></Button>
), ),
@@ -279,7 +289,11 @@ export default function StoresPage() {
}} /> }} />
<Button type="primary" onClick={async () => { <Button type="primary" onClick={async () => {
const v = await editForm.validateFields(); 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('已保存'); message.success('已保存');
setDetail({ ...detail, ...v }); setDetail({ ...detail, ...v });
void reload(); void reload();
@@ -291,6 +305,9 @@ export default function StoresPage() {
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}> <Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item> <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="地址">{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="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
<Descriptions.Item label="操作"> <Descriptions.Item label="操作">
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}> <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="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
<Form.Item name="district" label="区县"><Input /></Form.Item> <Form.Item name="district" label="区县"><Input /></Form.Item>
<Form.Item name="address" 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> </Form>
</> </>
)} )}
@@ -339,16 +359,16 @@ export default function StoresPage() {
)} )}
<Form form={createForm} layout="vertical" preserve> <Form form={createForm} layout="vertical" preserve>
<div style={{ display: createStep === 0 ? 'block' : 'none' }}> <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 <Select
showSearch showSearch
loading={optionsLoading} loading={optionsLoading}
optionFilterProp="label" optionFilterProp="label"
placeholder={optionsLoading ? '加载中…' : '请选择开城合伙人'} placeholder={optionsLoading ? '加载中…' : '请选择开城合伙人'}
options={partners.map((p) => ({ value: p.id, label: p.companyName }))} options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
onChange={(partnerId) => { onChange={(partnerAccountId) => {
const codes = createForm.getFieldValue('regionCodes') as string[] | undefined; 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); else createForm.setFieldValue('cityId', undefined);
}} }}
/> />
@@ -418,6 +438,9 @@ export default function StoresPage() {
<Form.Item name="bankBranch" label="开户支行" rules={[{ required: true, message: '请填写开户支行' }]}> <Form.Item name="bankBranch" label="开户支行" rules={[{ required: true, message: '请填写开户支行' }]}>
<Input placeholder="例如:中国工商银行洛阳分行" /> <Input placeholder="例如:中国工商银行洛阳分行" />
</Form.Item> </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 <Alert
type="info" type="info"
showIcon showIcon
@@ -48,6 +48,8 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
companyName: profile.companyName ?? '', companyName: profile.companyName ?? '',
isPrimary: profile.isPrimary ?? true, isPrimary: profile.isPrimary ?? true,
staffRole: profile.staffRole, 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', '/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; staffRole?: PartnerStaffRole;
}; };
@@ -104,6 +104,8 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
companyName: me.companyName, companyName: me.companyName,
isPrimary: me.isPrimary, isPrimary: me.isPrimary,
staffRole: me.staffRole ?? undefined, 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 { export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
return account?.isPrimary !== false; return account?.isPrimary !== false;
@@ -8,6 +8,15 @@ export function isSubAccount(account: PartnerMe | null | undefined): boolean {
return !!account && account.isPrimary === false; 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 { export function partnerHomePath(account: PartnerMe | null | undefined): string {
return isSubAccount(account) ? '/stores/new?step=1' : '/'; return isSubAccount(account) ? '/stores/new?step=1' : '/';
} }
+9 -4
View File
@@ -1,26 +1,31 @@
name: dukang-v1
# 与 dukang-v36014/6015)隔离;本栈使用 6016/6017
services: services:
mysql: mysql:
image: mysql:8.0 image: mysql:8.0
container_name: dukang-mysql container_name: dukang-v1-mysql
restart: unless-stopped restart: unless-stopped
environment: environment:
MYSQL_ROOT_PASSWORD: root MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: dukang_haoke MYSQL_DATABASE: dukang_haoke
ports: ports:
- '3306:3306' - '6016:3306'
volumes: volumes:
- mysql_data:/var/lib/mysql - mysql_data:/var/lib/mysql
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: dukang-redis container_name: dukang-v1-redis
restart: unless-stopped restart: unless-stopped
ports: ports:
- '6379:6379' - '6017:6379'
volumes: volumes:
- redis_data:/data - redis_data:/data
volumes: volumes:
mysql_data: mysql_data:
name: dukang-v1_mysql_data
redis_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; return undefined;
} }
} }
export * from './city-partner';
+2
View File
@@ -4,6 +4,8 @@ export interface CityDto {
name: string; name: string;
province: string; province: string;
status: string; status: string;
/** 订单+核销佣金合计上限(小数,默认 0.05) */
maxPartnerCommissionRate?: number;
} }
export interface ProductDto { 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', 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 { export enum AccountStatus {
ACTIVE = 'ACTIVE', ACTIVE = 'ACTIVE',
DISABLED = 'DISABLED', DISABLED = 'DISABLED',
+2
View File
@@ -15,3 +15,5 @@ export * from './partner-log';
export * from './promo'; export * from './promo';
export * from './hq-permissions'; export * from './hq-permissions';
export * from './partner'; export * from './partner';
export * from './city-partner';
export * from './city-warehouse';
+14 -1
View File
@@ -1,34 +1,47 @@
export type PartnerLogCategory = export type PartnerLogCategory =
| 'login' | 'login'
| 'wechat_auth' | 'wechat_auth'
| 'account_ops'
| 'store_ops' | 'store_ops'
| 'shipping' | 'shipping'
| 'settlement'; | 'settlement'
| 'warehouse_ops';
export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly string[]> = { export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly string[]> = {
login: ['partner_sms_send', 'partner_sms_login', 'partner_sms_verify_fail', 'partner_login_success'], login: ['partner_sms_send', 'partner_sms_login', 'partner_sms_verify_fail', 'partner_login_success'],
wechat_auth: ['partner_wechat_login', 'partner_wechat_bind'], 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'], store_ops: ['partner_store_create', 'partner_store_status_change'],
shipping: ['partner_order_ship', 'partner_delivery_advance'], shipping: ['partner_order_ship', 'partner_delivery_advance'],
settlement: ['partner_bill_view', 'partner_bill_detail_view'], 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 }> = [ export const PARTNER_LOG_CATEGORY_OPTIONS: Array<{ value: PartnerLogCategory | ''; label: string }> = [
{ value: '', label: '全部' }, { value: '', label: '全部' },
{ value: 'login', label: '登录' }, { value: 'login', label: '登录' },
{ value: 'wechat_auth', label: '微信授权' }, { value: 'wechat_auth', label: '微信授权' },
{ value: 'account_ops', label: '子账号管理' },
{ value: 'store_ops', label: '门店操作' }, { value: 'store_ops', label: '门店操作' },
{ value: 'shipping', label: '发货/配送' }, { value: 'shipping', label: '发货/配送' },
{ value: 'settlement', label: '结算' }, { value: 'settlement', label: '结算' },
{ value: 'warehouse_ops', label: '仓库操作' },
]; ];
export const PARTNER_LOG_CATEGORY_LABELS: Record<PartnerLogCategory | '', string> = { export const PARTNER_LOG_CATEGORY_LABELS: Record<PartnerLogCategory | '', string> = {
'': '全部', '': '全部',
login: '登录', login: '登录',
wechat_auth: '微信授权', wechat_auth: '微信授权',
account_ops: '子账号管理',
store_ops: '门店操作', store_ops: '门店操作',
shipping: '发货/配送', shipping: '发货/配送',
settlement: '结算', settlement: '结算',
warehouse_ops: '仓库操作',
}; };
export function resolvePartnerLogCategory(eventName: string): PartnerLogCategory | null { export function resolvePartnerLogCategory(eventName: string): PartnerLogCategory | null {
+5
View File
@@ -8,6 +8,8 @@ export interface PartnerMe {
companyName?: string; companyName?: string;
hasWechat?: boolean; hasWechat?: boolean;
staffRole?: PartnerStaffRole; staffRole?: PartnerStaffRole;
permissions?: string[];
primaryAccountId?: string;
} }
export interface PartnerStaffItem { export interface PartnerStaffItem {
@@ -15,6 +17,7 @@ export interface PartnerStaffItem {
name: string; name: string;
phone: string; phone: string;
staffRole: PartnerStaffRole; staffRole: PartnerStaffRole;
permissions?: string[];
status: AccountStatus; status: AccountStatus;
lastLoginAt?: string; lastLoginAt?: string;
} }
@@ -22,11 +25,13 @@ export interface PartnerStaffItem {
export interface CreatePartnerStaffRequest { export interface CreatePartnerStaffRequest {
phone: string; phone: string;
name: string; name: string;
permissions?: string[];
} }
export interface UpdatePartnerStaffRequest { export interface UpdatePartnerStaffRequest {
name?: string; name?: string;
staffRole?: PartnerStaffRole; staffRole?: PartnerStaffRole;
permissions?: string[];
status?: AccountStatus; 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 API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
const DEFAULT_MOCK_CODE = process.env.MOCK_SMS_CODE ?? '123456'; 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) { function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, 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.productionbash deploy/sync-api-env.sh production
# .env.development — 本地参考模板,不部署到服务器 # .env.development — 本地参考模板,不部署到服务器
DATABASE_URL="mysql://root:root@localhost:3306/dukang_haoke" DATABASE_URL="mysql://root:root@localhost:6016/dukang_haoke"
REDIS_URL="redis://localhost:6379" REDIS_URL="redis://localhost:6017"
JWT_SECRET="dukang-prev1-dev-secret-change-in-prod" JWT_SECRET="dukang-prev1-dev-secret-change-in-prod"
JWT_EXPIRES_IN="7d" JWT_EXPIRES_IN="7d"
PORT=3000 PORT=3000
+1
View File
@@ -11,6 +11,7 @@
"prisma:migrate": "prisma migrate dev", "prisma:migrate": "prisma migrate dev",
"prisma:validate": "prisma validate", "prisma:validate": "prisma validate",
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts", "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:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.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" "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();
});
+60 -47
View File
@@ -148,25 +148,7 @@ CREATE TABLE common_promo_code (
UNIQUE KEY uk_common_promo_code_code (code) UNIQUE KEY uk_common_promo_code_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码';
-- ===================== PARTNER先于 city/store ============================= -- ===================== PARTNER城市合伙人主账号 + 子账号 =============================
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='城市合伙人主体';
DROP TABLE IF EXISTS common_city; DROP TABLE IF EXISTS common_city;
CREATE TABLE common_city ( CREATE TABLE common_city (
@@ -175,35 +157,18 @@ CREATE TABLE common_city (
name VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL,
province VARCHAR(32) NOT NULL, province VARCHAR(32) NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'PENDING', status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
partner_id BIGINT UNSIGNED DEFAULT NULL,
local_min_qty INT NOT NULL DEFAULT 2, local_min_qty INT NOT NULL DEFAULT 2,
cross_min_qty INT NOT NULL DEFAULT 6, 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), 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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_common_city_code (code), 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
) 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_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; DROP TABLE IF EXISTS partner_account;
CREATE TABLE partner_account ( CREATE TABLE partner_account (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
partner_id BIGINT UNSIGNED NOT NULL,
phone VARCHAR(20) NOT NULL, phone VARCHAR(20) NOT NULL,
name VARCHAR(64) NOT NULL, name VARCHAR(64) NOT NULL,
wx_open_id VARCHAR(64) DEFAULT NULL, wx_open_id VARCHAR(64) DEFAULT NULL,
@@ -211,22 +176,67 @@ CREATE TABLE partner_account (
is_primary TINYINT NOT NULL DEFAULT 0, is_primary TINYINT NOT NULL DEFAULT 0,
parent_account_id BIGINT UNSIGNED DEFAULT NULL, parent_account_id BIGINT UNSIGNED DEFAULT NULL,
staff_role VARCHAR(16) DEFAULT NULL COMMENT 'PARTNER|INTERNAL|PROMOTER', staff_role VARCHAR(16) DEFAULT NULL COMMENT 'PARTNER|INTERNAL|PROMOTER',
permissions JSON DEFAULT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
last_login_at DATETIME(3) DEFAULT NULL, 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), 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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_partner_account_phone (phone), UNIQUE KEY uk_partner_account_phone (phone),
KEY idx_partner_account_partner (partner_id), UNIQUE KEY uk_partner_account_managed_warehouse (managed_warehouse_id),
CONSTRAINT fk_partner_account_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE RESTRICT, 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 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; DROP TABLE IF EXISTS partner_bill;
CREATE TABLE partner_bill ( CREATE TABLE partner_bill (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
bill_no VARCHAR(32) NOT NULL, bill_no VARCHAR(32) NOT NULL,
partner_id BIGINT UNSIGNED NOT NULL, partner_account_id BIGINT UNSIGNED NOT NULL,
period_start DATETIME(3) NOT NULL, period_start DATETIME(3) NOT NULL,
period_end DATETIME(3) NOT NULL, period_end DATETIME(3) NOT NULL,
order_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '下单佣金汇总', order_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '下单佣金汇总',
@@ -238,8 +248,8 @@ CREATE TABLE partner_bill (
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_partner_bill_no (bill_no), UNIQUE KEY uk_partner_bill_no (bill_no),
KEY idx_partner_bill_partner_status (partner_id, status), KEY idx_partner_bill_account_status (partner_account_id, status),
CONSTRAINT fk_partner_bill_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE RESTRICT 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账单'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合伙人T+30账单';
-- ===================== HQ ============================= -- ===================== HQ =============================
@@ -345,7 +355,7 @@ DROP TABLE IF EXISTS store_store;
CREATE TABLE store_store ( CREATE TABLE store_store (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
city_id BIGINT UNSIGNED NOT NULL, 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, category_id BIGINT UNSIGNED DEFAULT NULL,
name VARCHAR(128) NOT NULL, name VARCHAR(128) NOT NULL,
phone VARCHAR(20) NOT NULL, phone VARCHAR(20) NOT NULL,
@@ -366,13 +376,14 @@ CREATE TABLE store_store (
bank_account_name VARCHAR(64) DEFAULT NULL, bank_account_name VARCHAR(64) DEFAULT NULL,
bank_account_no VARCHAR(32) DEFAULT NULL, bank_account_no VARCHAR(32) DEFAULT NULL,
bank_branch VARCHAR(128) 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), 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), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id), PRIMARY KEY (id),
KEY idx_store_store_city_status (city_id, status), 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_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 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='餐饮门店'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='餐饮门店';
@@ -447,6 +458,8 @@ CREATE TABLE user_order (
completed_at DATETIME(3) DEFAULT NULL COMMENT '完成时间', completed_at DATETIME(3) DEFAULT NULL COMMENT '完成时间',
cancelled_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, remark VARCHAR(512) DEFAULT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 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), 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);
});
+74 -47
View File
@@ -106,6 +106,26 @@ enum CityStatus {
PAUSED PAUSED
} }
enum CityPartnerScopeType {
CITY_WIDE
DISTRICT
}
enum CityPartnerStatus {
ACTIVE
PAUSED
}
enum WarehouseManagerType {
HQ
PARTNER
}
enum WarehouseStatus {
ACTIVE
PAUSED
}
enum PartnerStaffRole { enum PartnerStaffRole {
PARTNER PARTNER
INTERNAL INTERNAL
@@ -391,64 +411,46 @@ model CommonCity {
name String @db.VarChar(64) name String @db.VarChar(64)
province String @db.VarChar(32) province String @db.VarChar(32)
status CityStatus @default(PENDING) status CityStatus @default(PENDING)
partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
localMinQty Int @default(2) @map("local_min_qty") localMinQty Int @default(2) @map("local_min_qty")
crossMinQty Int @default(6) @map("cross_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) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
partner Partner? @relation(fields: [partnerId], references: [id], onDelete: SetNull) warehouses CityWarehouse[]
commissionRule CommonCityCommissionRule? partnerAccounts PartnerAccount[] @relation("PartnerAccountCity")
stores Store[] stores Store[]
orders Order[] orders Order[]
@@index([partnerId])
@@map("common_city") @@map("common_city")
} }
model CommonCityCommissionRule { model CityWarehouse {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @unique @map("city_id") @db.UnsignedBigInt cityId BigInt @map("city_id") @db.UnsignedBigInt
orderCommissionRate Decimal @default(0) @map("order_commission_rate") @db.Decimal(5, 4) name String @db.VarChar(128)
redeemCommissionRate Decimal @default(0) @map("redeem_commission_rate") @db.Decimal(5, 4) address String @db.VarChar(256)
partnerProfitRate Decimal @default(0.35) @map("partner_profit_rate") @db.Decimal(5, 4) contactName String @map("contact_name") @db.VarChar(64)
storeSettlementRate Decimal @default(0.60) @map("store_settlement_rate") @db.Decimal(5, 4) 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) 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 ────────────────────────────────────────── // ─── 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 { model PartnerAccount {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
phone String @unique @db.VarChar(20) phone String @unique @db.VarChar(20)
name String @db.VarChar(64) name String @db.VarChar(64)
wxOpenId String? @map("wx_open_id") @db.VarChar(64) wxOpenId String? @map("wx_open_id") @db.VarChar(64)
@@ -456,25 +458,49 @@ model PartnerAccount {
isPrimary Int @default(0) @map("is_primary") @db.TinyInt isPrimary Int @default(0) @map("is_primary") @db.TinyInt
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
staffRole PartnerStaffRole? @map("staff_role") staffRole PartnerStaffRole? @map("staff_role")
permissions Json?
status AccountStatus @default(ACTIVE) status AccountStatus @default(ACTIVE)
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3) 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) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict) 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) parent PartnerAccount? @relation("PartnerAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: SetNull)
children PartnerAccount[] @relation("PartnerAccountHierarchy") children PartnerAccount[] @relation("PartnerAccountHierarchy")
stores Store[]
bills PartnerBill[]
@@index([partnerId]) @@index([cityId, scopeType])
@@index([cityId, isPrimary])
@@index([parentAccountId]) @@index([parentAccountId])
@@index([wxOpenId]) @@index([wxOpenId])
@@index([contactPhone])
@@map("partner_account") @@map("partner_account")
} }
model PartnerBill { model PartnerBill {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
billNo String @unique @map("bill_no") @db.VarChar(32) billNo String @unique @map("bill_no") @db.VarChar(32)
partnerId BigInt @map("partner_id") @db.UnsignedBigInt partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
periodStart DateTime @map("period_start") @db.DateTime(3) periodStart DateTime @map("period_start") @db.DateTime(3)
periodEnd DateTime @map("period_end") @db.DateTime(3) periodEnd DateTime @map("period_end") @db.DateTime(3)
orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2) orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2)
@@ -485,9 +511,9 @@ model PartnerBill {
paidAt DateTime? @map("paid_at") @db.DateTime(3) paidAt DateTime? @map("paid_at") @db.DateTime(3)
createdAt DateTime @default(now()) @map("created_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") @@map("partner_bill")
} }
@@ -626,7 +652,7 @@ model UserPromoAttribution {
model Store { model Store {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
cityId BigInt @map("city_id") @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 categoryId BigInt? @map("category_id") @db.UnsignedBigInt
name String @db.VarChar(128) name String @db.VarChar(128)
phone String @db.VarChar(20) phone String @db.VarChar(20)
@@ -647,11 +673,12 @@ model Store {
bankAccountName String? @map("bank_account_name") @db.VarChar(64) bankAccountName String? @map("bank_account_name") @db.VarChar(64)
bankAccountNo String? @map("bank_account_no") @db.VarChar(32) bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
bankBranch String? @map("bank_branch") @db.VarChar(128) 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) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
cityRef CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict) 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) category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull) coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
account StoreAccount? account StoreAccount?
@@ -660,7 +687,7 @@ model Store {
payouts StorePayout[] payouts StorePayout[]
@@index([cityId, status]) @@index([cityId, status])
@@index([partnerId]) @@index([partnerAccountId])
@@map("store_store") @@map("store_store")
} }
@@ -731,6 +758,8 @@ model Order {
completedAt DateTime? @map("completed_at") @db.DateTime(3) completedAt DateTime? @map("completed_at") @db.DateTime(3)
cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3) cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3)
payExpireAt DateTime? @map("pay_expire_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) remark String? @db.VarChar(512)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
@@ -915,8 +944,7 @@ model LogStoreAnalytics {
model LogPartnerAnalytics { model LogPartnerAnalytics {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
eventName String @map("event_name") @db.VarChar(64) eventName String @map("event_name") @db.VarChar(64)
clientApp ClientApp? @map("client_app") clientApp ClientApp? @map("client_app")
refType String? @map("ref_type") @db.VarChar(32) refType String? @map("ref_type") @db.VarChar(32)
@@ -924,7 +952,6 @@ model LogPartnerAnalytics {
extraJson Json? @map("extra_json") extraJson Json? @map("extra_json")
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
@@index([partnerId, createdAt])
@@index([partnerAccountId, createdAt]) @@index([partnerAccountId, createdAt])
@@index([eventName, createdAt]) @@index([eventName, createdAt])
@@map("log_partner_analytics") @@map("log_partner_analytics")
+502 -50
View File
@@ -1,421 +1,873 @@
import { PrismaClient, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client'; 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'; import { DEFAULT_PRODUCT_DETAIL_TEMPLATES } from './seeds/product-detail-templates.default';
const prisma = new PrismaClient(); const prisma = new PrismaClient();
async function createMockResource( async function createMockResource(
ownerType: ResourceOwnerType, ownerType: ResourceOwnerType,
ownerId: bigint, ownerId: bigint,
bizType: ResourceBizType, bizType: ResourceBizType,
url: string, url: string,
) { ) {
return prisma.commonResource.create({ return prisma.commonResource.create({
data: { data: {
ownerType, ownerType,
ownerId, ownerId,
bizType, bizType,
mediaType: ResourceMediaType.IMAGE, mediaType: ResourceMediaType.IMAGE,
ossBucket: 'mock-dukang', ossBucket: 'mock-dukang',
ossKey: `mock/${ownerType.toLowerCase()}/${ownerId}/${bizType.toLowerCase()}`, ossKey: `mock/${ownerType.toLowerCase()}/${ownerId}/${bizType.toLowerCase()}`,
url, url,
}, },
}); });
} }
async function main() { async function main() {
console.log('Seeding v3.1 data...'); console.log('Seeding v3.1 data...');
await prisma.logUserAnalytics.deleteMany(); await prisma.logUserAnalytics.deleteMany();
await prisma.logPartnerAnalytics.deleteMany();
await prisma.logThirdParty.deleteMany(); await prisma.logThirdParty.deleteMany();
await prisma.storePayout.deleteMany(); await prisma.storePayout.deleteMany();
await prisma.storeRating.deleteMany(); await prisma.storeRating.deleteMany();
await prisma.redeemRecord.deleteMany(); await prisma.redeemRecord.deleteMany();
await prisma.benefitCoupon.deleteMany(); await prisma.benefitCoupon.deleteMany();
await prisma.orderDelivery.deleteMany(); await prisma.orderDelivery.deleteMany();
await prisma.order.deleteMany(); await prisma.order.deleteMany();
await prisma.commonEvent.deleteMany(); await prisma.commonEvent.deleteMany();
await prisma.commonTicket.deleteMany(); await prisma.commonTicket.deleteMany();
await prisma.userPromoAttribution.deleteMany(); await prisma.userPromoAttribution.deleteMany();
await prisma.userCityPreference.deleteMany(); await prisma.userCityPreference.deleteMany();
await prisma.userAddress.deleteMany(); await prisma.userAddress.deleteMany();
await prisma.user.deleteMany(); await prisma.user.deleteMany();
await prisma.storeAccount.deleteMany(); await prisma.storeAccount.deleteMany();
await prisma.store.deleteMany(); await prisma.store.deleteMany();
await prisma.partnerBill.deleteMany(); await prisma.partnerBill.deleteMany();
await prisma.cityWarehouse.deleteMany();
await prisma.partnerAccount.deleteMany(); await prisma.partnerAccount.deleteMany();
await prisma.commonCityCommissionRule.deleteMany();
await prisma.commonCity.deleteMany(); await prisma.commonCity.deleteMany();
await prisma.partner.deleteMany();
await prisma.commonProductItem.deleteMany(); await prisma.commonProductItem.deleteMany();
await prisma.commonProductDetailTemplate.deleteMany(); await prisma.commonProductDetailTemplate.deleteMany();
await prisma.commonStoreCategory.deleteMany(); await prisma.commonStoreCategory.deleteMany();
await prisma.commonPromoCode.deleteMany(); await prisma.commonPromoCode.deleteMany();
await prisma.commonResource.deleteMany(); await prisma.commonResource.deleteMany();
await prisma.hqAccount.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({ const city = await prisma.commonCity.create({
data: { data: {
code: '410100', code: '410100',
name: '郑州市', name: '郑州市',
province: '河南省', province: '河南省',
status: 'ACTIVE', status: 'ACTIVE',
partnerId: partner.id,
localMinQty: 2, localMinQty: 2,
crossMinQty: 6, crossMinQty: 6,
}, },
}); });
await prisma.commonCityCommissionRule.create({
const primaryAccount = await prisma.partnerAccount.create({
data: { data: {
phone: '13700000001',
name: '郑州合伙人主账号',
isPrimary: 1,
staffRole: 'PARTNER',
status: 'ACTIVE',
cityId: city.id, cityId: city.id,
scopeType: 'CITY_WIDE',
orderCommissionRate: 0, orderCommissionRate: 0,
redeemCommissionRate: 0.03, 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([ const categories = await Promise.all([
prisma.commonStoreCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }), prisma.commonStoreCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }),
prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }), prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }),
]); ]);
for (const tpl of DEFAULT_PRODUCT_DETAIL_TEMPLATES) { for (const tpl of DEFAULT_PRODUCT_DETAIL_TEMPLATES) {
await prisma.commonProductDetailTemplate.create({ await prisma.commonProductDetailTemplate.create({
data: { data: {
code: tpl.code, code: tpl.code,
name: tpl.name, name: tpl.name,
description: tpl.description, description: tpl.description,
aromaType: tpl.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG' | null, aromaType: tpl.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG' | null,
storyTitle: tpl.storyTitle, storyTitle: tpl.storyTitle,
storyText: tpl.storyText, storyText: tpl.storyText,
features: tpl.features, features: tpl.features,
detailImageUrls: [], detailImageUrls: [],
suggestedDetailImageCount: tpl.suggestedDetailImageCount, suggestedDetailImageCount: tpl.suggestedDetailImageCount,
sortOrder: tpl.sortOrder, sortOrder: tpl.sortOrder,
status: 'ACTIVE', status: 'ACTIVE',
}, },
}); });
} }
const productDefs = [ const productDefs = [
{ skuCode: 'JZ-10', name: '酒祖杜康(国标特级10', subtitle: '清香型 53度', price: 128, sortOrder: 1, img: 'https://picsum.photos/seed/jiuzu10/400/400' }, { 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-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-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' }, { skuCode: 'JZ-30', name: '酒祖杜康(国标特级30', subtitle: '清香型 53度', price: 498, sortOrder: 4, img: 'https://picsum.photos/seed/jiuzu30/400/400' },
]; ];
const products = []; const products = [];
for (const [i, def] of productDefs.entries()) { for (const [i, def] of productDefs.entries()) {
const product = await prisma.commonProductItem.create({ const product = await prisma.commonProductItem.create({
data: { data: {
skuCode: def.skuCode, skuCode: def.skuCode,
barcode69: `69000000000${i + 1}`, barcode69: `69000000000${i + 1}`,
name: def.name, name: def.name,
subtitle: def.subtitle, subtitle: def.subtitle,
aromaType: 'QINGXIANG', aromaType: 'QINGXIANG',
spec: '500ml | 53度', spec: '500ml | 53度',
price: def.price, price: def.price,
benefitAmount: def.price, benefitAmount: def.price,
status: 'ON_SALE', status: 'ON_SALE',
sortOrder: def.sortOrder, sortOrder: def.sortOrder,
}, },
}); });
const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img); const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img);
await prisma.commonProductItem.update({ await prisma.commonProductItem.update({
where: { id: product.id }, where: { id: product.id },
data: { coverResourceId: cover.id }, data: { coverResourceId: cover.id },
}); });
products.push(product); 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 = [ const storeDefs = [
{ {
name: '郑州老城店', name: '郑州老城店',
phone: '13910000001', phone: '13910000001',
district: '金水区', district: '金水区',
address: '花园路100号', address: '花园路100号',
intro: '正宗河南菜,欢迎核销好客权益', intro: '正宗河南菜,欢迎核销好客权益',
img: 'https://picsum.photos/seed/store1/400/300', img: 'https://picsum.photos/seed/store1/400/300',
categoryId: categories[0].id, categoryId: categories[0].id,
settlementRate: 0.6,
withAccount: true, withAccount: true,
}, },
{ {
name: '郑州美食城店', name: '郑州美食城店',
phone: '13910000002', phone: '13910000002',
district: '二七区', district: '二七区',
address: '大学路200号', address: '大学路200号',
intro: '地方特色餐饮', intro: '地方特色餐饮',
img: 'https://picsum.photos/seed/store2/400/300', img: 'https://picsum.photos/seed/store2/400/300',
categoryId: categories[1].id, categoryId: categories[1].id,
settlementRate: 0.65,
withAccount: false, withAccount: false,
}, },
]; ];
const createdStores: { id: bigint; name: string }[] = []; const createdStores: { id: bigint; name: string }[] = [];
for (const def of storeDefs) { for (const def of storeDefs) {
const store = await prisma.store.create({ const store = await prisma.store.create({
data: { data: {
cityId: city.id, cityId: city.id,
partnerId: partner.id,
partnerAccountId: primaryAccount.id,
categoryId: def.categoryId, categoryId: def.categoryId,
name: def.name, name: def.name,
phone: def.phone, phone: def.phone,
province: '河南省', province: '河南省',
cityName: '郑州市', cityName: '郑州市',
district: def.district, district: def.district,
address: def.address, address: def.address,
intro: def.intro, intro: def.intro,
settlementRate: def.settlementRate,
status: 'OPEN', status: 'OPEN',
openTime: '10:00', openTime: '10:00',
closeTime: '22:00', closeTime: '22:00',
bankAccountName: def.name, bankAccountName: def.name,
bankAccountNo: '6222029876543210', bankAccountNo: '6222029876543210',
bankBranch: '建设银行郑州分行', bankBranch: '建设银行郑州分行',
}, },
}); });
const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img); const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img);
await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } }); await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
if (def.withAccount) { if (def.withAccount) {
await prisma.storeAccount.create({ await prisma.storeAccount.create({
data: { storeId: store.id, phone: def.phone, name: def.name }, data: { storeId: store.id, phone: def.phone, name: def.name },
}); });
} }
createdStores.push({ id: store.id, name: def.name }); createdStores.push({ id: store.id, name: def.name });
} }
const weekStart = (() => { const weekStart = (() => {
const d = new Date(); const d = new Date();
d.setHours(12, 0, 0, 0); d.setHours(12, 0, 0, 0);
const day = d.getDay(); const day = d.getDay();
const diff = day === 0 ? 6 : day - 1; const diff = day === 0 ? 6 : day - 1;
d.setDate(d.getDate() - diff); d.setDate(d.getDate() - diff);
d.setHours(10, 0, 0, 0); d.setHours(10, 0, 0, 0);
return d; return d;
})(); })();
const newWeekStore = await prisma.store.create({ const newWeekStore = await prisma.store.create({
data: { data: {
cityId: city.id, cityId: city.id,
partnerId: partner.id,
partnerAccountId: primaryAccount.id,
categoryId: categories[0].id, categoryId: categories[0].id,
name: '本周新签体验店', name: '本周新签体验店',
phone: '13910000003', phone: '13910000003',
province: '河南省', province: '河南省',
cityName: '郑州市', cityName: '郑州市',
district: '中原区', district: '中原区',
address: '建设路88号', address: '建设路88号',
intro: '本周新签约门店', intro: '本周新签约门店',
settlementRate: 0.6,
status: 'OPEN', status: 'OPEN',
openTime: '10:00', openTime: '10:00',
closeTime: '22:00', closeTime: '22:00',
bankAccountName: '本周新签体验店', bankAccountName: '本周新签体验店',
bankAccountNo: '6222029876543211', bankAccountNo: '6222029876543211',
bankBranch: '农业银行郑州分行', bankBranch: '农业银行郑州分行',
createdAt: new Date(weekStart.getTime() + 2 * 24 * 60 * 60 * 1000), createdAt: new Date(weekStart.getTime() + 2 * 24 * 60 * 60 * 1000),
}, },
}); });
createdStores.push({ id: newWeekStore.id, name: newWeekStore.name }); createdStores.push({ id: newWeekStore.id, name: newWeekStore.name });
await prisma.user.create({ await prisma.user.create({
data: { data: {
userNo: 'DK88293401', userNo: 'DK88293401',
phone: '13800000001', phone: '13800000001',
phoneVerifiedAt: new Date(), phoneVerifiedAt: new Date(),
nickname: '测试用户', nickname: '测试用户',
cityPreference: { cityPreference: {
create: { create: {
selectedCityCode: '410100', selectedCityCode: '410100',
selectedDistrict: '郑州市', selectedDistrict: '郑州市',
locateCityCode: '410100', locateCityCode: '410100',
locateDistrict: '金水区', locateDistrict: '金水区',
}, },
}, },
}, },
}); });
await prisma.hqAccount.create({ await prisma.hqAccount.create({
data: { data: {
phone: '13600000001', phone: '13600000001',
loginName: 'admin',
passwordHash: hashPassword('dukang@123!'),
name: '总部管理员', name: '总部管理员',
adminRole: 'SUPER_ADMIN', adminRole: 'SUPER_ADMIN',
}, },
}); });
await prisma.commonPromoCode.create({ await prisma.commonPromoCode.create({
data: { data: {
code: 'DKHQ001', code: 'DKHQ001',
name: '总部品鉴会', name: '总部品鉴会',
status: 'ACTIVE', status: 'ACTIVE',
}, },
}); });
await prisma.commonPromoCode.create({ await prisma.commonPromoCode.create({
data: { data: {
code: 'DKDEMO1', code: 'DKDEMO1',
name: '郑州品鉴会演示', name: '郑州品鉴会演示',
status: 'ACTIVE', status: 'ACTIVE',
}, },
}); });
const user = await prisma.user.findUniqueOrThrow({ where: { phone: '13800000001' } }); const user = await prisma.user.findUniqueOrThrow({ where: { phone: '13800000001' } });
const product = products[0]; const product = products[0];
const orderDefs = [ const orderDefs = [
{ dayOffset: 0, payAmount: 1198, quantity: 2 }, { dayOffset: 0, payAmount: 1198, quantity: 2 },
{ dayOffset: 1, payAmount: 599, quantity: 1 }, { dayOffset: 1, payAmount: 599, quantity: 1 },
{ dayOffset: 2, payAmount: 1760, quantity: 2 }, { dayOffset: 2, payAmount: 1760, quantity: 2 },
{ dayOffset: 4, payAmount: 1299, quantity: 1 }, { dayOffset: 4, payAmount: 1299, quantity: 1 },
{ dayOffset: 5, payAmount: 880, quantity: 1 }, { dayOffset: 5, payAmount: 880, quantity: 1 },
]; ];
const coupons: { id: bigint; balance: number }[] = []; const coupons: { id: bigint; balance: number }[] = [];
for (const [index, def] of orderDefs.entries()) { for (const [index, def] of orderDefs.entries()) {
const paidAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 14 * 60 * 60 * 1000); const paidAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 14 * 60 * 60 * 1000);
const listAmount = def.payAmount; const listAmount = def.payAmount;
const order = await prisma.order.create({ const order = await prisma.order.create({
data: { data: {
orderNo: `WK${Date.now()}${index}`, orderNo: `WK${Date.now()}${index}`,
userId: user.id, userId: user.id,
cityId: city.id, cityId: city.id,
status: 'COMPLETED', status: 'COMPLETED',
payStatus: 'PAID', payStatus: 'PAID',
deliveryType: 'LOCAL', deliveryType: 'LOCAL',
productId: product.id, productId: product.id,
barcode69: product.barcode69, barcode69: product.barcode69,
productName: product.name, productName: product.name,
productSpec: product.spec, productSpec: product.spec,
quantity: def.quantity, quantity: def.quantity,
listUnitPrice: product.price, listUnitPrice: product.price,
listAmount, listAmount,
productAmount: listAmount, productAmount: listAmount,
payAmount: listAmount, payAmount: listAmount,
benefitAmount: listAmount, benefitAmount: listAmount,
receiverName: '测试用户', receiverName: '测试用户',
receiverPhone: user.phone,
receiverPhone: user.phone!,
receiverAddress: '郑州市金水区测试路1号', receiverAddress: '郑州市金水区测试路1号',
receiverProvince: '河南省', receiverProvince: '河南省',
receiverCity: '郑州市', receiverCity: '郑州市',
receiverDistrict: '金水区', receiverDistrict: '金水区',
paidAt, paidAt,
completedAt: paidAt, completedAt: paidAt,
partnerAccountIdAtPay: primaryAccount.id,
orderCommissionRateAtPay: 0,
}, },
}); });
const coupon = await prisma.benefitCoupon.create({ const coupon = await prisma.benefitCoupon.create({
data: { data: {
couponNo: `CPN${Date.now()}${index}`, couponNo: `CPN${Date.now()}${index}`,
userId: user.id, userId: user.id,
orderId: order.id, orderId: order.id,
totalAmount: listAmount, totalAmount: listAmount,
balance: listAmount, balance: listAmount,
sourceProduct: product.name, sourceProduct: product.name,
}, },
}); });
coupons.push({ id: coupon.id, balance: listAmount }); coupons.push({ id: coupon.id, balance: listAmount });
} }
const redeemDefs = [ const redeemDefs = [
{ storeIndex: 0, amount: 42800, dayOffset: 1 },
{ storeIndex: 1, amount: 38400, dayOffset: 2 }, { storeIndex: 0, amount: 42800, dayOffset: 1, settlementRate: 0.6 },
{ storeIndex: 2, amount: 31200, dayOffset: 3 },
{ 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()) { for (const [index, def] of redeemDefs.entries()) {
const coupon = coupons[index]; const coupon = coupons[index];
const store = createdStores[def.storeIndex]; const store = createdStores[def.storeIndex];
const createdAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 16 * 60 * 60 * 1000); 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({ await prisma.redeemRecord.create({
data: { data: {
redeemNo: `RD${Date.now()}${index}`, redeemNo: `RD${Date.now()}${index}`,
userId: user.id, userId: user.id,
couponId: coupon.id, couponId: coupon.id,
storeId: store.id, storeId: store.id,
amount: def.amount, amount: def.amount,
settleAmount: Math.round(def.amount * 0.6 * 100) / 100,
settleAmount,
createdAt, createdAt,
}, },
}); });
await prisma.benefitCoupon.update({ await prisma.benefitCoupon.update({
where: { id: coupon.id }, where: { id: coupon.id },
data: { data: {
usedAmount: def.amount, usedAmount: def.amount,
balance: Math.max(0, coupon.balance - def.amount), balance: Math.max(0, coupon.balance - def.amount),
}, },
}); });
} }
const now = new Date(); const now = new Date();
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1); const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0); const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
await prisma.partnerBill.create({ await prisma.partnerBill.create({
data: { data: {
billNo: `PB${Date.now()}`, billNo: `PB${Date.now()}`,
partnerId: partner.id,
partnerAccountId: primaryAccount.id,
periodStart, periodStart,
periodEnd, periodEnd,
orderCommission: 1200, orderCommission: 1200,
redeemCommission: 800, redeemCommission: 800,
totalAmount: 2000, totalAmount: 2000,
status: 'CONFIRMED', status: 'CONFIRMED',
confirmedAt: now, confirmedAt: now,
}, },
}); });
console.log('Seed complete:', { console.log('Seed complete:', {
city: city.name, city: city.name,
warehouse: warehouse.name,
primaryAccount: primaryAccount.phone,
detailTemplates: DEFAULT_PRODUCT_DETAIL_TEMPLATES.length, detailTemplates: DEFAULT_PRODUCT_DETAIL_TEMPLATES.length,
products: products.length, products: products.length,
stores: storeDefs.length,
stores: createdStores.length,
testPhones: { testPhones: {
user: '13800000001', user: '13800000001',
partner: '13700000001', partner: '13700000001',
partnerStaff: '13700000002',
hq: '13600000001', hq: '13600000001',
}, },
}); });
} }
main() main()
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
process.exit(1); process.exit(1);
}) })
.finally(async () => { .finally(async () => {
await prisma.$disconnect(); 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 { AnalyticsModule } from './modules/analytics/analytics.module';
import { JobsModule } from './jobs/jobs.module'; import { JobsModule } from './jobs/jobs.module';
import { OpsModule } from './modules/ops/ops.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 { CommonModule } from './modules/common/common.module';
import { HqOperationModule } from './common/hq-operation/hq-operation.module'; import { HqOperationModule } from './common/hq-operation/hq-operation.module';
import { CallbacksModule } from './callbacks/callbacks.module'; import { CallbacksModule } from './callbacks/callbacks.module';
@@ -41,6 +42,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
AnalyticsModule, AnalyticsModule,
JobsModule, JobsModule,
OpsModule, OpsModule,
CityScopeModule,
CommonModule, CommonModule,
HqOperationModule, HqOperationModule,
CallbacksModule, CallbacksModule,
@@ -2,6 +2,13 @@
export const HqOperationAction = { export const HqOperationAction = {
CITY_CREATE: 'CITY_CREATE', CITY_CREATE: 'CITY_CREATE',
CITY_UPDATE: 'CITY_UPDATE', 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_CREATE: 'PARTNER_CREATE',
PARTNER_UPDATE: 'PARTNER_UPDATE', PARTNER_UPDATE: 'PARTNER_UPDATE',
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE', 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> = { export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
[HqOperationAction.CITY_CREATE]: '新增开城城市', [HqOperationAction.CITY_CREATE]: '新增开城城市',
[HqOperationAction.CITY_UPDATE]: '编辑开城城市', [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_CREATE]: '新增城市合伙人',
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人', [HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户', [HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
@@ -17,8 +17,8 @@ export type TrackStoreEventInput = TrackEventInput & {
}; };
export type TrackPartnerEventInput = TrackEventInput & { export type TrackPartnerEventInput = TrackEventInput & {
partnerAccountId?: bigint; /** 主账号 ID,用于合伙人维度聚合 */
partnerId: bigint; partnerAccountId: bigint;
}; };
@Injectable() @Injectable()
@@ -64,21 +64,21 @@ export class AnalyticsService {
} }
async trackPartnerOne( async trackPartnerOne(
partnerAccountId: bigint | undefined, actorAccountId: bigint | undefined,
clientApp: ClientApp | string, clientApp: ClientApp | string,
event: TrackPartnerEventInput, event: TrackPartnerEventInput,
) { ) {
await this.prisma.logPartnerAnalytics.create({ await this.prisma.logPartnerAnalytics.create({
data: this.toPartnerRow(partnerAccountId, clientApp, event), data: this.toPartnerRow(actorAccountId, clientApp, event),
}); });
} }
trackPartnerOneSafe( trackPartnerOneSafe(
partnerAccountId: bigint | undefined, actorAccountId: bigint | undefined,
clientApp: ClientApp | string, clientApp: ClientApp | string,
event: TrackPartnerEventInput, 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) { private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
@@ -110,17 +110,16 @@ export class AnalyticsService {
} }
private toPartnerRow( private toPartnerRow(
partnerAccountId: bigint | undefined, actorAccountId: bigint | undefined,
clientApp: ClientApp | string, clientApp: ClientApp | string,
event: TrackPartnerEventInput, event: TrackPartnerEventInput,
) { ) {
return { return {
partnerAccountId, partnerAccountId: event.partnerAccountId,
partnerId: event.partnerId,
eventName: event.eventName, eventName: event.eventName,
clientApp: clientApp as ClientApp, clientApp: clientApp as ClientApp,
refType: event.refType, refType: event.refType,
refId: event.refId, refId: event.refId ?? actorAccountId,
extraJson: event.extraJson as never, extraJson: event.extraJson as never,
}; };
} }
@@ -10,10 +10,28 @@ export class CatalogService {
async listCities() { async listCities() {
const cities = await this.prisma.commonCity.findMany({ const cities = await this.prisma.commonCity.findMany({
where: { status: 'ACTIVE' }, 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' }, 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) { 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( private trackPartnerEvent(
partnerAccountId: bigint | undefined, actorAccountId: bigint | undefined,
partnerId: bigint, primaryAccountId: bigint,
clientApp: ClientApp | string, clientApp: ClientApp | string,
eventName: string, eventName: string,
extraJson?: Record<string, unknown>, extraJson?: Record<string, unknown>,
ref?: { refType?: string; refId?: bigint }, ref?: { refType?: string; refId?: bigint },
) { ) {
this.analyticsService.trackPartnerOneSafe(partnerAccountId, clientApp, { this.analyticsService.trackPartnerOneSafe(actorAccountId, clientApp, {
partnerId, partnerAccountId: primaryAccountId,
eventName, eventName,
refType: ref?.refType, refType: ref?.refType,
refId: ref?.refId, 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) { private async assertPartnerAccountByPhone(phone: string) {
const account = await this.prisma.partnerAccount.findUnique({ const account = await this.prisma.partnerAccount.findUnique({
where: { phone }, where: { phone },
include: { partner: true },
}); });
if (!account) throw new BadRequestException('未找到合伙人账号'); if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用'); if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
@@ -197,11 +229,12 @@ export class AuthService {
async checkPartnerPhone(phone: string) { async checkPartnerPhone(phone: string) {
const normalizedPhone = this.assertMobilePhone(phone); const normalizedPhone = this.assertMobilePhone(phone);
const account = await this.assertPartnerAccountByPhone(normalizedPhone); const account = await this.assertPartnerAccountByPhone(normalizedPhone);
const primary = await this.resolvePrimaryAccount(account.id);
return { return {
ok: true, ok: true,
maskedPhone: this.maskPhone(normalizedPhone), maskedPhone: this.maskPhone(normalizedPhone),
name: account.name, name: account.name,
companyName: account.partner.companyName, companyName: primary.companyName,
}; };
} }
@@ -306,12 +339,13 @@ export class AuthService {
) { ) {
const partnerAccount = await this.prisma.partnerAccount.findUnique({ const partnerAccount = await this.prisma.partnerAccount.findUnique({
where: { id: actorRef.refId }, where: { id: actorRef.refId },
select: { id: true, partnerId: true }, select: { id: true, isPrimary: true, parentAccountId: true },
}); });
if (partnerAccount) { if (partnerAccount) {
const primary = await this.resolvePrimaryAccount(partnerAccount.id);
this.trackPartnerEvent( this.trackPartnerEvent(
partnerAccount.id, partnerAccount.id,
partnerAccount.partnerId, primary.id,
clientApp, clientApp,
'partner_sms_send', 'partner_sms_send',
{ {
@@ -415,20 +449,12 @@ export class AuthService {
private async buildPartnerSessionResponse(accountId: bigint, clientApp: ClientApp) { private async buildPartnerSessionResponse(accountId: bigint, clientApp: ClientApp) {
const account = await this.prisma.partnerAccount.findUnique({ const account = await this.prisma.partnerAccount.findUnique({
where: { id: accountId }, where: { id: accountId },
include: { partner: true },
}); });
if (!account || account.status !== 'ACTIVE') { if (!account || account.status !== 'ACTIVE') {
throw new UnauthorizedException('Invalid refresh token'); throw new UnauthorizedException('Invalid refresh token');
} }
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, { const primary = await this.resolvePrimaryAccount(account.id);
id: account.id.toString(), return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
partnerId: account.partnerId.toString(),
name: account.name,
phone: account.phone,
isPrimary: account.isPrimary === 1,
staffRole: account.staffRole ?? undefined,
companyName: account.partner.companyName,
});
} }
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) { async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
@@ -601,7 +627,8 @@ export class AuthService {
} catch (err) { } catch (err) {
const account = await this.prisma.partnerAccount.findUnique({ where: { phone: normalizedPhone } }); const account = await this.prisma.partnerAccount.findUnique({ where: { phone: normalizedPhone } });
if (account) { 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), phone: this.maskPhone(normalizedPhone),
reason: err instanceof BadRequestException ? err.message : '验证码错误', reason: err instanceof BadRequestException ? err.message : '验证码错误',
}); });
@@ -610,29 +637,21 @@ export class AuthService {
} }
const account = await this.prisma.partnerAccount.findUnique({ const account = await this.prisma.partnerAccount.findUnique({
where: { phone: normalizedPhone }, where: { phone: normalizedPhone },
include: { partner: true },
}); });
if (!account) throw new BadRequestException('未找到合伙人账号'); if (!account) throw new BadRequestException('未找到合伙人账号');
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用'); if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
const primary = await this.resolvePrimaryAccount(account.id);
await this.prisma.partnerAccount.update({ await this.prisma.partnerAccount.update({
where: { id: account.id }, where: { id: account.id },
data: { lastLoginAt: new Date() }, 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), 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', method: 'sms',
}); });
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, { return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
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,
});
} }
async loginHq(phone: string, code: string, clientApp: ClientApp) { async loginHq(phone: string, code: string, clientApp: ClientApp) {
@@ -734,9 +753,14 @@ export class AuthService {
if (actorType === 'PARTNER') { if (actorType === 'PARTNER') {
const account = await this.prisma.partnerAccount.findUnique({ const account = await this.prisma.partnerAccount.findUnique({
where: { id: actorId }, 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') { if (actorType === 'HQ') {
const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } }); const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } });
@@ -1102,7 +1126,6 @@ export class AuthService {
const account = await this.prisma.partnerAccount.findUnique({ const account = await this.prisma.partnerAccount.findUnique({
where: { id: partnerAccountId }, where: { id: partnerAccountId },
include: { partner: true },
}); });
if (!account) throw new BadRequestException('合伙人账号不存在'); if (!account) throw new BadRequestException('合伙人账号不存在');
@@ -1120,19 +1143,12 @@ export class AuthService {
wxUnionId: session.unionId ?? account.wxUnionId, wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(), 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, { return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(updated, primary));
id: updated.id.toString(),
partnerId: updated.partnerId.toString(),
name: updated.name,
phone: updated.phone,
isPrimary: updated.isPrimary === 1,
companyName: updated.partner.companyName,
});
} }
async loginPartnerWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') { async loginPartnerWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
@@ -1144,7 +1160,6 @@ export class AuthService {
let account = await this.prisma.partnerAccount.findFirst({ let account = await this.prisma.partnerAccount.findFirst({
where: { wxOpenId: session.openId }, where: { wxOpenId: session.openId },
include: { partner: true },
}); });
if (!account && this.wechatProvider.isMock()) { if (!account && this.wechatProvider.isMock()) {
@@ -1152,7 +1167,6 @@ export class AuthService {
account = await this.prisma.partnerAccount.findFirst({ account = await this.prisma.partnerAccount.findFirst({
where: { status: 'ACTIVE' }, where: { status: 'ACTIVE' },
orderBy: [{ isPrimary: 'desc' }, { id: 'asc' }], orderBy: [{ isPrimary: 'desc' }, { id: 'asc' }],
include: { partner: true },
}); });
} }
@@ -1167,23 +1181,15 @@ export class AuthService {
wxUnionId: session.unionId ?? account.wxUnionId, wxUnionId: session.unionId ?? account.wxUnionId,
lastLoginAt: new Date(), 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, primary.id, clientApp, 'partner_wechat_login', { platform });
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_login_success', { this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_login_success', {
method: 'wechat', method: 'wechat',
}); });
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, { return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
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,
});
} }
private async mergeUsers(guestId: bigint, primaryId: bigint): Promise<UserRow> { 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'; import { AccountStatus, PartnerStaffRole } from '@dukang/shared-types';
export class CreatePartnerStaffDto { export class CreatePartnerStaffDto {
@@ -15,6 +15,11 @@ export class CreatePartnerStaffDto {
@IsString() @IsString()
@IsIn(Object.values(PartnerStaffRole)) @IsIn(Object.values(PartnerStaffRole))
staffRole?: string; staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
} }
export class UpdatePartnerStaffDto { export class UpdatePartnerStaffDto {
@@ -27,6 +32,11 @@ export class UpdatePartnerStaffDto {
@IsOptional() @IsOptional()
staffRole?: string; staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
@IsString() @IsString()
@IsIn(Object.values(AccountStatus)) @IsIn(Object.values(AccountStatus))
@IsOptional() @IsOptional()
@@ -17,7 +17,7 @@ export class PartnerStaffController {
@Post() @Post()
create(@CurrentUser() user: AuthUser, @Body() dto: CreatePartnerStaffDto) { create(@CurrentUser() user: AuthUser, @Body() dto: CreatePartnerStaffDto) {
return this.staffService.createStaff(user.actorId, dto); return this.staffService.createStaff(user, dto);
} }
@Put(':id') @Put(':id')
@@ -26,11 +26,11 @@ export class PartnerStaffController {
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdatePartnerStaffDto, @Body() dto: UpdatePartnerStaffDto,
) { ) {
return this.staffService.updateStaff(user.actorId, BigInt(id), dto); return this.staffService.updateStaff(user, BigInt(id), dto);
} }
@Delete(':id') @Delete(':id')
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) { remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
return this.staffService.deleteStaff(user.actorId, BigInt(id)); return this.staffService.deleteStaff(user, BigInt(id));
} }
} }
@@ -6,11 +6,16 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common';
import { PartnerStaffRole } from '@dukang/shared-types'; import { PartnerStaffRole } from '@dukang/shared-types';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
constructor(private readonly prisma: PrismaService) {} import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
import { AnalyticsService } from '../analytics/analytics.service'; import { AnalyticsService } from '../analytics/analytics.service';
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto'; import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
@@ -20,7 +25,8 @@ export class PartnerStaffService {
@Injectable() @Injectable()
export class PartnerStaffService { export class PartnerStaffService {
async createStaff(parentAccountId: bigint, dto: CreatePartnerStaffDto) {
constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
@@ -39,23 +45,38 @@ export class PartnerStaffService {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
return rows.map((row) => this.toStaffItem(row));
} }
partnerId: parent.partnerId,
staffRole: (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL,
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
const parentAccountId = actor.actorId; const parentAccountId = actor.actorId;
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({ const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: parentAccountId }, where: { id: parentAccountId },
});
if (parent.isPrimary !== 1) {
throw new BadRequestException('仅主账号可添加子账号');
} }
async updateStaff(parentAccountId: bigint, staffId: bigint, dto: UpdatePartnerStaffDto) {
const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号码');
}
@@ -65,6 +86,9 @@ export class PartnerStaffService {
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名');
@@ -72,15 +96,57 @@ export class PartnerStaffService {
const account = await this.prisma.partnerAccount.create({
data: {
phone,
name,
staffRole,
permissions: dto.permissions ?? undefined,
isPrimary: 0,
parentAccountId: parent.id,
status: 'DISABLED',
}, },
async deleteStaff(parentAccountId: bigint, staffId: bigint) { });
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
name,
phone: this.maskPhone(phone),
staffRole,
status: account.status, 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, name: staff.name,
@@ -94,6 +160,7 @@ export class PartnerStaffService {
if (dto.name !== undefined) { if (dto.name !== undefined) {
const name = dto.name.trim();
if (!name) throw new BadRequestException('请填写真实姓名'); if (!name) throw new BadRequestException('请填写真实姓名');
@@ -102,6 +169,7 @@ export class PartnerStaffService {
} }
if (dto.staffRole !== undefined) { if (dto.staffRole !== undefined) {
data.staffRole = dto.staffRole as PartnerStaffRole; data.staffRole = dto.staffRole as PartnerStaffRole;
} }
@@ -1,13 +1,18 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from '@dukang/domain';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type { AdminCitiesQueryDto } from './dto/admin-query.dto'; import type { AdminCitiesQueryDto } from './dto/admin-query.dto';
import type { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto'; import type { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
@Injectable() @Injectable()
export class AdminCitiesService { export class AdminCitiesService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async list(query: AdminCitiesQueryDto) { async list(query: AdminCitiesQueryDto) {
const page = query.page ?? 1; const page = query.page ?? 1;
@@ -16,7 +21,9 @@ export class AdminCitiesService {
if (query.name) where.name = { contains: query.name }; if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code }; if (query.code) where.code = { contains: query.code };
if (query.status) where.status = query.status as Prisma.EnumCityStatusFilter['equals']; 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([ const [items, total] = await Promise.all([
this.prisma.commonCity.findMany({ this.prisma.commonCity.findMany({
@@ -25,8 +32,12 @@ export class AdminCitiesService {
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
include: { include: {
partner: { select: { id: true, companyName: true } }, partnerAccounts: {
_count: { select: { stores: true, orders: true } }, 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 }), this.prisma.commonCity.count({ where }),
@@ -34,8 +45,18 @@ export class AdminCitiesService {
return serializeBigInt({ return serializeBigInt({
items: items.map((c) => ({ items: items.map((c) => ({
...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, storeCount: c._count.stores,
orderCount: c._count.orders, orderCount: c._count.orders,
partnerBindingCount: c.partnerAccounts.length,
warehouseCount: c._count.warehouses,
_count: undefined, _count: undefined,
})), })),
total, total,
@@ -48,13 +69,22 @@ export class AdminCitiesService {
const city = await this.prisma.commonCity.findUnique({ const city = await this.prisma.commonCity.findUnique({
where: { id }, where: { id },
include: { include: {
partner: true, warehouses: {
commissionRule: true, include: { partnerAccount: { select: { id: true, companyName: true } } },
orderBy: { createdAt: 'desc' },
},
_count: { select: { stores: true, orders: true } }, _count: { select: { stores: true, orders: true } },
}, },
}); });
if (!city) throw new NotFoundException('开城城市不存在'); 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) { async create(dto: CreateCityDto) {
@@ -65,31 +95,48 @@ export class AdminCitiesService {
code: dto.code, code: dto.code,
name: dto.name, name: dto.name,
province: dto.province, province: dto.province,
partnerId: dto.partnerId ? BigInt(dto.partnerId) : null,
status: (dto.status ?? 'PENDING') as 'PENDING' | 'ACTIVE' | 'PAUSED', status: (dto.status ?? 'PENDING') as 'PENDING' | 'ACTIVE' | 'PAUSED',
commissionRule: {
create: {
orderCommissionRate: 0,
redeemCommissionRate: 0.03,
},
},
}, },
}); });
return serializeBigInt(city); return serializeBigInt(city);
} }
async update(id: bigint, dto: UpdateCityDto) { 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({ const city = await this.prisma.commonCity.update({
where: { id }, where: { id },
data: { data: {
...(dto.name !== undefined ? { name: dto.name } : {}), ...(dto.name !== undefined ? { name: dto.name } : {}),
...(dto.province !== undefined ? { province: dto.province } : {}), ...(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.status !== undefined ? { status: dto.status as 'PENDING' | 'ACTIVE' | 'PAUSED' } : {}),
...(dto.localMinQty !== undefined ? { localMinQty: dto.localMinQty } : {}), ...(dto.localMinQty !== undefined ? { localMinQty: dto.localMinQty } : {}),
...(dto.crossMinQty !== undefined ? { crossMinQty: dto.crossMinQty } : {}), ...(dto.crossMinQty !== undefined ? { crossMinQty: dto.crossMinQty } : {}),
...(dto.maxPartnerCommissionRate !== undefined
? { maxPartnerCommissionRate: dto.maxPartnerCommissionRate }
: {}),
}, },
}); });
return serializeBigInt(city); 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 }, _count: { status: true },
}), }),
this.prisma.store.count(), 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.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
this.prisma.orderDelivery.count(), this.prisma.orderDelivery.count(),
this.prisma.storePayout.count({ where: { status: 'PENDING' } }), this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
@@ -15,12 +15,12 @@ export class AdminPartnerLogsService {
async list(query: AdminPartnerLogsQueryDto) { async list(query: AdminPartnerLogsQueryDto) {
const page = query.page ?? 1; const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20; const pageSize = query.pageSize ?? 20;
const partnerIds = await this.resolvePartnerIds(query); const partnerAccountIds = await this.resolvePartnerAccountIds(query);
if (partnerIds && partnerIds.length === 0) { if (partnerAccountIds && partnerAccountIds.length === 0) {
return { items: [], total: 0, page, pageSize }; return { items: [], total: 0, page, pageSize };
} }
const where = this.buildWhere(query, partnerIds); const where = this.buildWhere(query, partnerAccountIds);
const [rows, total] = await Promise.all([ const [rows, total] = await Promise.all([
this.prisma.logPartnerAnalytics.findMany({ this.prisma.logPartnerAnalytics.findMany({
where, where,
@@ -44,10 +44,10 @@ export class AdminPartnerLogsService {
private buildWhere( private buildWhere(
query: AdminPartnerLogsQueryDto, query: AdminPartnerLogsQueryDto,
partnerIds?: bigint[], partnerAccountIds?: bigint[],
): Prisma.LogPartnerAnalyticsWhereInput { ): Prisma.LogPartnerAnalyticsWhereInput {
const where: 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); if (query.partnerAccountId) where.partnerAccountId = BigInt(query.partnerAccountId);
const categoryEvents = query.eventName const categoryEvents = query.eventName
? [query.eventName] ? [query.eventName]
@@ -64,40 +64,23 @@ export class AdminPartnerLogsService {
return where; 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)]; if (query.partnerId) return [BigInt(query.partnerId)];
const partnerWhere: Prisma.PartnerWhereInput = {}; const accountWhere: Prisma.PartnerAccountWhereInput = { isPrimary: 1 };
if (query.companyName) partnerWhere.companyName = { contains: query.companyName }; if (query.companyName) accountWhere.companyName = { contains: query.companyName };
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.phone) accountWhere.phone = { contains: query.phone };
if (query.companyName || query.phone) {
const accounts = await this.prisma.partnerAccount.findMany({ const accounts = await this.prisma.partnerAccount.findMany({
where: accountWhere, 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 }, select: { id: true },
take: 100, take: 100,
}); });
return partners.map((p) => p.id); return accounts.map((a) => a.id);
} }
return undefined; return undefined;
@@ -106,8 +89,7 @@ export class AdminPartnerLogsService {
private async enrichRows( private async enrichRows(
rows: Array<{ rows: Array<{
id: bigint; id: bigint;
partnerAccountId: bigint | null; partnerAccountId: bigint;
partnerId: bigint;
eventName: string; eventName: string;
clientApp: string | null; clientApp: string | null;
refType: string | null; refType: string | null;
@@ -116,37 +98,25 @@ export class AdminPartnerLogsService {
createdAt: Date; createdAt: Date;
}>, }>,
) { ) {
const partnerIds = [...new Set(rows.map((r) => r.partnerId))]; const accountIds = [...new Set(rows.map((r) => r.partnerAccountId))];
const accountIds = [...new Set(rows.map((r) => r.partnerAccountId).filter((id): id is bigint => id != null))]; const accounts = accountIds.length
? await this.prisma.partnerAccount.findMany({
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 } }, where: { id: { in: accountIds } },
select: { id: true, name: true, phone: true }, select: { id: true, name: true, phone: true, companyName: 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)); const accountMap = new Map(accounts.map((a) => [a.id.toString(), a] as const));
return rows.map((row) => { return rows.map((row) => {
const partner = partnerMap.get(row.partnerId.toString()); const account = accountMap.get(row.partnerAccountId.toString());
const account = row.partnerAccountId ? accountMap.get(row.partnerAccountId.toString()) : undefined;
return { return {
id: row.id.toString(), id: row.id.toString(),
partnerId: row.partnerId.toString(), partnerId: row.partnerAccountId.toString(),
partnerAccountId: row.partnerAccountId?.toString() ?? null, partnerAccountId: row.partnerAccountId.toString(),
accountName: account?.name ?? null, accountName: account?.name ?? null,
accountPhone: account?.phone ?? null, accountPhone: account?.phone ?? null,
companyName: partner?.companyName ?? null, companyName: account?.companyName ?? null,
category: resolvePartnerLogCategory(row.eventName), category: resolvePartnerLogCategory(row.eventName),
eventName: row.eventName, eventName: row.eventName,
clientApp: row.clientApp, clientApp: row.clientApp,
@@ -1,7 +1,9 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; 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 { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto'; import type { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto';
import type { import type {
CreatePartnerAccountDto, CreatePartnerAccountDto,
@@ -10,77 +12,36 @@ import type {
UpdatePartnerDto, UpdatePartnerDto,
} from './dto/admin-mutate.dto'; } 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() @Injectable()
export class AdminPartnersService { export class AdminPartnersService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async listPartners(query: AdminPartnersQueryDto) { async listPartners(query: AdminPartnersQueryDto) {
const page = query.page ?? 1; const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20; 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.companyName) where.companyName = { contains: query.companyName };
if (query.contactPhone) where.contactPhone = { contains: query.contactPhone }; 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.phone) where.phone = { contains: query.phone };
if (query.partnerId) where.partnerId = BigInt(query.partnerId); if (query.cityId) where.cityId = BigInt(query.cityId);
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals']; if (query.partnerId) where.id = BigInt(query.partnerId);
if (query.isPrimary === '0' || query.isPrimary === '1') {
where.isPrimary = Number(query.isPrimary);
}
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
this.prisma.partnerAccount.findMany({ this.prisma.partnerAccount.findMany({
@@ -89,8 +50,257 @@ export class AdminPartnersService {
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
include: { include: {
partner: { select: { id: true, companyName: true } }, city: { select: { id: true, code: true, name: true, status: true, maxPartnerCommissionRate: true } },
parent: { select: { id: true, name: true, phone: 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 }), this.prisma.partnerAccount.count({ where }),
@@ -98,14 +308,14 @@ export class AdminPartnersService {
return serializeBigInt({ items, total, page, pageSize }); return serializeBigInt({ items, total, page, pageSize });
} }
async listPartnerAccountTree(partnerId?: bigint) { async listPartnerAccountTree(primaryAccountId?: bigint) {
const where: Prisma.PartnerAccountWhereInput = {}; const where: Prisma.PartnerAccountWhereInput = primaryAccountId
if (partnerId) where.partnerId = partnerId; ? { OR: [{ id: primaryAccountId }, { parentAccountId: primaryAccountId }] }
: { isPrimary: 1 };
const accounts = await this.prisma.partnerAccount.findMany({ const accounts = await this.prisma.partnerAccount.findMany({
where, where,
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }], orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
include: { partner: { select: { id: true, companyName: true } } },
}); });
type TreeNode = (typeof accounts)[number] & { children: TreeNode[] }; type TreeNode = (typeof accounts)[number] & { children: TreeNode[] };
@@ -122,7 +332,7 @@ export class AdminPartnersService {
const parent = nodeMap.get(account.parentAccountId.toString()); const parent = nodeMap.get(account.parentAccountId.toString());
if (parent) parent.children.push(node); if (parent) parent.children.push(node);
else roots.push(node); else roots.push(node);
} else { } else if (account.isPrimary === 1) {
roots.push(node); roots.push(node);
} }
} }
@@ -134,8 +344,9 @@ export class AdminPartnersService {
status: node.status, status: node.status,
isPrimary: node.isPrimary, isPrimary: node.isPrimary,
staffRole: node.staffRole, staffRole: node.staffRole,
permissions: node.permissions,
parentAccountId: node.parentAccountId, parentAccountId: node.parentAccountId,
partner: node.partner, companyName: node.companyName,
createdAt: node.createdAt, createdAt: node.createdAt,
lastLoginAt: node.lastLoginAt, lastLoginAt: node.lastLoginAt,
children: node.children.length ? node.children.map(mapNode) : undefined, children: node.children.length ? node.children.map(mapNode) : undefined,
@@ -148,27 +359,21 @@ export class AdminPartnersService {
const account = await this.prisma.partnerAccount.findUnique({ const account = await this.prisma.partnerAccount.findUnique({
where: { id }, where: { id },
include: { include: {
partner: { parent: { select: { id: true, name: true, phone: true, companyName: true } },
select: {
id: true,
companyName: true,
contactPhone: true,
address: true,
},
},
parent: { select: { id: true, name: true, phone: 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([ const [bills, orders] = await Promise.all([
this.prisma.partnerBill.findMany({ this.prisma.partnerBill.findMany({
where: { partnerId: account.partnerId }, where: { partnerAccountId: primary.id },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 50, take: 50,
}), }),
this.prisma.order.findMany({ this.prisma.order.findMany({
where: { city: { partnerId: account.partnerId } }, where: orderWhere,
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 50, take: 50,
select: { 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) { async createPartnerAccount(dto: CreatePartnerAccountDto) {
if (!dto.parentAccountId) {
throw new BadRequestException('请指定主账号 parentAccountId 创建子账号');
}
const phone = dto.phone.trim(); const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) { if (!/^1[3-9]\d{9}$/.test(phone)) {
throw new BadRequestException('请输入正确的手机号码'); throw new BadRequestException('请输入正确的手机号码');
@@ -193,53 +402,40 @@ export class AdminPartnersService {
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } }); const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
if (phoneTaken) throw new BadRequestException('该手机号已被使用'); if (phoneTaken) throw new BadRequestException('该手机号已被使用');
if (dto.parentAccountId) {
const parent = await this.prisma.partnerAccount.findUnique({ const parent = await this.prisma.partnerAccount.findUnique({
where: { id: BigInt(dto.parentAccountId) }, where: { id: BigInt(dto.parentAccountId) },
}); });
if (!parent) throw new BadRequestException('主账号不存在'); if (!parent) throw new BadRequestException('主账号不存在');
if (parent.isPrimary !== 1) throw new BadRequestException('仅可向主账号添加子账号'); if (parent.isPrimary !== 1) {
if (dto.partnerId && dto.partnerId !== parent.partnerId.toString()) { throw new BadRequestException('仅可向主账号添加子账号,不支持多级子账号');
throw new BadRequestException('开城合伙人与主账号不匹配');
} }
const account = await this.prisma.partnerAccount.create({ const account = await this.prisma.partnerAccount.create({
data: { data: {
partnerId: parent.partnerId,
phone, phone,
name: dto.name.trim(), name: dto.name.trim(),
staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER', staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER',
permissions: dto.permissions ?? undefined,
isPrimary: 0, isPrimary: 0,
parentAccountId: parent.id, parentAccountId: parent.id,
status: 'ACTIVE', status: 'ACTIVE',
}, },
include: { partner: { select: { id: true, companyName: true } } },
});
return serializeBigInt(account);
}
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,
isPrimary: 0,
},
include: { partner: { select: { id: true, companyName: true } } },
}); });
return serializeBigInt(account); return serializeBigInt(account);
} }
async updatePartnerAccount(id: bigint, dto: UpdatePartnerAccountDto) { async updatePartnerAccount(id: bigint, dto: UpdatePartnerAccountDto) {
const existing = await this.prisma.partnerAccount.findUnique({ where: { id } }); 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 = {}; const data: Prisma.PartnerAccountUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name; if (dto.name !== undefined) data.name = dto.name;
if (dto.status !== undefined) data.status = dto.status as 'ACTIVE' | 'DISABLED'; 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) { if (dto.phone !== undefined) {
const phone = dto.phone.trim(); const phone = dto.phone.trim();
if (!/^1[3-9]\d{9}$/.test(phone)) { if (!/^1[3-9]\d{9}$/.test(phone)) {
@@ -258,7 +454,7 @@ export class AdminPartnersService {
async deletePartnerSubAccount(id: bigint) { async deletePartnerSubAccount(id: bigint) {
const account = await this.prisma.partnerAccount.findUnique({ where: { id } }); const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
if (!account) throw new NotFoundException('开城合伙人账号不存在'); if (!account) throw new NotFoundException('合伙人账号不存在');
if (!account.parentAccountId) { if (!account.parentAccountId) {
throw new BadRequestException('仅可删除子账号'); throw new BadRequestException('仅可删除子账号');
} }
@@ -40,7 +40,7 @@ export class AdminRedeemService {
where: { id }, where: { id },
include: { include: {
user: true, user: true,
store: { include: { partner: { select: { id: true, companyName: true } } } }, store: { include: { partnerAccount: { select: { id: true, companyName: true } } } },
coupon: true, coupon: true,
payout: true, payout: true,
}, },
@@ -4,6 +4,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { mapStoreCompat } from '../../common/compat/v31-compat'; import { mapStoreCompat } from '../../common/compat/v31-compat';
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto'; import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type { import type {
CreateStoreAccountDto, CreateStoreAccountDto,
CreateStoreDto, CreateStoreDto,
@@ -16,7 +17,10 @@ import type {
@Injectable() @Injectable()
export class AdminStoresService { export class AdminStoresService {
constructor(private readonly prisma: PrismaService) {} constructor(
private readonly prisma: PrismaService,
private readonly partnerCityService: PartnerCityService,
) {}
async listStores(query: AdminStoresQueryDto) { async listStores(query: AdminStoresQueryDto) {
const page = query.page ?? 1; const page = query.page ?? 1;
@@ -25,7 +29,7 @@ export class AdminStoresService {
if (query.name) where.name = { contains: query.name }; if (query.name) where.name = { contains: query.name };
if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals']; if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals'];
if (query.cityId) where.cityId = BigInt(query.cityId); 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 }; if (query.phone) where.phone = { contains: query.phone };
const [items, total] = await Promise.all([ const [items, total] = await Promise.all([
@@ -36,7 +40,7 @@ export class AdminStoresService {
take: pageSize, take: pageSize,
include: { include: {
cityRef: { select: { id: true, name: true, code: true } }, 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 } }, account: { select: { id: true, phone: true, name: true, status: true } },
coverResource: { select: { id: true, url: true } }, coverResource: { select: { id: true, url: true } },
}, },
@@ -56,7 +60,7 @@ export class AdminStoresService {
where: { id }, where: { id },
include: { include: {
cityRef: true, cityRef: true,
partner: true, partnerAccount: true,
category: true, category: true,
account: true, account: true,
coverResource: true, coverResource: true,
@@ -123,6 +127,7 @@ export class AdminStoresService {
...(dto.intro !== undefined ? { intro: dto.intro } : {}), ...(dto.intro !== undefined ? { intro: dto.intro } : {}),
...(dto.address !== undefined ? { address: dto.address } : {}), ...(dto.address !== undefined ? { address: dto.address } : {}),
...(dto.district !== undefined ? { district: dto.district } : {}), ...(dto.district !== undefined ? { district: dto.district } : {}),
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
}, },
}); });
@@ -162,18 +167,22 @@ export class AdminStoresService {
}); });
if (existingAccount) throw new BadRequestException('该手机号已绑定门店'); if (existingAccount) throw new BadRequestException('该手机号已绑定门店');
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } }); const partnerAccountId = BigInt(dto.partnerAccountId);
if (!partner) throw new BadRequestException('开城合伙人不存在'); 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) } }); const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } });
if (!city) throw new BadRequestException('开城城市不存在'); if (!city) throw new BadRequestException('开城城市不存在');
if (city.partnerId && city.partnerId !== partner.id) { await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
throw new BadRequestException('开城城市与合伙人不匹配');
}
const store = await this.prisma.store.create({ const store = await this.prisma.store.create({
data: { data: {
cityId: city.id, cityId: city.id,
partnerId: partner.id, partnerAccountId,
settlementRate: dto.settlementRate ?? 0.6,
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null, categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
name: dto.name, name: dto.name,
phone: normalizedPhone, phone: normalizedPhone,
@@ -359,7 +368,7 @@ export class AdminStoresService {
async detailStoreAccount(id: bigint) { async detailStoreAccount(id: bigint) {
const account = await this.prisma.storeAccount.findUnique({ const account = await this.prisma.storeAccount.findUnique({
where: { id }, where: { id },
include: { store: { include: { cityRef: true, partner: true } } }, include: { store: { include: { cityRef: true, partnerAccount: true } } },
}); });
if (!account) throw new NotFoundException('门店账号不存在'); if (!account) throw new NotFoundException('门店账号不存在');
return serializeBigInt(account); return serializeBigInt(account);
@@ -4,6 +4,7 @@ import { BenefitService } from '../benefit/benefit.service';
import { TradeService } from '../trade/trade.service'; import { TradeService } from '../trade/trade.service';
import { TicketService } from '../common/ticket.service'; import { TicketService } from '../common/ticket.service';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { PartnerCityService } from '../city-scope/partner-city.service';
import type { TicketListQueryDto } from '../common/dto/common-query.dto'; import type { TicketListQueryDto } from '../common/dto/common-query.dto';
@Injectable() @Injectable()
@@ -13,6 +14,7 @@ export class AdminTicketsService {
private readonly ticketService: TicketService, private readonly ticketService: TicketService,
private readonly tradeService: TradeService, private readonly tradeService: TradeService,
private readonly benefitService: BenefitService, private readonly benefitService: BenefitService,
private readonly partnerCityService: PartnerCityService,
) {} ) {}
list(query: TicketListQueryDto) { list(query: TicketListQueryDto) {
@@ -82,15 +84,9 @@ export class AdminTicketsService {
} }
async listPartnerReshipments(partnerAccountId: bigint) { async listPartnerReshipments(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({ const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(partnerAccountId);
where: { id: partnerAccountId },
});
const cities = await this.prisma.commonCity.findMany({
where: { partnerId: account.partnerId },
select: { id: true },
});
const orders = await this.prisma.order.findMany({ const orders = await this.prisma.order.findMany({
where: { cityId: { in: cities.map((c) => c.id) } }, where: orderWhere,
select: { id: true }, select: { id: true },
}); });
const orderIds = orders.map((o) => o.id); const orderIds = orders.map((o) => o.id);
@@ -213,11 +213,23 @@ export class AdminWechatBindingsService {
const accounts = await this.prisma.partnerAccount.findMany({ const accounts = await this.prisma.partnerAccount.findMany({
where, 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) { for (const a of accounts) {
if (!a.wxOpenId) continue; if (!a.wxOpenId) continue;
const refId = a.isPrimary === 1 ? a.id : (a.parentAccountId ?? a.id);
rows.push({ rows.push({
actorType: 'PARTNER', actorType: 'PARTNER',
actorId: a.id, actorId: a.id,
@@ -225,8 +237,8 @@ export class AdminWechatBindingsService {
name: a.name, name: a.name,
wxOpenId: a.wxOpenId, wxOpenId: a.wxOpenId,
wxUnionId: a.wxUnionId, wxUnionId: a.wxUnionId,
refId: a.partnerId, refId,
refLabel: a.partner.companyName, refLabel: a.companyName,
lastLoginAt: a.lastLoginAt, lastLoginAt: a.lastLoginAt,
status: a.status, status: a.status,
}); });
@@ -9,6 +9,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
Min, Min,
Max,
MinLength, MinLength,
ValidateIf, ValidateIf,
} from 'class-validator'; } from 'class-validator';
@@ -22,7 +23,7 @@ export class UpdateStoreStatusDto {
export class CreateStoreDto { export class CreateStoreDto {
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
partnerId: string; partnerAccountId: string;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@@ -88,6 +89,11 @@ export class CreateStoreDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
contractUrl?: string; contractUrl?: string;
@IsOptional()
@IsNumber()
@Min(0)
settlementRate?: number;
} }
export class UpdateStoreDto { export class UpdateStoreDto {
@@ -114,6 +120,11 @@ export class UpdateStoreDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
district?: string; district?: string;
@IsOptional()
@IsNumber()
@Min(0)
settlementRate?: number;
} }
export class CreateStoreAccountDto { export class CreateStoreAccountDto {
@@ -145,6 +156,18 @@ export class UpdateStoreAccountDto {
} }
export class CreatePartnerDto { export class CreatePartnerDto {
@IsString()
@IsNotEmpty()
cityId: string;
@IsString()
@IsNotEmpty()
phone: string;
@IsString()
@IsNotEmpty()
name: string;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
companyName: string; companyName: string;
@@ -153,9 +176,40 @@ export class CreatePartnerDto {
@IsNotEmpty() @IsNotEmpty()
address: string; address: string;
@IsOptional()
@IsString() @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() @IsOptional()
@IsString() @IsString()
@@ -168,9 +222,21 @@ export class CreatePartnerDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
bankBranch?: string; bankBranch?: string;
@IsOptional()
@IsNumber()
weeklyStoreTarget?: number;
} }
export class UpdatePartnerDto { export class UpdatePartnerDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
phone?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
companyName?: string; companyName?: string;
@@ -183,6 +249,38 @@ export class UpdatePartnerDto {
@IsString() @IsString()
contactPhone?: string; 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() @IsOptional()
@IsString() @IsString()
bankAccountName?: string; bankAccountName?: string;
@@ -194,6 +292,14 @@ export class UpdatePartnerDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
bankBranch?: string; bankBranch?: string;
@IsOptional()
@IsNumber()
weeklyStoreTarget?: number;
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
} }
export class UpdatePartnerAccountDto { export class UpdatePartnerAccountDto {
@@ -208,13 +314,21 @@ export class UpdatePartnerAccountDto {
@IsOptional() @IsOptional()
@IsIn(['ACTIVE', 'DISABLED']) @IsIn(['ACTIVE', 'DISABLED'])
status?: string; status?: string;
@IsOptional()
@IsIn(['PARTNER', 'INTERNAL', 'PROMOTER'])
staffRole?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
permissions?: string[];
} }
export class CreatePartnerAccountDto { export class CreatePartnerAccountDto {
@ValidateIf((o: CreatePartnerAccountDto) => !o.parentAccountId)
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
partnerId?: string; parentAccountId: string;
@IsString() @IsString()
@IsNotEmpty() @IsNotEmpty()
@@ -228,10 +342,10 @@ export class CreatePartnerAccountDto {
@IsIn(['PARTNER', 'INTERNAL', 'PROMOTER']) @IsIn(['PARTNER', 'INTERNAL', 'PROMOTER'])
staffRole?: string; staffRole?: string;
/** 主账号 ID;传入则创建子账号 */
@IsOptional() @IsOptional()
@IsString() @IsArray()
parentAccountId?: string; @IsString({ each: true })
permissions?: string[];
} }
export class CreateCityDto { export class CreateCityDto {
@@ -247,10 +361,6 @@ export class CreateCityDto {
@IsNotEmpty() @IsNotEmpty()
province: string; province: string;
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional() @IsOptional()
@IsIn(['PENDING', 'ACTIVE', 'PAUSED']) @IsIn(['PENDING', 'ACTIVE', 'PAUSED'])
status?: string; status?: string;
@@ -265,10 +375,6 @@ export class UpdateCityDto {
@IsString() @IsString()
province?: string; province?: string;
@IsOptional()
@IsString()
partnerId?: string;
@IsOptional() @IsOptional()
@IsIn(['PENDING', 'ACTIVE', 'PAUSED']) @IsIn(['PENDING', 'ACTIVE', 'PAUSED'])
status?: string; status?: string;
@@ -278,6 +384,127 @@ export class UpdateCityDto {
@IsOptional() @IsOptional()
crossMinQty?: number; 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 { export class CreateStoreMediaDto {
@@ -109,6 +109,36 @@ export class AdminPartnersQueryDto extends PaginationQueryDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
contactPhone?: string; 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 { export class AdminPartnerAccountsQueryDto extends PaginationQueryDto {
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { IamModule } from '../iam/iam.module'; import { IamModule } from '../iam/iam.module';
import { TradeModule } from '../trade/trade.module'; import { TradeModule } from '../trade/trade.module';
import { AdminDashboardController } from './admin-dashboard.controller'; import { AdminDashboardController } from './admin-dashboard.controller';
@@ -12,6 +13,7 @@ import { AdminStoresService } from './admin-stores.service';
import { AdminPartnersController, AdminPartnerAccountsController } from './admin-partners.controller'; import { AdminPartnersController, AdminPartnerAccountsController } from './admin-partners.controller';
import { AdminPartnersService } from './admin-partners.service'; import { AdminPartnersService } from './admin-partners.service';
import { AdminCitiesController } from './admin-cities.controller'; import { AdminCitiesController } from './admin-cities.controller';
import { AdminCityWarehousesController, AdminCityWarehouseMutationsController } from './admin-city-warehouses.controller';
import { AdminCitiesService } from './admin-cities.service'; import { AdminCitiesService } from './admin-cities.service';
import { AdminBenefitCouponsController, AdminBenefitLedgersController } from './admin-benefit.controller'; import { AdminBenefitCouponsController, AdminBenefitLedgersController } from './admin-benefit.controller';
import { AdminBenefitService } from './admin-benefit.service'; import { AdminBenefitService } from './admin-benefit.service';
@@ -50,7 +52,7 @@ import { AdminHqPermissionsController } from './admin-hq-permissions.controller'
import { AdminHqPermissionsService } from './admin-hq-permissions.service'; import { AdminHqPermissionsService } from './admin-hq-permissions.service';
@Module({ @Module({
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule], imports: [CityScopeModule, IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
controllers: [ controllers: [
AdminDashboardController, AdminDashboardController,
AdminUsersController, AdminUsersController,
@@ -61,6 +63,8 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
AdminPartnersController, AdminPartnersController,
AdminPartnerAccountsController, AdminPartnerAccountsController,
AdminCitiesController, AdminCitiesController,
AdminCityWarehousesController,
AdminCityWarehouseMutationsController,
AdminBenefitCouponsController, AdminBenefitCouponsController,
AdminBenefitLedgersController, AdminBenefitLedgersController,
AdminRedeemRecordsController, AdminRedeemRecordsController,
@@ -104,5 +108,6 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
AdminHqPermissionsService, AdminHqPermissionsService,
SuperAdminGuard, SuperAdminGuard,
], ],
exports: [CityScopeModule],
}) })
export class OpsModule {} export class OpsModule {}
@@ -236,10 +236,7 @@ export class RedeemService {
} }
const amount = tokenAmount; const amount = tokenAmount;
const cityRule = await this.prisma.commonCityCommissionRule.findUnique({ const settlementRate = Number(account.store.settlementRate);
where: { cityId: account.store.cityId },
});
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
const settleAmount = calcRedeemSettleAmount(amount, settlementRate); const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
let record; let record;
@@ -152,15 +152,22 @@ export class PartnerMeController {
async me(@CurrentUser() user: AuthUser) { async me(@CurrentUser() user: AuthUser) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({ const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: user.actorId }, 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 { return {
id: account.id.toString(), id: account.id.toString(),
name: account.name, name: account.name,
phone: account.phone, phone: account.phone,
isPrimary: account.isPrimary === 1, isPrimary: account.isPrimary === 1,
staffRole: account.staffRole ?? undefined, 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, hasWechat: !!account.wxOpenId,
}; };
} }
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { IamModule } from '../iam/iam.module'; import { IamModule } from '../iam/iam.module';
import { AnalyticsModule } from '../analytics/analytics.module'; import { AnalyticsModule } from '../analytics/analytics.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { SettlementService } from './settlement.service'; import { SettlementService } from './settlement.service';
import { import {
AdminPartnerBillController, AdminPartnerBillController,
@@ -11,7 +12,7 @@ import {
} from './settlement.controller'; } from './settlement.controller';
@Module({ @Module({
imports: [IamModule, AnalyticsModule], imports: [IamModule, AnalyticsModule, CityScopeModule],
controllers: [ controllers: [
SettlementController, SettlementController,
PartnerMeController, PartnerMeController,
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module'; import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import { AnalyticsService } from '../analytics/analytics.service'; import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
function generateBillNo() { function generateBillNo() {
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`; return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
@@ -13,6 +14,7 @@ export class SettlementService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService, private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
) {} ) {}
async createStorePayout( async createStorePayout(
@@ -152,16 +154,13 @@ export class SettlementService {
return results; return results;
} }
async listPartnerBills(partnerAccountId: bigint) { async listPartnerBills(partnerAccountId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId },
});
const bills = await this.prisma.partnerBill.findMany({ const bills = await this.prisma.partnerBill.findMany({
where: { partnerId: account.partnerId }, where: { partnerAccountId: primary.id },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId, partnerAccountId: primary.id,
eventName: 'partner_bill_view', eventName: 'partner_bill_view',
extraJson: { count: bills.length }, extraJson: { count: bills.length },
}); });
@@ -178,7 +177,7 @@ export class SettlementService {
const pageSize = query.pageSize ?? 20; const pageSize = query.pageSize ?? 20;
const where: Prisma.PartnerBillWhereInput = {}; const where: Prisma.PartnerBillWhereInput = {};
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals']; 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([ const [items, total] = await Promise.all([
this.prisma.partnerBill.findMany({ this.prisma.partnerBill.findMany({
@@ -186,7 +185,7 @@ export class SettlementService {
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize, skip: (page - 1) * pageSize,
take: pageSize, take: pageSize,
include: { partner: { select: { companyName: true } } }, include: { partnerAccount: { select: { companyName: true } } },
}), }),
this.prisma.partnerBill.count({ where }), this.prisma.partnerBill.count({ where }),
]); ]);
@@ -196,20 +195,21 @@ export class SettlementService {
async getAdminPartnerBill(id: bigint) { async getAdminPartnerBill(id: bigint) {
const bill = await this.prisma.partnerBill.findUnique({ const bill = await this.prisma.partnerBill.findUnique({
where: { id }, where: { id },
include: { partner: true }, include: { partnerAccount: true },
}); });
if (!bill) throw new NotFoundException('账单不存在'); if (!bill) throw new NotFoundException('账单不存在');
return serializeBigInt(bill); return serializeBigInt(bill);
} }
async generatePartnerBill(body: { partnerId: string; year: number; month: number }) { 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 periodStart = new Date(body.year, body.month - 1, 1);
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999); const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
const existing = await this.prisma.partnerBill.findFirst({ const existing = await this.prisma.partnerBill.findFirst({
where: { where: {
partnerId, partnerAccountId: primary.id,
periodStart, periodStart,
status: { not: 'DRAFT' }, status: { not: 'DRAFT' },
}, },
@@ -218,31 +218,33 @@ export class SettlementService {
throw new BadRequestException('该月账单已确认,不可重复生成'); throw new BadRequestException('该月账单已确认,不可重复生成');
} }
const cities = await this.prisma.commonCity.findMany({ if (!primary.cityId) {
where: { partnerId }, throw new BadRequestException('合伙人未绑定开城城市');
include: { commissionRule: true }, }
});
const cityIds = cities.map((c) => c.id); const orderCommissionRate = Number(primary.orderCommissionRate ?? 0);
const defaultOrderRate = cities[0]?.commissionRule?.orderCommissionRate const redeemCommissionRate = Number(primary.redeemCommissionRate ?? 0.03);
? Number(cities[0].commissionRule.orderCommissionRate)
: 0;
const defaultRedeemRate = cities[0]?.commissionRule?.redeemCommissionRate
? Number(cities[0].commissionRule.redeemCommissionRate)
: 0.03;
const orders = await this.prisma.order.findMany({ const orders = await this.prisma.order.findMany({
where: { where: {
cityId: { in: cityIds }, cityId: primary.cityId,
payStatus: 'PAID', payStatus: 'PAID',
paidAt: { gte: periodStart, lte: periodEnd }, paidAt: { gte: periodStart, lte: periodEnd },
}, },
}); });
const orderCommission = orders.reduce( const orderCommission = orders.reduce((sum, o) => {
(sum, o) => sum + Number(o.payAmount) * defaultOrderRate, if (o.partnerAccountIdAtPay) {
0, 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 storeIds = stores.map((s) => s.id);
const redeems = await this.prisma.redeemRecord.findMany({ const redeems = await this.prisma.redeemRecord.findMany({
where: { where: {
@@ -251,14 +253,14 @@ export class SettlementService {
}, },
}); });
const redeemCommission = redeems.reduce( const redeemCommission = redeems.reduce(
(sum, r) => sum + Number(r.amount) * defaultRedeemRate, (sum, r) => sum + Number(r.amount) * redeemCommissionRate,
0, 0,
); );
const totalAmount = Math.round((orderCommission + redeemCommission) * 100) / 100; const totalAmount = Math.round((orderCommission + redeemCommission) * 100) / 100;
const draft = await this.prisma.partnerBill.findFirst({ const draft = await this.prisma.partnerBill.findFirst({
where: { partnerId, periodStart, status: 'DRAFT' }, where: { partnerAccountId: primary.id, periodStart, status: 'DRAFT' },
}); });
const bill = draft const bill = draft
@@ -269,7 +271,7 @@ export class SettlementService {
: await this.prisma.partnerBill.create({ : await this.prisma.partnerBill.create({
data: { data: {
billNo: generateBillNo(), billNo: generateBillNo(),
partnerId, partnerAccountId: primary.id,
periodStart, periodStart,
periodEnd, periodEnd,
orderCommission, orderCommission,
@@ -310,12 +312,12 @@ export class SettlementService {
async exportPartnerBills(query: { partnerId?: string; status?: string }) { async exportPartnerBills(query: { partnerId?: string; status?: string }) {
const where: Prisma.PartnerBillWhereInput = {}; 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']; if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
const bills = await this.prisma.partnerBill.findMany({ const bills = await this.prisma.partnerBill.findMany({
where, where,
include: { partner: { select: { companyName: true } } }, include: { partnerAccount: { select: { companyName: true } } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
@@ -323,7 +325,7 @@ export class SettlementService {
const rows = bills.map((b) => const rows = bills.map((b) =>
[ [
b.billNo, b.billNo,
b.partner.companyName, b.partnerAccount.companyName,
b.periodStart.toISOString().slice(0, 10), b.periodStart.toISOString().slice(0, 10),
b.periodEnd.toISOString().slice(0, 10), b.periodEnd.toISOString().slice(0, 10),
Number(b.orderCommission), Number(b.orderCommission),
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
import { IamModule } from '../iam/iam.module'; import { IamModule } from '../iam/iam.module';
import { RedeemModule } from '../redeem/redeem.module'; import { RedeemModule } from '../redeem/redeem.module';
import { AnalyticsModule } from '../analytics/analytics.module'; import { AnalyticsModule } from '../analytics/analytics.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { StoreService } from './store.service'; import { StoreService } from './store.service';
import { import {
PartnerDashboardController, PartnerDashboardController,
@@ -13,7 +14,7 @@ import {
} from './store.controller'; } from './store.controller';
@Module({ @Module({
imports: [IamModule, AnalyticsModule, forwardRef(() => RedeemModule)], imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
controllers: [ controllers: [
PublicStoreController, PublicStoreController,
PartnerStoreController, PartnerStoreController,
@@ -11,6 +11,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { mapStoreCompat } from '../../common/compat/v31-compat'; import { mapStoreCompat } from '../../common/compat/v31-compat';
import { parseBigIntParam } from '../../common/parse-bigint'; import { parseBigIntParam } from '../../common/parse-bigint';
import { AnalyticsService } from '../analytics/analytics.service'; import { AnalyticsService } from '../analytics/analytics.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
@Injectable() @Injectable()
export class StoreService { export class StoreService {
@@ -19,6 +20,7 @@ export class StoreService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly analyticsService: AnalyticsService, private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
) {} ) {}
async listOpenStores(cityCode?: string) { async listOpenStores(cityCode?: string) {
@@ -48,9 +50,15 @@ export class StoreService {
return serializeBigInt(mapStoreCompat({ ...store, media })); 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) { async partnerListStores(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId); const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
const where: { partnerId: bigint; id?: { in: bigint[] } } = { partnerId: account.partnerId }; const where: { partnerAccountId: bigint; id?: { in: bigint[] } } = { partnerAccountId: primaryId };
if (this.isSubAccount(account)) { if (this.isSubAccount(account)) {
const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId); const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId);
if (storeIds.length === 0) return []; if (storeIds.length === 0) return [];
@@ -65,9 +73,9 @@ export class StoreService {
} }
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) { 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({ const store = await this.prisma.store.findFirst({
where: { id: storeId, partnerId: account.partnerId }, where: { id: storeId, partnerAccountId: primaryId },
include: { category: true, coverResource: true }, include: { category: true, coverResource: true },
}); });
if (!store) throw new NotFoundException('门店不存在'); if (!store) throw new NotFoundException('门店不存在');
@@ -88,10 +96,11 @@ export class StoreService {
} }
async partnerListCities(partnerAccountId: bigint) { 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({ const cities = await this.prisma.commonCity.findMany({
where: { partnerId: account.partnerId }, where: cityWhere,
select: { id: true, name: true, code: true, province: true, partnerId: true }, select: { id: true, name: true, code: true, province: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
return serializeBigInt(cities); return serializeBigInt(cities);
@@ -115,11 +124,11 @@ export class StoreService {
} }
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) { 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(); const normalizedPhone = String(body.phone).trim();
await this.assertStorePhoneAvailable(normalizedPhone); 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 coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
const envPhotoUrls = Array.isArray(body.envPhotoUrls) const envPhotoUrls = Array.isArray(body.envPhotoUrls)
? body.envPhotoUrls.map((u) => String(u).trim()).filter(Boolean) ? body.envPhotoUrls.map((u) => String(u).trim()).filter(Boolean)
@@ -133,7 +142,7 @@ export class StoreService {
const store = await this.prisma.store.create({ const store = await this.prisma.store.create({
data: { data: {
cityId: city.id, cityId: city.id,
partnerId: account.partnerId, partnerAccountId: primaryId,
categoryId: body.categoryId ? parseBigIntParam(body.categoryId, '分类ID') : null, categoryId: body.categoryId ? parseBigIntParam(body.categoryId, '分类ID') : null,
name: String(body.name), name: String(body.name),
phone: normalizedPhone, phone: normalizedPhone,
@@ -220,7 +229,7 @@ export class StoreService {
}); });
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId, partnerAccountId: primaryId,
eventName: 'partner_store_create', eventName: 'partner_store_create',
refType: 'STORE', refType: 'STORE',
refId: store.id, refId: store.id,
@@ -240,10 +249,10 @@ export class StoreService {
storeId: bigint, storeId: bigint,
status: 'OPEN' | 'PAUSED' | 'CLOSED', status: 'OPEN' | 'PAUSED' | 'CLOSED',
) { ) {
const account = await this.getPartnerAccount(partnerAccountId); const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account); this.assertPrimaryAccount(account);
const store = await this.prisma.store.findFirst({ 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) throw new NotFoundException('门店不存在');
if (store.status === 'CLOSED') { if (store.status === 'CLOSED') {
@@ -259,7 +268,7 @@ export class StoreService {
include: { coverResource: true }, include: { coverResource: true },
}); });
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId, partnerAccountId: primaryId,
eventName: 'partner_store_status_change', eventName: 'partner_store_status_change',
refType: 'STORE', refType: 'STORE',
refId: storeId, refId: storeId,
@@ -276,10 +285,10 @@ export class StoreService {
storeId: bigint, storeId: bigint,
body: Record<string, unknown>, body: Record<string, unknown>,
) { ) {
const account = await this.getPartnerAccount(partnerAccountId); const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account); this.assertPrimaryAccount(account);
const store = await this.prisma.store.findFirst({ 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) throw new NotFoundException('门店不存在');
if (store.status === 'CLOSED') { if (store.status === 'CLOSED') {
@@ -343,20 +352,20 @@ export class StoreService {
} }
async partnerDashboard(partnerAccountId: bigint) { async partnerDashboard(partnerAccountId: bigint) {
const account = await this.getPartnerAccount(partnerAccountId); const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account); this.assertPrimaryAccount(account);
const partnerStoreIds = await this.prisma.store.findMany({ const partnerStoreIds = await this.prisma.store.findMany({
where: { partnerId: account.partnerId }, where: { partnerAccountId: primaryId },
select: { id: true }, select: { id: true },
}); });
const storeIds = partnerStoreIds.map((s) => s.id); const storeIds = partnerStoreIds.map((s) => s.id);
const [storeCount, orderCount, recentStores, pendingAuditCount] = await Promise.all([ 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({ this.prisma.order.count({
where: { city: { partnerId: account.partnerId } }, where: await this.partnerCityService.buildPartnerOrderWhere(primaryId),
}), }),
this.prisma.store.findMany({ this.prisma.store.findMany({
where: { partnerId: account.partnerId }, where: { partnerAccountId: primaryId },
select: { id: true, name: true, status: true, createdAt: true }, select: { id: true, name: true, status: true, createdAt: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 10, take: 10,
@@ -375,18 +384,17 @@ export class StoreService {
return { return {
storeCount, storeCount,
orderCount, orderCount,
companyName: account.partner.companyName, companyName: account.companyName ?? '',
recentStores: serializeBigInt(recentStores), recentStores: serializeBigInt(recentStores),
pendingAuditCount, pendingAuditCount,
}; };
} }
async partnerLeaderboard(partnerAccountId: bigint, period: PartnerLeaderboardPeriod = 'total') { async partnerLeaderboard(partnerAccountId: bigint, period: PartnerLeaderboardPeriod = 'total') {
const account = await this.getPartnerAccount(partnerAccountId); const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
this.assertPrimaryAccount(account);
const accounts = await this.prisma.partnerAccount.findMany({ const accounts = await this.prisma.partnerAccount.findMany({
where: { partnerId: account.partnerId }, where: { OR: [{ id: primaryId }, { parentAccountId: primaryId }] },
orderBy: { id: 'asc' }, orderBy: { id: 'asc' },
}); });
@@ -457,10 +465,9 @@ export class StoreService {
return { period, list, self }; return { period, list, self };
} }
async partnerWeeklyReport(partnerAccountId: bigint, startDate?: string) { async partnerWeeklyReport(actorAccountId: bigint, startDate?: string) {
const account = await this.getPartnerAccount(partnerAccountId); const { account, primaryId } = await this.resolvePartnerScope(actorAccountId);
this.assertPrimaryAccount(account); this.assertPrimaryAccount(account);
const partnerId = account.partnerId;
const currentWeekStart = this.startOfWeekMonday(new Date()); const currentWeekStart = this.startOfWeekMonday(new Date());
const periodStart = const periodStart =
@@ -472,16 +479,16 @@ export class StoreService {
const prevPeriodEnd = periodStart; const prevPeriodEnd = periodStart;
const newStoreTarget = const newStoreTarget =
account.partner.weeklyStoreTarget ?? account.weeklyStoreTarget ??
Number(process.env.PARTNER_WEEKLY_STORE_TARGET ?? 20); Number(process.env.PARTNER_WEEKLY_STORE_TARGET ?? 20);
const orderWhere = { const orderWhere = {
city: { partnerId }, ...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)),
payStatus: 'PAID' as const, payStatus: 'PAID' as const,
paidAt: { gte: periodStart, lt: periodEnd }, paidAt: { gte: periodStart, lt: periodEnd },
}; };
const prevOrderWhere = { const prevOrderWhere = {
city: { partnerId }, ...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)),
payStatus: 'PAID' as const, payStatus: 'PAID' as const,
paidAt: { gte: prevPeriodStart, lt: prevPeriodEnd }, paidAt: { gte: prevPeriodStart, lt: prevPeriodEnd },
}; };
@@ -498,16 +505,16 @@ export class StoreService {
] = await Promise.all([ ] = await Promise.all([
this.prisma.order.aggregate({ where: orderWhere, _sum: { payAmount: true } }), this.prisma.order.aggregate({ where: orderWhere, _sum: { payAmount: true } }),
this.prisma.order.count({ where: orderWhere }), this.prisma.order.count({ where: orderWhere }),
this.prisma.store.count({ where: { partnerId } }), this.prisma.store.count({ where: { partnerAccountId: primaryId } }),
this.prisma.store.count({ 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.order.aggregate({ where: prevOrderWhere, _sum: { payAmount: true } }),
this.prisma.redeemRecord.groupBy({ this.prisma.redeemRecord.groupBy({
by: ['storeId'], by: ['storeId'],
where: { where: {
createdAt: { gte: periodStart, lt: periodEnd }, createdAt: { gte: periodStart, lt: periodEnd },
store: { partnerId }, store: { partnerAccountId: primaryId },
}, },
_sum: { amount: true }, _sum: { amount: true },
orderBy: { _sum: { amount: 'desc' } }, orderBy: { _sum: { amount: 'desc' } },
@@ -516,7 +523,7 @@ export class StoreService {
this.prisma.redeemRecord.findMany({ this.prisma.redeemRecord.findMany({
where: { where: {
createdAt: { gte: periodStart, lt: periodEnd }, createdAt: { gte: periodStart, lt: periodEnd },
store: { partnerId }, store: { partnerAccountId: primaryId },
}, },
select: { storeId: true }, select: { storeId: true },
distinct: ['storeId'], distinct: ['storeId'],
@@ -677,22 +684,29 @@ export class StoreService {
return ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][index] ?? ''; return ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][index] ?? '';
} }
private async getPartnerPrimaryId(actorAccountId: bigint) {
const primary = await this.partnerCityService.resolvePrimaryAccount(actorAccountId);
return primary.id;
}
private async getPartnerAccount(partnerAccountId: bigint) { private async getPartnerAccount(partnerAccountId: bigint) {
return this.prisma.partnerAccount.findUniqueOrThrow({ return this.prisma.partnerAccount.findUniqueOrThrow({
where: { id: partnerAccountId }, 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) { if (cityId) {
const city = await this.prisma.commonCity.findFirst({ const id = parseBigIntParam(cityId, '城市ID');
where: { id: parseBigIntParam(cityId, '城市ID'), partnerId }, await this.partnerCityService.assertPartnerAccountBoundToCity(primaryId, id);
}); const city = await this.prisma.commonCity.findUnique({ where: { id } });
if (!city) throw new BadRequestException('所选地区未匹配到开城城市'); if (!city) throw new BadRequestException('所选地区未匹配到开城城市');
return city; 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('合伙人未绑定开城'); if (!city) throw new BadRequestException('合伙人未绑定开城');
return city; return city;
} }
@@ -5,11 +5,12 @@ import { IamModule } from '../iam/iam.module';
import { BenefitModule } from '../benefit/benefit.module'; import { BenefitModule } from '../benefit/benefit.module';
import { CatalogModule } from '../catalog/catalog.module'; import { CatalogModule } from '../catalog/catalog.module';
import { CommonModule } from '../common/common.module'; import { CommonModule } from '../common/common.module';
import { CityScopeModule } from '../city-scope/city-scope.module';
import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller'; import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller';
import { TradeService } from './trade.service'; import { TradeService } from './trade.service';
@Module({ @Module({
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, forwardRef(() => BenefitModule), CommonModule], imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, CityScopeModule, forwardRef(() => BenefitModule), CommonModule],
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController], controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
providers: [TradeService], providers: [TradeService],
exports: [TradeService], exports: [TradeService],
@@ -17,6 +17,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { AnalyticsService } from '../analytics/analytics.service'; import { AnalyticsService } from '../analytics/analytics.service';
import { CatalogService } from '../catalog/catalog.service'; import { CatalogService } from '../catalog/catalog.service';
import { BenefitService } from '../benefit/benefit.service'; import { BenefitService } from '../benefit/benefit.service';
import { PartnerCityService } from '../city-scope/partner-city.service';
import { TicketService } from '../common/ticket.service'; import { TicketService } from '../common/ticket.service';
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants'; import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
import { IPayProvider } from '../../integrations/pay/pay.interface'; import { IPayProvider } from '../../integrations/pay/pay.interface';
@@ -39,6 +40,7 @@ export class TradeService {
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider, @Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider, @Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
private readonly analyticsService: AnalyticsService, private readonly analyticsService: AnalyticsService,
private readonly partnerCityService: PartnerCityService,
) {} ) {}
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) { async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
@@ -220,6 +222,10 @@ export class TradeService {
const { externalNo } = payResult; const { externalNo } = payResult;
const now = new Date(); const now = new Date();
const paySnapshot = await this.partnerCityService.resolveForOrder(
order.cityId,
order.receiverDistrict,
);
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
await tx.order.update({ await tx.order.update({
@@ -229,6 +235,8 @@ export class TradeService {
payStatus: 'PAID', payStatus: 'PAID',
paidAt: now, paidAt: now,
payExternalNo: externalNo, payExternalNo: externalNo,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
}, },
}); });
await tx.logThirdParty.create({ await tx.logThirdParty.create({
@@ -300,6 +308,10 @@ export class TradeService {
} }
const now = new Date(); const now = new Date();
const paySnapshot = await this.partnerCityService.resolveForOrder(
order.cityId,
order.receiverDistrict,
);
await this.prisma.$transaction(async (tx) => { await this.prisma.$transaction(async (tx) => {
const current = await tx.order.findUnique({ where: { id: order.id } }); const current = await tx.order.findUnique({ where: { id: order.id } });
if (!current || current.payStatus === 'PAID') return; if (!current || current.payStatus === 'PAID') return;
@@ -311,6 +323,8 @@ export class TradeService {
payStatus: 'PAID', payStatus: 'PAID',
paidAt: now, paidAt: now,
payExternalNo: params.transactionId, payExternalNo: params.transactionId,
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
}, },
}); });
await tx.logThirdParty.create({ await tx.logThirdParty.create({
@@ -450,15 +464,10 @@ export class TradeService {
} }
async listPartnerReshipments(partnerAccountId: bigint) { async listPartnerReshipments(partnerAccountId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({ const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
where: { id: partnerAccountId }, const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
});
const cities = await this.prisma.commonCity.findMany({
where: { partnerId: account.partnerId },
select: { id: true },
});
const orders = await this.prisma.order.findMany({ const orders = await this.prisma.order.findMany({
where: { cityId: { in: cities.map((c) => c.id) } }, where: orderWhere,
select: { id: true }, select: { id: true },
}); });
const tickets = await this.prisma.commonTicket.findMany({ const tickets = await this.prisma.commonTicket.findMany({
@@ -473,12 +482,8 @@ export class TradeService {
} }
async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) { async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({ const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
where: { id: partnerAccountId }, const where = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
});
const cities = await this.prisma.commonCity.findMany({ where: { partnerId: account.partnerId } });
const cityIds = cities.map((c) => c.id);
const where = { cityId: { in: cityIds } };
const [list, total] = await Promise.all([ const [list, total] = await Promise.all([
this.prisma.order.findMany({ this.prisma.order.findMany({
where, where,
@@ -493,12 +498,10 @@ export class TradeService {
} }
async getPartnerOrder(partnerAccountId: bigint, orderId: bigint) { async getPartnerOrder(partnerAccountId: bigint, orderId: bigint) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({ const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
where: { id: partnerAccountId }, const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
});
const cities = await this.prisma.commonCity.findMany({ where: { partnerId: account.partnerId } });
const order = await this.prisma.order.findFirst({ 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 }, include: { delivery: true, user: true, imageResource: true },
}); });
if (!order) throw new NotFoundException('订单不存在'); if (!order) throw new NotFoundException('订单不存在');
@@ -510,13 +513,12 @@ export class TradeService {
} }
async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) { async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) {
const account = await this.prisma.partnerAccount.findUniqueOrThrow({ const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
where: { id: partnerAccountId }, const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
});
const order = await this.prisma.order.findFirst({ const order = await this.prisma.order.findFirst({
where: { where: {
id: orderId, id: orderId,
city: { partnerId: account.partnerId }, ...partnerOrderWhere,
}, },
include: { delivery: true }, include: { delivery: true },
}); });
@@ -524,7 +526,7 @@ export class TradeService {
await this.applyStatusTransition(order.id, order.status, targetStatus); await this.applyStatusTransition(order.id, order.status, targetStatus);
if (targetStatus === 'SHIPPING') { if (targetStatus === 'SHIPPING') {
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId, partnerAccountId: primary.id,
eventName: 'partner_order_ship', eventName: 'partner_order_ship',
refType: 'ORDER', refType: 'ORDER',
refId: orderId, refId: orderId,
@@ -532,7 +534,7 @@ export class TradeService {
}); });
} }
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', { this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
partnerId: account.partnerId, partnerAccountId: primary.id,
eventName: 'partner_delivery_advance', eventName: 'partner_delivery_advance',
refType: 'ORDER', refType: 'ORDER',
refId: orderId, 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 | 待提现申请流 | | C10 | 门店主动提现 | 自动 StorePayout | 📋 P2 | 待提现申请流 |
| C11 | 工单四类型 | 3 enum | 📋 P3 | 待扩展 `TicketType` | | C11 | 工单四类型 | 3 enum | 📋 P3 | 待扩展 `TicketType` |
| C12 | 现场提货 | 无 DeliveryType | 📋 P2 | 待 `ON_SITE_PICKUP` | | 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 | | C14 | 文档残留旧口径 | 5min/5 Tab | ✅ 已修复 | `apps/AGENTS.md`、rules、agents、skills |
### 1.3 P0 已改文件清单 ### 1.3 P0 已改文件清单
@@ -196,8 +196,8 @@
| **REQ-H-014a** 提现白名单配置 | 无 | | **REQ-H-014a** 提现白名单配置 | 无 |
| **REQ-S-017~019** 门店子账号 + 多店选店 | 无店员角色、无选店列表 | | **REQ-S-017~019** 门店子账号 + 多店选店 | 无店员角色、无选店列表 |
| **REQ-P-008** 一号多店确认弹窗 | 当前直接拒绝(见 C9) | | **REQ-P-008** 一号多店确认弹窗 | 当前直接拒绝(见 C9) |
| **REQ-H-004a / REQ-H-007** 多合伙人管辖与佣金 | 无全城/区域、区县互斥、佣金快照 | | **REQ-H-004a / REQ-H-007** 多合伙人管辖与佣金 | 主账号 `partner_account` + HQ 合伙人页;支付写 `partnerAccountIdAtPay` 快照 | 🔶 基础已做;ACC-P21~P28 全套验收待补 |
| **ACC-P21~P28** 多合伙人全套 | 数据模型与 UI 均未做 | | **ACC-P21~P28** 多合伙人全套 | 主账号模型 + HQ UI + 域规则单测 | 🔶 部分完成 |
| **推广码完整归因(W2 门禁)** | 后端有部分 API**admin-web 缺推广码管理页** | | **推广码完整归因(W2 门禁)** | 后端有部分 API**admin-web 缺推广码管理页** |
| **合伙人 T+30 独立确认打款** | 有 bill generate;合伙人确认/打款流不完整 | | **合伙人 T+30 独立确认打款** | 有 bill generate;合伙人确认/打款流不完整 |
@@ -207,7 +207,7 @@
|------------|------| |------------|------|
| **REQ-P-021 / REQ-H-012** 代下单 | 无 | | **REQ-P-021 / REQ-H-012** 代下单 | 无 |
| **REQ-S-008a / REQ-S-020 / REQ-H-020a** 弱网 5 次拍照兜底 | 无待处理核销单模型与 UI | | **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-009 + SC-03** 跨城完整履约 | 后端有 `CROSS_CITY` 检测;总部物流 UX/佣金归总部未闭环 |
| **REQ-U-021 OPT-001** 发票 | 无模块 | | **REQ-U-021 OPT-001** 发票 | 无模块 |
| **REQ-U-022** 四类型工单用户端 | 仅 refund-request | | **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,负责架构、主交易链路、支付退款、后台运营、交付验收 | | `jacy-dukang` | **全部四端**(含 `h5-shop``h5-partner` | **全部模块** + `packages/*`、Prisma | Tech lead**2026-07 起暂代刘景尧 B+D 职责** |
| `刘景尧` | `apps/h5-shop``apps/h5-partner` | `store``redeem`,并配合 `settlement`、配送/核销联调 | 负责门店、合伙人、录店、核销、门店体验与辖区履约 | | `刘景尧` | ~~`apps/h5-shop``apps/h5-partner`~~ | ~~`store``redeem`~~ | **暂停分工**,恢复前由 jacy 代管 |
### 2.1 四端交付形态(工程口径) ### 2.1 四端交付形态(工程口径)
@@ -47,9 +47,16 @@ V3 必须达到可业务验收状态:
- `apps/*` 只走 HTTP API 与 `packages/shared-types`,禁止 import `server/*` 或其他 app。 - `apps/*` 只走 HTTP API 与 `packages/shared-types`,禁止 import `server/*` 或其他 app。
- 后端跨模块只调用 exported Service,禁止为了赶进度直接写他人领域表。 - 后端跨模块只调用 exported Service,禁止为了赶进度直接写他人领域表。
- 涉及 API、枚举、DTO、业务规则变更,必须同步 `packages/shared-types``packages/domain` 与本手册。 - 涉及 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 上限) ## 3. V3 核销规则(已替代 V2 的 ¥500 上限)