城市合伙人端的修改(后台)
This commit is contained in:
@@ -26,10 +26,12 @@ alwaysApply: true
|
||||
|
||||
## 边界(2 人团队)
|
||||
|
||||
> **临时(2026-07)**:刘京尧任务由 `jacy-dukang` 代管,B+D 路径可改。
|
||||
|
||||
| 负责人 | 路径 |
|
||||
|--------|------|
|
||||
| jacy-dukang | `apps/h5-user/`, `apps/admin-web/`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, `modules/{iam,trade,benefit,analytics,catalog,settlement,ops}/` |
|
||||
| 刘京尧 | `apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/` |
|
||||
| jacy-dukang | `apps/*`, `packages/`, `callbacks/`, `jobs/`, `common/`, `integrations/`, 全部 `modules/*` |
|
||||
| ~~刘京尧~~ | ~~`apps/h5-partner/`, `apps/h5-shop/`, `modules/{store,redeem}/`~~(暂代管) |
|
||||
|
||||
- 跨模块只 inject **exported Service**,禁止 `prisma` 写他人表
|
||||
- `apps/*` 禁止 import `server/*` 或其他 app
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
```bash
|
||||
# 基础设施
|
||||
cd deploy && docker compose up -d
|
||||
cd deploy && docker compose up -d # dukang-v1:MySQL :6016 Redis :6017
|
||||
|
||||
# 依赖与数据库
|
||||
pnpm install
|
||||
@@ -60,16 +60,18 @@ Mock 验证码:`123456`。测试账号见 [`README.md`](./README.md)。
|
||||
| 负责人 | Git 账号 | 职责 |
|
||||
|--------|----------|------|
|
||||
| **Jacy**(管理员) | `jacy-dukang` | C 端、admin-web、后端主模块、packages、Prisma 迁移主 Review |
|
||||
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块 |
|
||||
| **刘景尧** | `刘景尧` | 合伙人 H5、门店 H5、`store` / `redeem` 模块(**暂由 jacy-dukang 代管**) |
|
||||
|
||||
逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**人员合并**如下:
|
||||
> **临时分工(2026-07)**:刘景尧任务暂由 `jacy-dukang` 全权负责;逻辑 OWNER B+D 路径可改,Prisma 迁移由 jacy 主导。
|
||||
|
||||
逻辑模块边界仍按 A/B/C/D 划分(便于 Agent 隔离),**当前人员合并**如下:
|
||||
|
||||
| 逻辑 OWNER | 负责人 | 可改路径 | 后端 Module |
|
||||
|------------|--------|----------|-------------|
|
||||
| **A + C + Lead** | jacy-dukang | `apps/h5-user/`, `apps/admin-web/`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | `iam`, `trade`, `benefit`, `analytics`, `catalog`, `settlement`, `ops` |
|
||||
| **B + D** | 刘景尧 | `apps/h5-partner/`, `apps/h5-shop/` | `store`, `redeem` |
|
||||
| **A + C + Lead + B + D** | jacy-dukang | `apps/*`, `packages/*`, `callbacks/`, `jobs/`, `common/`, `integrations/` | 全部后端模块 |
|
||||
| ~~B + D~~ | ~~刘景尧~~ | — | 恢复分工前由 jacy 代管 |
|
||||
|
||||
**Prisma 迁移**:jacy-dukang 主 Review;若改 `store_*` / 核销相关表,需刘景尧共同 Review。
|
||||
**Prisma 迁移**:jacy-dukang 主导 Review;涉及 `store_*` / 核销表时仍建议刘景尧知会(恢复分工后共同 Review)。
|
||||
|
||||
### 跨模块规则(R1–R8 摘要)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
```bash
|
||||
cd deploy
|
||||
docker compose up -d
|
||||
docker compose up -d # MySQL :6016 Redis :6017(与 dukang-v3 的 6014/6015 隔离)
|
||||
```
|
||||
|
||||
### 2. 安装依赖
|
||||
@@ -48,7 +48,7 @@ pnpm dev:partner # http://localhost:5175
|
||||
|----|--------|
|
||||
| C端用户 | 13800000001 |
|
||||
| 合伙人 | 13700000001 |
|
||||
| HQ | 13600000001 |
|
||||
| HQ | 13600000001(密码登录:`admin` / `dukang@123!`;短信验证码 123456) |
|
||||
|
||||
门店登录使用录入门店时绑定的手机号;seed 仅预置「郑州老城店」联调账号 `13910000001`。
|
||||
|
||||
|
||||
@@ -7,8 +7,6 @@ import UsersPage from './pages/UsersPage';
|
||||
import OrdersPage from './pages/OrdersPage';
|
||||
import StoresPage from './pages/StoresPage';
|
||||
import StoreAccountsPage from './pages/StoreAccountsPage';
|
||||
import PartnersPage from './pages/PartnersPage';
|
||||
import PartnerAccountsPage from './pages/PartnerAccountsPage';
|
||||
import BenefitCouponsPage from './pages/BenefitCouponsPage';
|
||||
import BenefitLedgersPage from './pages/BenefitLedgersPage';
|
||||
import RedeemRecordsPage from './pages/RedeemRecordsPage';
|
||||
@@ -17,6 +15,8 @@ import DeliveriesPage from './pages/DeliveriesPage';
|
||||
import XiaofeixiaTestPage from './pages/XiaofeixiaTestPage';
|
||||
import HqAccountsPage from './pages/HqAccountsPage';
|
||||
import CitiesPage from './pages/CitiesPage';
|
||||
import CityPartnersPage from './pages/CityPartnersPage';
|
||||
import CityWarehousesPage from './pages/CityWarehousesPage';
|
||||
import StoreMediaPage from './pages/StoreMediaPage';
|
||||
import ProductsPage from './pages/ProductsPage';
|
||||
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
|
||||
@@ -58,9 +58,11 @@ export default function App() {
|
||||
<Route path="/store-accounts" element={<StoreAccountsPage />} />
|
||||
<Route path="/store-media" element={<StoreMediaPage />} />
|
||||
<Route path="/resources" element={<ResourcesPage />} />
|
||||
<Route path="/partners" element={<PartnersPage />} />
|
||||
<Route path="/partners" element={<Navigate to="/city-partners" replace />} />
|
||||
<Route path="/cities" element={<CitiesPage />} />
|
||||
<Route path="/partner-accounts" element={<PartnerAccountsPage />} />
|
||||
<Route path="/city-partners" element={<CityPartnersPage />} />
|
||||
<Route path="/city-warehouses" element={<CityWarehousesPage />} />
|
||||
<Route path="/partner-accounts" element={<Navigate to="/city-partners" replace />} />
|
||||
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
||||
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
||||
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Cascader } from 'antd';
|
||||
import type { DefaultOptionType } from 'antd/es/cascader';
|
||||
import { PROVINCE_CITY_OPTIONS } from '../lib/china-region';
|
||||
|
||||
type ChinaProvinceCityCascaderProps = {
|
||||
value?: string[];
|
||||
onChange?: (codes: string[]) => void;
|
||||
disabled?: boolean;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
export default function ChinaProvinceCityCascader({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = '请选择省 / 市',
|
||||
}: ChinaProvinceCityCascaderProps) {
|
||||
return (
|
||||
<Cascader
|
||||
options={PROVINCE_CITY_OPTIONS as DefaultOptionType[]}
|
||||
value={value}
|
||||
onChange={(codes) => onChange?.((codes ?? []) as string[])}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
showSearch={{
|
||||
filter: (input, path) =>
|
||||
path.some((option) =>
|
||||
String(option.label ?? '').toLowerCase().includes(input.toLowerCase()),
|
||||
),
|
||||
}}
|
||||
changeOnSelect={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Cascader,
|
||||
Checkbox,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CITY_PARTNER_STATUS_LABELS,
|
||||
CityPartnerScopeType,
|
||||
CityPartnerStatus,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import PartnerSubAccountList from './PartnerSubAccountList';
|
||||
|
||||
type PartnerRow = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
scopeType?: string;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
accountCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = PartnerRow & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
managedWarehouseId?: string | null;
|
||||
managedWarehouseName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
cityId: string;
|
||||
maxPartnerCommissionRate?: number;
|
||||
onChanged?: () => void;
|
||||
};
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
|
||||
value: k,
|
||||
label: PARTNER_PERMISSION_LABELS[k],
|
||||
}));
|
||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
|
||||
.filter(([value]) => value !== 'PARTNER')
|
||||
.map(([value, label]) => ({ value, label }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
function commissionSumError(
|
||||
orderPercent: number,
|
||||
redeemPercent: number,
|
||||
maxRate: number,
|
||||
): string | null {
|
||||
const sum = orderPercent / 100 + redeemPercent / 100;
|
||||
if (sum > maxRate + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CityPartnersPanel({ cityId, maxPartnerCommissionRate = 0.05, onChanged }: Props) {
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [partners, setPartners] = useState<PartnerRow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subEditOpen, setSubEditOpen] = useState(false);
|
||||
const [subEditId, setSubEditId] = useState<string | null>(null);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
|
||||
const loadPartners = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await request<Paginated<PartnerRow>>(`/admin/partners?cityId=${cityId}&pageSize=100`);
|
||||
setPartners(res.items);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [cityId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadPartners();
|
||||
}, [loadPartners]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request(`/admin/partners/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}
|
||||
|
||||
async function deleteSubAccount(subId: string) {
|
||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
if (detail) void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<PartnerRow> = [
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 100,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{
|
||||
title: '佣金',
|
||||
width: 120,
|
||||
render: (_, row) => `${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
||||
},
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ marginBottom: 12 }}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({
|
||||
cityId,
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 3,
|
||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||
});
|
||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建合伙人
|
||||
</Button>
|
||||
<Table rowKey="id" size="small" loading={loading} columns={columns} dataSource={partners} pagination={false} />
|
||||
|
||||
<Drawer
|
||||
title="城市合伙人"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '主账号',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||
<Select options={BINDING_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县">
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%(可在城市基本信息中调整)
|
||||
</Typography.Text>
|
||||
<Form.Item label="管仓仓库">
|
||||
<Input
|
||||
disabled
|
||||
value={detail.managedWarehouseName ?? '未分配(请在仓库管理中设置)'}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
||||
children: (
|
||||
<PartnerSubAccountList
|
||||
subs={detail.children ?? []}
|
||||
onAdd={() => {
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
||||
setSubOpen(true);
|
||||
}}
|
||||
onEdit={(row) => {
|
||||
setSubEditId(row.id);
|
||||
subEditForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
staffRole: row.staffRole ?? 'INTERNAL',
|
||||
permissions: row.permissions ?? [],
|
||||
status: row.status,
|
||||
});
|
||||
setSubEditOpen(true);
|
||||
}}
|
||||
onDelete={(subId) => void deleteSubAccount(subId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal title="新建城市合伙人" open={createOpen} width={560} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
maxPartnerCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
cityId,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%
|
||||
</Typography.Text>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="添加子账号" open={subOpen} onCancel={() => setSubOpen(false)} onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
void openPartner(detail.id);
|
||||
void loadPartners();
|
||||
onChanged?.();
|
||||
}}>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
|
||||
<Select options={STAFF_ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="编辑子账号" open={subEditOpen} onCancel={() => setSubEditOpen(false)} onOk={async () => {
|
||||
if (!subEditId || !detail) return;
|
||||
const v = await subEditForm.validateFields();
|
||||
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setSubEditOpen(false);
|
||||
void openPartner(detail.id);
|
||||
}}>
|
||||
<Form form={subEditForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="staffRole" label="角色"><Select options={STAFF_ROLE_OPTIONS} /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'DISABLED', label: '停用' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Button, Popconfirm, Space, Table, Tag, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
|
||||
export type PartnerSubAccountRow = {
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
};
|
||||
|
||||
function formatPermissions(permissions?: string[]) {
|
||||
return permissions?.map((k) => PARTNER_PERMISSION_LABELS[k as PartnerPermissionKey] || k).join('、') || '—';
|
||||
}
|
||||
|
||||
type Props = {
|
||||
subs: PartnerSubAccountRow[];
|
||||
onAdd: () => void;
|
||||
onEdit: (sub: PartnerSubAccountRow) => void;
|
||||
onDelete: (subId: string) => void;
|
||||
};
|
||||
|
||||
export default function PartnerSubAccountList({ subs, onAdd, onEdit, onDelete }: Props) {
|
||||
const columns: ColumnsType<PartnerSubAccountRow> = [
|
||||
{ title: '姓名', dataIndex: 'name', width: 100 },
|
||||
{ title: '手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'staffRole',
|
||||
width: 90,
|
||||
render: (v) =>
|
||||
v ? PARTNER_STAFF_ROLE_LABELS[v as keyof typeof PARTNER_STAFF_ROLE_LABELS] || v : '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s) => (
|
||||
<Tag color={s === 'ACTIVE' ? 'green' : 'default'}>{s === 'ACTIVE' ? '启用' : '停用'}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
ellipsis: true,
|
||||
render: (p: string[] | undefined) => formatPermissions(p),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => onEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="确定删除该子账号?" onConfirm={() => onDelete(row.id)}>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
子账号({subs.length})· 仅主账号可添加,不可多级
|
||||
</Typography.Text>
|
||||
<Button size="small" type="primary" onClick={onAdd}>
|
||||
添加子账号
|
||||
</Button>
|
||||
</div>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
columns={columns}
|
||||
dataSource={subs}
|
||||
locale={{ emptyText: '暂无子账号' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -51,9 +51,9 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
icon: <TeamOutlined />,
|
||||
label: '开城',
|
||||
children: [
|
||||
{ key: '/partners', label: '开城合伙人' },
|
||||
{ key: '/cities', label: '开城城市' },
|
||||
{ key: '/partner-accounts', label: '开城合伙人账户' },
|
||||
{ key: '/cities', label: '城市' },
|
||||
{ key: '/city-partners', label: '城市合伙人' },
|
||||
{ key: '/city-warehouses', label: '仓库' },
|
||||
{ key: '/partner-bills', label: '合伙人结算' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -2,14 +2,35 @@ import { codeToText, regionData } from 'element-china-area-data';
|
||||
|
||||
export { regionData as CHINA_REGION_OPTIONS };
|
||||
|
||||
export type ProvinceCityOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
children?: Array<{ value: string; label: string }>;
|
||||
};
|
||||
|
||||
/** 省 / 市二级(不含区县) */
|
||||
export const PROVINCE_CITY_OPTIONS: ProvinceCityOption[] = regionData.map((province) => ({
|
||||
value: province.value,
|
||||
label: province.label,
|
||||
children: province.children?.map((city) => ({
|
||||
value: city.value,
|
||||
label: city.label,
|
||||
})),
|
||||
}));
|
||||
|
||||
export type OpenCityRef = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
partnerId?: string | null;
|
||||
partner?: { id: string };
|
||||
partnerBindings?: Array<{ partnerAccountId: string }>;
|
||||
boundPartnerIds?: string[];
|
||||
};
|
||||
|
||||
function cityBoundPartnerIds(city: OpenCityRef): string[] {
|
||||
if (city.boundPartnerIds?.length) return city.boundPartnerIds.map(String);
|
||||
return (city.partnerBindings ?? []).map((b) => String(b.partnerAccountId));
|
||||
}
|
||||
|
||||
export type ParsedChinaRegion = {
|
||||
province: string;
|
||||
city: string;
|
||||
@@ -19,6 +40,39 @@ export type ParsedChinaRegion = {
|
||||
districtCode: string;
|
||||
};
|
||||
|
||||
export type ParsedProvinceCity = {
|
||||
province: string;
|
||||
city: string;
|
||||
provinceCode: string;
|
||||
cityCode: string;
|
||||
};
|
||||
|
||||
/** element-china-area-data 市级码(如 4101)→ 国标 6 位 adcode(410100) */
|
||||
export function normalizeCityAdcode(code: string): string {
|
||||
const raw = code.trim();
|
||||
if (/^\d{6}$/.test(raw)) return raw;
|
||||
if (/^\d{4}$/.test(raw)) return `${raw}00`;
|
||||
if (/^\d{2}$/.test(raw)) return `${raw}0100`;
|
||||
return raw.padEnd(6, '0').slice(0, 6);
|
||||
}
|
||||
|
||||
export function parseProvinceCityCodes(codes?: string[]): ParsedProvinceCity | null {
|
||||
if (!codes || codes.length < 2) return null;
|
||||
const [provinceCode, cityCode] = codes;
|
||||
const province = codeToText[provinceCode];
|
||||
let city = codeToText[cityCode];
|
||||
if (!province || !city) return null;
|
||||
if (city === '市辖区' || city === '县' || city === '省直辖县级行政区划') {
|
||||
city = province.endsWith('市') ? province : city;
|
||||
}
|
||||
return {
|
||||
province,
|
||||
city,
|
||||
provinceCode,
|
||||
cityCode: normalizeCityAdcode(cityCode),
|
||||
};
|
||||
}
|
||||
|
||||
/** 区县 adcode → 地级市 adcode(如 410105 → 410100) */
|
||||
export function districtCodeToCityCode(districtCode: string): string {
|
||||
if (districtCode.length < 6) return districtCode;
|
||||
@@ -42,14 +96,11 @@ export function formatRegionLabel(region: ParsedChinaRegion): string {
|
||||
export function matchOpenCityId(
|
||||
cities: OpenCityRef[],
|
||||
districtCode: string,
|
||||
partnerId?: string,
|
||||
partnerAccountId?: string,
|
||||
): string | undefined {
|
||||
const cityCode = districtCodeToCityCode(districtCode);
|
||||
const scoped = partnerId
|
||||
? cities.filter((c) => {
|
||||
const pid = c.partnerId ?? c.partner?.id;
|
||||
return !pid || String(pid) === partnerId;
|
||||
})
|
||||
const scoped = partnerAccountId
|
||||
? cities.filter((c) => cityBoundPartnerIds(c).includes(String(partnerAccountId)))
|
||||
: cities;
|
||||
return (
|
||||
scoped.find((c) => c.code === cityCode)?.id
|
||||
@@ -61,11 +112,11 @@ export function matchOpenCityId(
|
||||
export function resolveRegionBinding(
|
||||
codes: string[],
|
||||
cities: OpenCityRef[],
|
||||
partnerId?: string,
|
||||
partnerAccountId?: string,
|
||||
) {
|
||||
const region = parseRegionCodes(codes);
|
||||
if (!region) return null;
|
||||
const cityId = matchOpenCityId(cities, region.districtCode, partnerId);
|
||||
const cityId = matchOpenCityId(cities, region.districtCode, partnerAccountId);
|
||||
const matchedCity = cityId ? cities.find((c) => c.id === cityId) : undefined;
|
||||
return {
|
||||
region,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
export const HQ_OPERATION_ACTION_OPTIONS = [
|
||||
{ value: 'CITY_CREATE', label: '新增开城城市' },
|
||||
{ value: 'CITY_UPDATE', label: '编辑开城城市' },
|
||||
{ value: 'CITY_DELETE', label: '删除开城城市' },
|
||||
{ value: 'CITY_PARTNER_BIND', label: '绑定城市合伙人' },
|
||||
{ value: 'CITY_PARTNER_UPDATE', label: '编辑城市合伙人绑定' },
|
||||
{ value: 'CITY_PARTNER_UNBIND', label: '解绑城市合伙人' },
|
||||
{ value: 'WAREHOUSE_CREATE', label: '新增城市仓库' },
|
||||
{ value: 'WAREHOUSE_UPDATE', label: '编辑城市仓库' },
|
||||
{ value: 'WAREHOUSE_DELETE', label: '删除城市仓库' },
|
||||
{ value: 'PARTNER_CREATE', label: '新增城市合伙人' },
|
||||
{ value: 'PARTNER_UPDATE', label: '编辑城市合伙人' },
|
||||
{ value: 'PARTNER_ACCOUNT_CREATE', label: '新增合伙人账户' },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export type StoreCreateForm = {
|
||||
partnerId: string;
|
||||
partnerAccountId: string;
|
||||
cityId: string;
|
||||
regionCodes?: string[];
|
||||
province?: string;
|
||||
@@ -16,17 +16,18 @@ export type StoreCreateForm = {
|
||||
bankAccountName: string;
|
||||
bankAccountNo: string;
|
||||
bankBranch: string;
|
||||
settlementRate?: number;
|
||||
};
|
||||
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
const BANK_RE = /^\d{16,19}$/;
|
||||
|
||||
export function validateStoreCreateStep1(
|
||||
form: Pick<StoreCreateForm, 'partnerId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
||||
form: Pick<StoreCreateForm, 'partnerAccountId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
||||
): string | null {
|
||||
if (!form.partnerId) return '请选择开城合伙人';
|
||||
if (!form.partnerAccountId) return '请选择开城合伙人';
|
||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 开城城市」配置对应区划';
|
||||
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 城市」配置对应区划';
|
||||
if (!form.name?.trim()) return '请填写门店名称';
|
||||
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||
|
||||
@@ -1,24 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography, message,
|
||||
Button,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { WAREHOUSE_MANAGER_LABELS, WarehouseManagerType } from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { parseProvinceCityCodes, type ParsedProvinceCity } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, CITY_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import CityPartnersPanel from '../components/CityPartnersPanel';
|
||||
import ChinaProvinceCityCascader from '../components/ChinaProvinceCityCascader';
|
||||
|
||||
type Row = {
|
||||
id: string; code: string; name: string; province: string; status: string;
|
||||
storeCount: number; orderCount: number; createdAt: string;
|
||||
partner?: { id: string; companyName: string };
|
||||
type WarehouseRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId: string | null;
|
||||
partnerCompanyName?: string | null;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
type Row = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
province: string;
|
||||
status: string;
|
||||
storeCount: number;
|
||||
orderCount: number;
|
||||
partnerBindingCount?: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerOption = { id: string; companyName: string; cityId?: string | null };
|
||||
|
||||
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
||||
|
||||
export default function CitiesPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [warehouseForm] = Form.useForm();
|
||||
const [warehouseEditForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/cities',
|
||||
@@ -32,37 +71,132 @@ export default function CitiesPage() {
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [warehouses, setWarehouses] = useState<WarehouseRow[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [warehouseOpen, setWarehouseOpen] = useState(false);
|
||||
const [warehouseEditOpen, setWarehouseEditOpen] = useState(false);
|
||||
const [warehouseEditId, setWarehouseEditId] = useState<string | null>(null);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
const [createRegionPreview, setCreateRegionPreview] = useState<ParsedProvinceCity | null>(null);
|
||||
const createRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||
const [warehouseManagerType, setWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
const [editWarehouseManagerType, setEditWarehouseManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
|
||||
async function loadPartners() {
|
||||
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
const loadPartners = useCallback(async (cityId: string) => {
|
||||
const res = await request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`);
|
||||
setPartners(res.items);
|
||||
}, []);
|
||||
|
||||
const loadWarehouses = useCallback(async (cityId: string) => {
|
||||
const wh = await request<WarehouseRow[]>(`/admin/cities/${cityId}/warehouses`);
|
||||
setWarehouses(wh);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!createRegionCodes?.length) {
|
||||
setCreateRegionPreview(null);
|
||||
return;
|
||||
}
|
||||
const parsed = parseProvinceCityCodes(createRegionCodes as string[]);
|
||||
createForm.setFieldsValue({
|
||||
code: parsed?.cityCode,
|
||||
name: parsed?.city,
|
||||
province: parsed?.province,
|
||||
});
|
||||
setCreateRegionPreview(parsed);
|
||||
}, [createRegionCodes, createForm]);
|
||||
|
||||
function openCreateModal() {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ status: 'PENDING' });
|
||||
setCreateRegionPreview(null);
|
||||
setCreateOpen(true);
|
||||
}
|
||||
|
||||
const openDetail = async (row: Row) => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/cities/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({
|
||||
...d,
|
||||
maxPartnerCommissionPercent:
|
||||
d.maxPartnerCommissionRate != null
|
||||
? Number(d.maxPartnerCommissionRate) * 100
|
||||
: 5,
|
||||
});
|
||||
await loadPartners(row.id);
|
||||
await loadWarehouses(row.id);
|
||||
setDrawerOpen(true);
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '编码', dataIndex: 'code', width: 90 },
|
||||
{ title: '城市', dataIndex: 'name', width: 100 },
|
||||
{ title: '省份', dataIndex: 'province', width: 90 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: (s) => <Tag>{CITY_STATUS_LABELS[s] || s}</Tag> },
|
||||
{ title: '开城合伙人', dataIndex: ['partner', 'companyName'], width: 140, ellipsis: true },
|
||||
{ title: '合伙人', dataIndex: 'partnerBindingCount', width: 90 },
|
||||
{ title: '门店', dataIndex: 'storeCount', width: 70 },
|
||||
{ title: '订单', dataIndex: 'orderCount', width: 70 },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/cities/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({
|
||||
...d,
|
||||
partnerId: (d.partner as { id?: string } | null)?.id ?? (d as { partnerId?: string }).partnerId,
|
||||
});
|
||||
void loadPartners();
|
||||
setDrawerOpen(true);
|
||||
}}>编辑</Button>
|
||||
<Button type="link" size="small" onClick={() => void openDetail(row)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const warehouseColumns: ColumnsType<WarehouseRow> = [
|
||||
{ title: '仓库名', dataIndex: 'name' },
|
||||
{ title: '地址', dataIndex: 'address', ellipsis: true },
|
||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||
{
|
||||
title: '管仓',
|
||||
dataIndex: 'managerType',
|
||||
width: 100,
|
||||
render: (v: WarehouseManagerType) => WAREHOUSE_MANAGER_LABELS[v] || v,
|
||||
},
|
||||
{ title: '合伙人', dataIndex: 'partnerCompanyName', ellipsis: true, render: (v) => v || '—' },
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{s}</Tag> },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setWarehouseEditId(row.id);
|
||||
setEditWarehouseManagerType(row.managerType);
|
||||
warehouseEditForm.setFieldsValue(row);
|
||||
setWarehouseEditOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '确认删除仓库?',
|
||||
onOk: async () => {
|
||||
await request(`/admin/city-warehouses/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
if (detail?.id) await loadWarehouses(String(detail.id));
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -70,8 +204,8 @@ export default function CitiesPage() {
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城城市</Typography.Title>
|
||||
<Button type="primary" onClick={() => { void loadPartners(); setCreateOpen(true); }}>新建城市</Button>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>城市</Typography.Title>
|
||||
<Button type="primary" onClick={openCreateModal}>新建城市</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="name" label="城市"><Input allowClear /></Form.Item>
|
||||
@@ -81,59 +215,197 @@ export default function CitiesPage() {
|
||||
</Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="编辑开城城市" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/cities/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
)}>
|
||||
|
||||
<Drawer title="城市管理" width={720} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="编码">{String(detail.code)}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店数">{String(detail.storeCount ?? (detail as { _count?: { stores?: number } })._count?.stores ?? '—')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="partnerId" label="开城合伙人">
|
||||
<Select allowClear placeholder="选择合伙人" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="localMinQty" label="同城起订量"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="crossMinQty" label="跨城起订量"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<>
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="编码">{String(detail.code)}</Descriptions.Item>
|
||||
<Descriptions.Item label="合伙人数">{String(detail.partnerBindingCount ?? '—')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="localMinQty" label="同城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item name="crossMinQty" label="跨城起购"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
|
||||
<Form.Item
|
||||
name="maxPartnerCommissionPercent"
|
||||
label="合伙人佣金合计上限 %"
|
||||
extra="订单佣金 + 核销佣金不得超过此比例;不填默认 5%"
|
||||
>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} placeholder="5" />
|
||||
</Form.Item>
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
const { maxPartnerCommissionPercent, ...rest } = v;
|
||||
await request(`/admin/cities/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...rest,
|
||||
maxPartnerCommissionRate:
|
||||
maxPartnerCommissionPercent != null && maxPartnerCommissionPercent !== ''
|
||||
? Number(maxPartnerCommissionPercent) / 100
|
||||
: 0.05,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
const refreshed = await request<Record<string, unknown>>(`/admin/cities/${detail.id}`);
|
||||
setDetail(refreshed);
|
||||
void reload();
|
||||
}}>保存</Button>
|
||||
</Form>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'partners',
|
||||
label: '合伙人',
|
||||
children: detail?.id ? (
|
||||
<CityPartnersPanel
|
||||
cityId={String(detail.id)}
|
||||
maxPartnerCommissionRate={
|
||||
detail.maxPartnerCommissionRate != null
|
||||
? Number(detail.maxPartnerCommissionRate)
|
||||
: 0.05
|
||||
}
|
||||
onChanged={() => {
|
||||
void reload();
|
||||
void loadPartners(String(detail.id));
|
||||
}}
|
||||
/>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
key: 'warehouses',
|
||||
label: '仓库管理',
|
||||
children: (
|
||||
<>
|
||||
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => {
|
||||
warehouseForm.resetFields();
|
||||
warehouseForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: 'ACTIVE' });
|
||||
setWarehouseManagerType(WarehouseManagerType.HQ);
|
||||
setWarehouseOpen(true);
|
||||
}}>新增仓库</Button>
|
||||
<Table rowKey="id" size="small" columns={warehouseColumns} dataSource={warehouses} pagination={false} />
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal title="新建开城城市" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/cities', { method: 'POST', body: JSON.stringify(v) });
|
||||
if (!v.code || !v.name || !v.province) {
|
||||
message.error('请选择省 / 市');
|
||||
return;
|
||||
}
|
||||
await request('/admin/cities', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
code: v.code,
|
||||
name: v.name,
|
||||
province: v.province,
|
||||
status: v.status,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
setCreateRegionPreview(null);
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="code" label="城市编码" rules={[{ required: true }]}><Input placeholder="如 ZZ" /></Form.Item>
|
||||
<Form.Item name="name" label="城市名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="province" label="省份" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="partnerId" label="开城合伙人">
|
||||
<Select allowClear placeholder="选择合伙人" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
<Form.Item
|
||||
name="regionCodes"
|
||||
label="所在地区"
|
||||
rules={[{ required: true, message: '请选择省 / 市' }]}
|
||||
>
|
||||
<ChinaProvinceCityCascader />
|
||||
</Form.Item>
|
||||
{createRegionPreview ? (
|
||||
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="城市编码">{createRegionPreview.cityCode}</Descriptions.Item>
|
||||
<Descriptions.Item label="省份">{createRegionPreview.province}</Descriptions.Item>
|
||||
<Descriptions.Item label="城市名">{createRegionPreview.city}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
选择省 / 市后,城市编码将自动填入(不可编辑)
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Form.Item name="code" hidden rules={[{ required: true, message: '请选择省 / 市' }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" hidden rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="province" hidden rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="status" label="状态" initialValue="PENDING">
|
||||
<Select options={Object.entries(CITY_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="新增仓库" open={warehouseOpen} onCancel={() => setWarehouseOpen(false)} onOk={async () => {
|
||||
const v = await warehouseForm.validateFields();
|
||||
await request(`/admin/cities/${detail?.id}/warehouses`, { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setWarehouseOpen(false);
|
||||
if (detail?.id) await loadWarehouses(String(detail.id));
|
||||
}}>
|
||||
<Form form={warehouseForm} layout="vertical">
|
||||
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
|
||||
<Select options={MANAGER_OPTIONS} onChange={(v) => setWarehouseManagerType(v)} />
|
||||
</Form.Item>
|
||||
{warehouseManagerType === WarehouseManagerType.PARTNER && (
|
||||
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
|
||||
<Select options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="status" label="状态" initialValue="ACTIVE">
|
||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'PAUSED', label: '暂停' }]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal title="编辑仓库" open={warehouseEditOpen} onCancel={() => setWarehouseEditOpen(false)} onOk={async () => {
|
||||
const v = await warehouseEditForm.validateFields();
|
||||
await request(`/admin/city-warehouses/${warehouseEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setWarehouseEditOpen(false);
|
||||
if (detail?.id) await loadWarehouses(String(detail.id));
|
||||
}}>
|
||||
<Form form={warehouseEditForm} layout="vertical">
|
||||
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
|
||||
<Select options={MANAGER_OPTIONS} onChange={(v) => setEditWarehouseManagerType(v)} />
|
||||
</Form.Item>
|
||||
{editWarehouseManagerType === WarehouseManagerType.PARTNER && (
|
||||
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
|
||||
<Select options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={[{ value: 'ACTIVE', label: '启用' }, { value: 'PAUSED', label: '暂停' }]} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,645 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Cascader,
|
||||
Checkbox,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CITY_PARTNER_STATUS_LABELS,
|
||||
CityPartnerScopeType,
|
||||
CityPartnerStatus,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
PARTNER_STAFF_ROLE_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import PartnerSubAccountList, { type PartnerSubAccountRow } from '../components/PartnerSubAccountList';
|
||||
|
||||
type SubRow = PartnerSubAccountRow;
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
contactPhone?: string | null;
|
||||
cityId?: string | null;
|
||||
cityName?: string | null;
|
||||
scopeType?: string;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
bindingStatus?: string;
|
||||
managedWarehouseId?: string | null;
|
||||
managedWarehouseName?: string | null;
|
||||
maxPartnerCommissionRate?: number | null;
|
||||
storeCount: number;
|
||||
accountCount: number;
|
||||
subAccounts?: SubRow[];
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = Row & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
/** 详情接口仍返回 children */
|
||||
children?: SubRow[];
|
||||
};
|
||||
|
||||
function toTableRows(items: Row[]): Row[] {
|
||||
return items.map((row) => {
|
||||
const { children, subAccounts, ...rest } = row as Row & { children?: SubRow[] };
|
||||
return { ...rest, subAccounts: subAccounts ?? children ?? [] };
|
||||
});
|
||||
}
|
||||
|
||||
type CityOption = { id: string; name: string; code: string };
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const BINDING_OPTIONS = Object.entries(CITY_PARTNER_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({
|
||||
value: k,
|
||||
label: PARTNER_PERMISSION_LABELS[k],
|
||||
}));
|
||||
const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS)
|
||||
.filter(([value]) => value !== 'PARTNER')
|
||||
.map(([value, label]) => ({ value, label }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
function commissionSumError(orderPercent: number, redeemPercent: number, maxRate: number): string | null {
|
||||
const sum = orderPercent / 100 + redeemPercent / 100;
|
||||
if (sum > maxRate + 1e-9) {
|
||||
return `订单佣金与核销佣金合计不得超过 ${(maxRate * 100).toFixed(2)}%(当前 ${(sum * 100).toFixed(2)}%)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function CityPartnersPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [subEditForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partners',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.companyName) qs.set('companyName', filters.companyName);
|
||||
if (filters.phone) qs.set('phone', filters.phone);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subEditOpen, setSubEditOpen] = useState(false);
|
||||
const [subEditId, setSubEditId] = useState<string | null>(null);
|
||||
const [subEditParentId, setSubEditParentId] = useState<string | null>(null);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [maxCommissionRate, setMaxCommissionRate] = useState(0.05);
|
||||
const [createMaxRate, setCreateMaxRate] = useState(0.05);
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setCities(res.items);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCities();
|
||||
}, [loadCities]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
setMaxCommissionRate(d.maxPartnerCommissionRate ?? 0.05);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus ?? CityPartnerStatus.ACTIVE,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
return d;
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 0),
|
||||
maxCommissionRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request(`/admin/partners/${detail.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}
|
||||
|
||||
async function refreshPartnerContext(parentId: string) {
|
||||
if (detail?.id === parentId) {
|
||||
await openPartner(parentId);
|
||||
}
|
||||
void reload();
|
||||
}
|
||||
|
||||
async function deleteSubAccount(subId: string, parentId: string) {
|
||||
await request(`/admin/partner-accounts/${subId}`, { method: 'DELETE' });
|
||||
message.success('子账号已删除');
|
||||
await refreshPartnerContext(parentId);
|
||||
}
|
||||
|
||||
function openSubEdit(row: SubRow, parentId: string) {
|
||||
setSubEditId(row.id);
|
||||
setSubEditParentId(parentId);
|
||||
subEditForm.setFieldsValue({
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
staffRole: row.staffRole ?? 'INTERNAL',
|
||||
permissions: row.permissions ?? [],
|
||||
status: row.status,
|
||||
});
|
||||
setSubEditOpen(true);
|
||||
}
|
||||
|
||||
async function openAddSubAccount(parentId: string) {
|
||||
await openPartner(parentId);
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
||||
setSubOpen(true);
|
||||
}
|
||||
|
||||
async function onCreateCityChange(cityId: string) {
|
||||
const cityRes = await request<{ maxPartnerCommissionRate?: number }>(`/admin/cities/${cityId}`);
|
||||
setCreateMaxRate(cityRes.maxPartnerCommissionRate ?? 0.05);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 90 },
|
||||
{ title: '主账号姓名', dataIndex: 'name', width: 100, ellipsis: true },
|
||||
{ title: '登录手机', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 90,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{
|
||||
title: '佣金',
|
||||
width: 110,
|
||||
render: (_, row) =>
|
||||
`${Math.round((row.orderCommissionRate ?? 0) * 100)}% / ${Math.round((row.redeemCommissionRate ?? 0.03) * 100)}%`,
|
||||
},
|
||||
{
|
||||
title: '绑定状态',
|
||||
dataIndex: 'bindingStatus',
|
||||
width: 80,
|
||||
render: (v) => (
|
||||
<Tag color={v === CityPartnerStatus.ACTIVE ? 'green' : 'default'}>
|
||||
{CITY_PARTNER_STATUS_LABELS[v as CityPartnerStatus] || v || '—'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '管仓仓库',
|
||||
dataIndex: 'managedWarehouseName',
|
||||
width: 110,
|
||||
ellipsis: true,
|
||||
render: (v) => v || '—',
|
||||
},
|
||||
{ title: '门店', dataIndex: 'storeCount', width: 60 },
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 70, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
城市合伙人
|
||||
</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 3,
|
||||
scopeType: CityPartnerScopeType.CITY_WIDE,
|
||||
});
|
||||
setCreateScopeType(CityPartnerScopeType.CITY_WIDE);
|
||||
setCreateMaxRate(0.05);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建合伙人
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={
|
||||
<>
|
||||
账号结构仅两级:主账号 → 子账号(子账号不可再添加下级)。管仓仓库请在{' '}
|
||||
<Link to="/city-warehouses">仓库管理</Link> 中分配。
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="companyName" label="公司">
|
||||
<Input allowClear placeholder="公司名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机">
|
||||
<Input allowClear placeholder="登录手机" />
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="城市">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 160 }}
|
||||
placeholder="全部城市"
|
||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
filterForm.resetFields();
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={toTableRows(data?.items ?? [])}
|
||||
scroll={{ x: 1200 }}
|
||||
childrenColumnName="__noTreeChildren__"
|
||||
expandable={{
|
||||
expandedRowRender: (record) => (
|
||||
<PartnerSubAccountList
|
||||
subs={record.subAccounts ?? []}
|
||||
onAdd={() => void openAddSubAccount(record.id)}
|
||||
onEdit={(sub) => openSubEdit(sub, record.id)}
|
||||
onDelete={(subId) => void deleteSubAccount(subId, record.id)}
|
||||
/>
|
||||
),
|
||||
rowExpandable: () => true,
|
||||
columnWidth: 40,
|
||||
}}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="城市合伙人"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => void savePartner()}>
|
||||
保存
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="城市">{detail.cityName ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="门店数">{detail.storeCount ?? 0}</Descriptions.Item>
|
||||
<Descriptions.Item label="管仓仓库" span={2}>
|
||||
{detail.managedWarehouseName ?? (
|
||||
<Typography.Text type="secondary">
|
||||
未分配(请前往 <Link to="/city-warehouses">仓库管理</Link> 设置)
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '主账号',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contactPhone" label="业务联系电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bindingStatus" label="绑定状态" rules={[{ required: true }]}>
|
||||
<Select options={BINDING_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县">
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %">
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %">
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
|
||||
订单 + 核销合计不得超过 {(maxCommissionRate * 100).toFixed(2)}%
|
||||
</Typography.Text>
|
||||
<Form.Item name="bankAccountName" label="户名">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`,
|
||||
children: (
|
||||
<PartnerSubAccountList
|
||||
subs={detail.children ?? []}
|
||||
onAdd={() => {
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
||||
setSubOpen(true);
|
||||
}}
|
||||
onEdit={(sub) => openSubEdit(sub, detail.id)}
|
||||
onDelete={(subId) => void deleteSubAccount(subId, detail.id)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title="新建城市合伙人"
|
||||
open={createOpen}
|
||||
width={560}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
const err = commissionSumError(
|
||||
Number(v.orderCommissionRate ?? 0),
|
||||
Number(v.redeemCommissionRate ?? 3),
|
||||
createMaxRate,
|
||||
);
|
||||
if (err) {
|
||||
message.error(err);
|
||||
return;
|
||||
}
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes:
|
||||
createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
||||
onChange={(id) => void onCreateCityChange(id)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %">
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %">
|
||||
<InputNumber min={0} max={100} precision={2} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" style={{ display: 'block' }}>
|
||||
订单 + 核销合计不得超过 {(createMaxRate * 100).toFixed(2)}%(由所选城市配置决定)
|
||||
</Typography.Text>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={detail ? `添加子账号 · ${detail.companyName}` : '添加子账号'}
|
||||
open={subOpen}
|
||||
onCancel={() => setSubOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
await refreshPartnerContext(detail.id);
|
||||
}}
|
||||
>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="staffRole" label="角色" initialValue="INTERNAL">
|
||||
<Select options={STAFF_ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="编辑子账号"
|
||||
open={subEditOpen}
|
||||
onCancel={() => setSubEditOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!subEditId) return;
|
||||
const parentId = detail?.id ?? subEditParentId;
|
||||
if (!parentId) return;
|
||||
const v = await subEditForm.validateFields();
|
||||
await request(`/admin/partner-accounts/${subEditId}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
message.success('已更新');
|
||||
setSubEditOpen(false);
|
||||
await refreshPartnerContext(parentId);
|
||||
}}
|
||||
>
|
||||
<Form form={subEditForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="staffRole" label="角色">
|
||||
<Select options={STAFF_ROLE_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'ACTIVE', label: '启用' },
|
||||
{ value: 'DISABLED', label: '停用' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
WAREHOUSE_MANAGER_LABELS,
|
||||
WAREHOUSE_STATUS_LABELS,
|
||||
WarehouseManagerType,
|
||||
WarehouseStatus,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
cityId: string;
|
||||
cityName: string;
|
||||
cityCode: string;
|
||||
name: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId: string | null;
|
||||
partnerCompanyName?: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type CityOption = { id: string; name: string; code: string };
|
||||
type PartnerOption = { id: string; companyName: string };
|
||||
|
||||
const MANAGER_OPTIONS = Object.entries(WAREHOUSE_MANAGER_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const STATUS_OPTIONS = Object.entries(WAREHOUSE_STATUS_LABELS).map(([value, label]) => ({ value, label }));
|
||||
|
||||
export default function CityWarehousesPage() {
|
||||
const [filterForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/city-warehouses',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.name) qs.set('name', filters.name);
|
||||
if (filters.cityId) qs.set('cityId', filters.cityId);
|
||||
if (filters.managerType) qs.set('managerType', filters.managerType);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editRow, setEditRow] = useState<Row | null>(null);
|
||||
const [createManagerType, setCreateManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
const [editManagerType, setEditManagerType] = useState<WarehouseManagerType>(WarehouseManagerType.HQ);
|
||||
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setCities(res.items);
|
||||
}, []);
|
||||
|
||||
const loadPartners = useCallback(async (cityId: string) => {
|
||||
const res = await request<Paginated<PartnerOption>>(
|
||||
`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`,
|
||||
);
|
||||
setPartners(res.items);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCities();
|
||||
}, [loadCities]);
|
||||
|
||||
async function openEdit(row: Row) {
|
||||
setEditRow(row);
|
||||
setEditManagerType(row.managerType);
|
||||
await loadPartners(row.cityId);
|
||||
editForm.setFieldsValue({
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
contactName: row.contactName,
|
||||
contactPhone: row.contactPhone,
|
||||
managerType: row.managerType,
|
||||
partnerAccountId: row.partnerAccountId,
|
||||
status: row.status,
|
||||
});
|
||||
setEditOpen(true);
|
||||
}
|
||||
|
||||
function onManagerTypeChange(
|
||||
type: WarehouseManagerType,
|
||||
form: typeof createForm | typeof editForm,
|
||||
setType: (v: WarehouseManagerType) => void,
|
||||
) {
|
||||
setType(type);
|
||||
if (type !== WarehouseManagerType.PARTNER) {
|
||||
form.setFieldValue('partnerAccountId', undefined);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 },
|
||||
{
|
||||
title: '城市',
|
||||
width: 100,
|
||||
render: (_, row) => (
|
||||
<span title={row.cityCode}>
|
||||
{row.cityName}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: '地址', dataIndex: 'address', ellipsis: true },
|
||||
{ title: '联系人', dataIndex: 'contactName', width: 90 },
|
||||
{ title: '电话', dataIndex: 'contactPhone', width: 120 },
|
||||
{
|
||||
title: '管仓类型',
|
||||
dataIndex: 'managerType',
|
||||
width: 100,
|
||||
render: (v) => WAREHOUSE_MANAGER_LABELS[v as WarehouseManagerType] || v,
|
||||
},
|
||||
{
|
||||
title: '管仓合伙人',
|
||||
dataIndex: 'partnerCompanyName',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (v, row) =>
|
||||
v && row.partnerAccountId ? (
|
||||
<Link to="/city-partners">{v}</Link>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s) => (
|
||||
<Tag color={s === WarehouseStatus.ACTIVE ? 'green' : 'default'}>
|
||||
{WAREHOUSE_STATUS_LABELS[s as WarehouseStatus] || s}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime },
|
||||
{
|
||||
title: '操作',
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space size={0}>
|
||||
<Button type="link" size="small" onClick={() => void openEdit(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确定删除该仓库?"
|
||||
description="删除后将解除关联合伙人的管仓绑定"
|
||||
onConfirm={async () => {
|
||||
await request(`/admin/city-warehouses/${row.id}`, { method: 'DELETE' });
|
||||
message.success('已删除');
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>
|
||||
仓库管理
|
||||
</Typography.Title>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ managerType: WarehouseManagerType.HQ, status: WarehouseStatus.ACTIVE });
|
||||
setCreateManagerType(WarehouseManagerType.HQ);
|
||||
setCreateCityId(undefined);
|
||||
setPartners([]);
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
新建仓库
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="管仓合伙人仅可在此页面分配;城市合伙人详情中的管仓仓库为只读展示。"
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={filterForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16 }}
|
||||
onFinish={(v) => {
|
||||
setFilters(v);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<Form.Item name="name" label="仓库名">
|
||||
<Input allowClear placeholder="仓库名" />
|
||||
</Form.Item>
|
||||
<Form.Item name="cityId" label="城市">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 140 }}
|
||||
placeholder="全部城市"
|
||||
options={cities.map((c) => ({ value: c.id, label: c.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="managerType" label="管仓类型">
|
||||
<Select allowClear style={{ width: 120 }} placeholder="全部" options={MANAGER_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 100 }} placeholder="全部" options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
filterForm.resetFields();
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
showTotal: (t) => `共 ${t} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="新建仓库"
|
||||
open={createOpen}
|
||||
width={520}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request(`/admin/cities/${v.cityId}/warehouses`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(v),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
||||
onChange={(id) => {
|
||||
setCreateCityId(id);
|
||||
createForm.setFieldValue('partnerAccountId', undefined);
|
||||
void loadPartners(id);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={MANAGER_OPTIONS}
|
||||
onChange={(v) => onManagerTypeChange(v, createForm, setCreateManagerType)}
|
||||
/>
|
||||
</Form.Item>
|
||||
{createManagerType === WarehouseManagerType.PARTNER && createCityId && (
|
||||
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder={partners.length ? '选择合伙人' : '该城市暂无合伙人'}
|
||||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="status" label="状态" initialValue={WarehouseStatus.ACTIVE}>
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="编辑仓库"
|
||||
open={editOpen}
|
||||
width={520}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!editRow) return;
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/city-warehouses/${editRow.id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(v),
|
||||
});
|
||||
message.success('已更新');
|
||||
setEditOpen(false);
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
{editRow && (
|
||||
<Descriptions column={1} size="small" bordered style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="所属城市">
|
||||
{editRow.cityName}({editRow.cityCode})
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="name" label="仓库名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contactName" label="联系人" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="managerType" label="管仓类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={MANAGER_OPTIONS}
|
||||
onChange={(v) => onManagerTypeChange(v, editForm, setEditManagerType)}
|
||||
/>
|
||||
</Form.Item>
|
||||
{editManagerType === WarehouseManagerType.PARTNER && editRow && (
|
||||
<Form.Item name="partnerAccountId" label="管仓合伙人" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select options={STATUS_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message,
|
||||
Button, Checkbox, Drawer, Form, Input, Modal, Popconfirm, Select, Space, Table, Tabs, Tag, Typography, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PARTNER_STAFF_ROLE_LABELS } from '@dukang/shared-types';
|
||||
import { PARTNER_PERMISSION_KEYS, PARTNER_PERMISSION_LABELS, PARTNER_STAFF_ROLE_LABELS, type PartnerPermissionKey } from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { AdminCellLine } from '../components/AdminCellLine';
|
||||
import { ACCOUNT_STATUS_LABELS, ADMIN_OPTIONS_PAGE_SIZE, ORDER_STATUS_LABELS, PARTNER_BILL_STATUS_LABELS, fmtTime } from '../lib/constants';
|
||||
@@ -18,11 +18,12 @@ type AccountTreeRow = {
|
||||
status: string;
|
||||
isPrimary: number;
|
||||
staffRole?: string | null;
|
||||
permissions?: string[] | null;
|
||||
parentAccountId?: string | null;
|
||||
parent?: ParentAccount | null;
|
||||
createdAt: string;
|
||||
lastLoginAt?: string | null;
|
||||
partner?: { id: string; companyName: string };
|
||||
companyName?: string | null;
|
||||
children?: AccountTreeRow[];
|
||||
};
|
||||
|
||||
@@ -43,6 +44,11 @@ const STAFF_ROLE_OPTIONS = Object.entries(PARTNER_STAFF_ROLE_LABELS).map(([value
|
||||
label,
|
||||
}));
|
||||
|
||||
const PERMISSION_OPTIONS = PARTNER_PERMISSION_KEYS.map((key: PartnerPermissionKey) => ({
|
||||
value: key,
|
||||
label: PARTNER_PERMISSION_LABELS[key],
|
||||
}));
|
||||
|
||||
function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): AccountTreeRow[] {
|
||||
const phoneQ = phone?.trim();
|
||||
const match = (row: AccountTreeRow) => {
|
||||
@@ -65,10 +71,6 @@ function filterTree(rows: AccountTreeRow[], phone?: string, status?: string): Ac
|
||||
return walk(rows);
|
||||
}
|
||||
|
||||
function accountTypeLabel(isPrimary: number) {
|
||||
return isPrimary === 1 ? '主账号' : '子账号';
|
||||
}
|
||||
|
||||
function staffRoleLabel(role?: string | null) {
|
||||
if (!role) return '-';
|
||||
return PARTNER_STAFF_ROLE_LABELS[role as keyof typeof PARTNER_STAFF_ROLE_LABELS] ?? role;
|
||||
@@ -77,7 +79,6 @@ function staffRoleLabel(role?: string | null) {
|
||||
export default function PartnerAccountsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<{ phone?: string; status?: string; partnerId?: string }>({});
|
||||
const [treeData, setTreeData] = useState<AccountTreeRow[]>([]);
|
||||
@@ -85,7 +86,6 @@ export default function PartnerAccountsPage() {
|
||||
const [expandedRowKeys, setExpandedRowKeys] = useState<string[]>([]);
|
||||
const [detail, setDetail] = useState<Detail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [subParent, setSubParent] = useState<AccountTreeRow | null>(null);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
@@ -117,6 +117,7 @@ export default function PartnerAccountsPage() {
|
||||
name: detail.name,
|
||||
phone: detail.phone,
|
||||
status: detail.status,
|
||||
permissions: detail.permissions ?? [],
|
||||
});
|
||||
}, [drawerOpen, detail, editForm]);
|
||||
|
||||
@@ -153,7 +154,7 @@ export default function PartnerAccountsPage() {
|
||||
function openAddSub(parent: AccountTreeRow) {
|
||||
setSubParent(parent);
|
||||
subForm.resetFields();
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL' });
|
||||
subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] });
|
||||
setSubOpen(true);
|
||||
}
|
||||
|
||||
@@ -179,9 +180,7 @@ export default function PartnerAccountsPage() {
|
||||
secondary={
|
||||
row.parentAccountId
|
||||
? `子账号 · ${staffRoleLabel(row.staffRole)}`
|
||||
: row.isPrimary === 1
|
||||
? '主账号'
|
||||
: '合伙人账号'
|
||||
: '主账号'
|
||||
}
|
||||
/>
|
||||
),
|
||||
@@ -191,15 +190,14 @@ export default function PartnerAccountsPage() {
|
||||
title: '开城合伙人',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (_, row) => row.partner?.companyName || '—',
|
||||
render: (_, row) => row.companyName || row.parent?.name || '—',
|
||||
},
|
||||
{
|
||||
title: '账号类型',
|
||||
dataIndex: 'isPrimary',
|
||||
width: 90,
|
||||
render: (v, row) => (
|
||||
<Tag color={row.parentAccountId ? 'default' : v === 1 ? 'blue' : 'default'}>
|
||||
{row.parentAccountId ? '子账号' : accountTypeLabel(v)}
|
||||
render: (_, row) => (
|
||||
<Tag color={row.parentAccountId ? 'default' : 'blue'}>
|
||||
{row.parentAccountId ? '子账号' : '主账号'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
@@ -213,9 +211,9 @@ export default function PartnerAccountsPage() {
|
||||
title: '所属主账号',
|
||||
width: 140,
|
||||
render: (_, row) => (
|
||||
row.isPrimary === 1 || !row.parentAccountId
|
||||
? '-'
|
||||
: (row.parent ? `${row.parent.name} / ${row.parent.phone}` : '-')
|
||||
row.parentAccountId && row.parent
|
||||
? `${row.parent.name} / ${row.parent.phone}`
|
||||
: '-'
|
||||
),
|
||||
},
|
||||
{ title: '状态', dataIndex: 'status', width: 80, render: (s) => <Tag>{ACCOUNT_STATUS_LABELS[s] || s}</Tag> },
|
||||
@@ -226,7 +224,7 @@ export default function PartnerAccountsPage() {
|
||||
render: (_, row) => (
|
||||
<Space size={0} wrap onClick={(e) => e.stopPropagation()}>
|
||||
<Button type="link" size="small" onClick={() => void openAccount(row.id)}>详情</Button>
|
||||
{!row.parentAccountId && row.isPrimary === 1 ? (
|
||||
{!row.parentAccountId ? (
|
||||
<Button type="link" size="small" onClick={() => openAddSub(row)}>添加子账号</Button>
|
||||
) : null}
|
||||
{row.parentAccountId ? (
|
||||
@@ -260,10 +258,11 @@ export default function PartnerAccountsPage() {
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人账户</Typography.Title>
|
||||
<Typography.Text type="secondary">主账号下可展开/收起子账号,支持添加与删除子账号</Typography.Text>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>合伙人子账号</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
主账号在「开城合伙人」创建;此处仅管理子账号树与权限
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button type="primary" onClick={() => { void loadPartners(); setCreateOpen(true); }}>新建账户</Button>
|
||||
</Space>
|
||||
<Form
|
||||
form={form}
|
||||
@@ -315,7 +314,7 @@ export default function PartnerAccountsPage() {
|
||||
}}
|
||||
/>
|
||||
<Drawer
|
||||
title={detail?.parentAccountId ? '合伙人子账号详情' : '开城合伙人账户详情'}
|
||||
title={detail?.parentAccountId ? '子账号详情' : '主账号详情(只读)'}
|
||||
width={720}
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
@@ -323,7 +322,11 @@ export default function PartnerAccountsPage() {
|
||||
setDetail(null);
|
||||
}}
|
||||
destroyOnClose
|
||||
extra={<Button type="primary" onClick={() => void saveAccount()}>保存</Button>}
|
||||
extra={
|
||||
detail?.parentAccountId
|
||||
? <Button type="primary" onClick={() => void saveAccount()}>保存</Button>
|
||||
: null
|
||||
}
|
||||
>
|
||||
{detail && (
|
||||
<Tabs items={[
|
||||
@@ -339,32 +342,28 @@ export default function PartnerAccountsPage() {
|
||||
name: detail.name,
|
||||
phone: detail.phone,
|
||||
status: detail.status,
|
||||
permissions: detail.permissions ?? [],
|
||||
}}
|
||||
>
|
||||
<Form.Item label="开城合伙人">
|
||||
<Input value={detail.partner?.companyName} disabled />
|
||||
<Input value={detail.companyName || detail.parent?.name || '—'} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item label="账号类型">
|
||||
<Input
|
||||
value={
|
||||
detail.parentAccountId
|
||||
? `子账号 · ${staffRoleLabel(detail.staffRole)}`
|
||||
: accountTypeLabel(detail.isPrimary)
|
||||
: '主账号'
|
||||
}
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
{detail.staffRole ? (
|
||||
<Form.Item label="角色">
|
||||
<Input value={staffRoleLabel(detail.staffRole)} disabled />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{detail.parentAccountId && detail.parent ? (
|
||||
<Form.Item label="所属主账号">
|
||||
<Input value={`${detail.parent.name} / ${detail.parent.phone}`} disabled />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input disabled={!detail.parentAccountId} /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="登录手机"
|
||||
@@ -373,14 +372,25 @@ export default function PartnerAccountsPage() {
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={11} />
|
||||
<Input maxLength={11} disabled={!detail.parentAccountId} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
<Select disabled={!detail.parentAccountId} options={Object.entries(ACCOUNT_STATUS_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Typography.Text type="secondary">
|
||||
合伙人 H5 登录使用「登录手机」,与开城合伙人主体的「联系电话」可不同。
|
||||
</Typography.Text>
|
||||
{detail.parentAccountId ? (
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERMISSION_OPTIONS} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{!detail.parentAccountId ? (
|
||||
<Typography.Text type="secondary">
|
||||
主账号请在「开城合伙人」页面编辑。
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">
|
||||
合伙人 H5 登录使用「登录手机」,与主账号联系电话可不同。
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
@@ -403,31 +413,6 @@ export default function PartnerAccountsPage() {
|
||||
]} />
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建开城合伙人账户" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/partner-accounts', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void loadTree();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true }]}>
|
||||
<Select showSearch optionFilterProp="label" options={partners.map((p) => ({ value: p.id, label: p.companyName }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item
|
||||
name="phone"
|
||||
label="登录手机"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码' },
|
||||
]}
|
||||
>
|
||||
<Input maxLength={11} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
<Modal
|
||||
title={subParent ? `添加子账号 · ${subParent.name}` : '添加子账号'}
|
||||
open={subOpen}
|
||||
@@ -438,11 +423,11 @@ export default function PartnerAccountsPage() {
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
partnerId: subParent.partner?.id,
|
||||
parentAccountId: subParent.id,
|
||||
name: v.name,
|
||||
phone: v.phone,
|
||||
staffRole: v.staffRole,
|
||||
permissions: v.permissions,
|
||||
}),
|
||||
});
|
||||
message.success('子账号已创建');
|
||||
@@ -466,6 +451,9 @@ export default function PartnerAccountsPage() {
|
||||
<Form.Item name="staffRole" label="角色" rules={[{ required: true }]}>
|
||||
<Select options={STAFF_ROLE_OPTIONS.filter((o) => o.value !== 'PARTNER')} />
|
||||
</Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Checkbox.Group options={PERMISSION_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -1,39 +1,86 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button, Descriptions, Drawer, Form, Input, Modal, Space, Table, Typography, message,
|
||||
Button,
|
||||
Cascader,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import {
|
||||
CITY_PARTNER_SCOPE_LABELS,
|
||||
CityPartnerScopeType,
|
||||
PARTNER_PERMISSION_KEYS,
|
||||
PARTNER_PERMISSION_LABELS,
|
||||
type PartnerPermissionKey,
|
||||
} from '@dukang/shared-types';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { CHINA_REGION_OPTIONS } from '../lib/china-region';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string; companyName: string; contactPhone: string; address: string;
|
||||
storeCount: number; accountCount: number; cityCount: number; createdAt: string;
|
||||
id: string;
|
||||
companyName: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
contactPhone?: string | null;
|
||||
cityId?: string | null;
|
||||
cityName?: string | null;
|
||||
scopeType?: string;
|
||||
orderCommissionRate?: number;
|
||||
redeemCommissionRate?: number;
|
||||
storeCount: number;
|
||||
accountCount: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type PartnerDetail = Row & {
|
||||
address?: string;
|
||||
districtCodes?: string[] | null;
|
||||
bindingStatus?: string;
|
||||
bankAccountName?: string | null;
|
||||
bankAccountNo?: string | null;
|
||||
bankBranch?: string | null;
|
||||
cities?: Array<{ name: string; code: string; status: string }>;
|
||||
managedWarehouseId?: string | null;
|
||||
children?: Array<{
|
||||
id: string;
|
||||
phone: string;
|
||||
name: string;
|
||||
staffRole?: string;
|
||||
permissions?: string[];
|
||||
status: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function pickPartnerFormValues(d: PartnerDetail) {
|
||||
return {
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone,
|
||||
address: d.address,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
};
|
||||
type CityOption = { id: string; name: string; code: string };
|
||||
type WarehouseOption = { id: string; name: string };
|
||||
|
||||
const SCOPE_OPTIONS = Object.entries(CITY_PARTNER_SCOPE_LABELS).map(([value, label]) => ({ value, label }));
|
||||
const PERM_OPTIONS = PARTNER_PERMISSION_KEYS.map((k: PartnerPermissionKey) => ({ value: k, label: PARTNER_PERMISSION_LABELS[k] }));
|
||||
|
||||
function flattenDistrictCodes(values: string[] | string[][] | undefined): string[] {
|
||||
if (!values?.length) return [];
|
||||
if (Array.isArray(values[0])) {
|
||||
return (values as string[][]).map((path) => path[path.length - 1]).filter(Boolean);
|
||||
}
|
||||
return values as string[];
|
||||
}
|
||||
|
||||
export default function PartnersPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [editForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [subForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partners',
|
||||
@@ -48,37 +95,86 @@ export default function PartnersPage() {
|
||||
const [detail, setDetail] = useState<PartnerDetail | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [subOpen, setSubOpen] = useState(false);
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [warehouses, setWarehouses] = useState<WarehouseOption[]>([]);
|
||||
const [createScopeType, setCreateScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [editScopeType, setEditScopeType] = useState<CityPartnerScopeType>(CityPartnerScopeType.CITY_WIDE);
|
||||
const [createCityId, setCreateCityId] = useState<string | undefined>();
|
||||
|
||||
const loadCities = useCallback(async () => {
|
||||
const res = await request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`);
|
||||
setCities(res.items);
|
||||
}, []);
|
||||
|
||||
const loadWarehouses = useCallback(async (cityId: string) => {
|
||||
const rows = await request<WarehouseOption[]>(`/admin/cities/${cityId}/warehouses`);
|
||||
setWarehouses(rows.map((w) => ({ id: w.id, name: (w as { name: string }).name })));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCities();
|
||||
}, [loadCities]);
|
||||
|
||||
async function openPartner(id: string) {
|
||||
const d = await request<PartnerDetail>(`/admin/partners/${id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue(pickPartnerFormValues(d));
|
||||
setEditScopeType((d.scopeType as CityPartnerScopeType) || CityPartnerScopeType.CITY_WIDE);
|
||||
if (d.cityId) await loadWarehouses(d.cityId);
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
companyName: d.companyName,
|
||||
contactPhone: d.contactPhone ?? d.phone,
|
||||
address: d.address ?? '',
|
||||
scopeType: d.scopeType,
|
||||
districtCodes: d.districtCodes ?? [],
|
||||
orderCommissionRate: (d.orderCommissionRate ?? 0) * 100,
|
||||
redeemCommissionRate: (d.redeemCommissionRate ?? 0.03) * 100,
|
||||
bindingStatus: d.bindingStatus,
|
||||
managedWarehouseId: d.managedWarehouseId,
|
||||
bankAccountName: d.bankAccountName ?? '',
|
||||
bankAccountNo: d.bankAccountNo ?? '',
|
||||
bankBranch: d.bankBranch ?? '',
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
async function savePartner() {
|
||||
if (!detail) return;
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
const body = {
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: editScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
};
|
||||
await request(`/admin/partners/${detail.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
message.success('已保存');
|
||||
setDrawerOpen(false);
|
||||
void reload();
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '公司名', dataIndex: 'companyName' },
|
||||
{ title: '联系电话', dataIndex: 'contactPhone', width: 130 },
|
||||
{ title: '公司名', dataIndex: 'companyName', ellipsis: true },
|
||||
{ title: '城市', dataIndex: 'cityName', width: 100 },
|
||||
{ title: '主账号', dataIndex: 'phone', width: 130 },
|
||||
{
|
||||
title: '管辖',
|
||||
dataIndex: 'scopeType',
|
||||
width: 100,
|
||||
render: (v) => (v ? CITY_PARTNER_SCOPE_LABELS[v as keyof typeof CITY_PARTNER_SCOPE_LABELS] || v : '—'),
|
||||
},
|
||||
{ title: '门店', dataIndex: 'storeCount', width: 70 },
|
||||
{ title: '账号', dataIndex: 'accountCount', width: 70 },
|
||||
{ title: '开城城市', dataIndex: 'cityCount', width: 90 },
|
||||
{ title: '子账号', dataIndex: 'accountCount', width: 80, render: (n) => Math.max(0, n - 1) },
|
||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 120,
|
||||
title: '操作',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>详情</Button>
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>编辑</Button>
|
||||
</Space>
|
||||
<Button type="link" size="small" onClick={() => void openPartner(row.id)}>
|
||||
管理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -87,54 +183,178 @@ export default function PartnersPage() {
|
||||
<div>
|
||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||
<Typography.Title level={4} style={{ margin: 0 }}>开城合伙人</Typography.Title>
|
||||
<Button type="primary" onClick={() => setCreateOpen(true)}>新建开城合伙人</Button>
|
||||
<Button type="primary" onClick={() => { setCreateOpen(true); createForm.resetFields(); setCreateScopeType(CityPartnerScopeType.CITY_WIDE); }}>
|
||||
新建城市合伙人
|
||||
</Button>
|
||||
</Space>
|
||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="companyName" label="公司"><Input allowClear /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="电话"><Input allowClear /></Form.Item>
|
||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||
</Form>
|
||||
<Table rowKey="id" className="admin-table-nowrap" loading={loading} columns={columns} dataSource={data?.items ?? []}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Table
|
||||
rowKey="id"
|
||||
className="admin-table-nowrap"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => { setPage(p); setPageSize(ps); },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="编辑开城合伙人"
|
||||
width={560}
|
||||
title="城市合伙人"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={<Button type="primary" onClick={() => void savePartner()}>保存</Button>}
|
||||
>
|
||||
{detail && (
|
||||
<>
|
||||
{Array.isArray(detail.cities) && detail.cities.length > 0 && (
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }} title="关联开城城市">
|
||||
{detail.cities.map((c) => (
|
||||
<Descriptions.Item key={c.code} label={c.code}>{c.name} ({c.status})</Descriptions.Item>
|
||||
))}
|
||||
</Descriptions>
|
||||
)}
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: (
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="业务联系电话"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setEditScopeType(v)} />
|
||||
</Form.Item>
|
||||
{editScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县">
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
<Form.Item name="managedWarehouseId" label="管仓仓库">
|
||||
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="户名"><Input /></Form.Item>
|
||||
<Form.Item name="bankAccountNo" label="账号"><Input /></Form.Item>
|
||||
<Form.Item name="bankBranch" label="开户行"><Input /></Form.Item>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'staff',
|
||||
label: '子账号',
|
||||
children: (
|
||||
<>
|
||||
<Button type="primary" style={{ marginBottom: 12 }} onClick={() => { subForm.resetFields(); setSubOpen(true); }}>
|
||||
添加子账号
|
||||
</Button>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={detail.children ?? []}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name' },
|
||||
{ title: '手机', dataIndex: 'phone' },
|
||||
{ title: '状态', dataIndex: 'status', render: (s) => <Tag>{s}</Tag> },
|
||||
{
|
||||
title: '权限',
|
||||
dataIndex: 'permissions',
|
||||
render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
<Modal title="新建开城合伙人" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/partners', { method: 'POST', body: JSON.stringify(v) });
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}>
|
||||
<Form form={createForm} layout="vertical">
|
||||
|
||||
<Modal
|
||||
title="新建城市合伙人"
|
||||
open={createOpen}
|
||||
width={560}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await createForm.validateFields();
|
||||
await request('/admin/partners', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...v,
|
||||
orderCommissionRate: Number(v.orderCommissionRate ?? 0) / 100,
|
||||
redeemCommissionRate: Number(v.redeemCommissionRate ?? 3) / 100,
|
||||
districtCodes: createScopeType === CityPartnerScopeType.DISTRICT ? flattenDistrictCodes(v.districtCodes) : undefined,
|
||||
}),
|
||||
});
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
createForm.resetFields();
|
||||
void reload();
|
||||
}}
|
||||
>
|
||||
<Form form={createForm} layout="vertical" initialValues={{ orderCommissionRate: 0, redeemCommissionRate: 3, scopeType: CityPartnerScopeType.CITY_WIDE }}>
|
||||
<Form.Item name="cityId" label="开城城市" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={cities.map((c) => ({ value: c.id, label: `${c.name} (${c.code})` }))}
|
||||
onChange={(id) => { setCreateCityId(id); void loadWarehouses(id); }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="companyName" label="公司名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="contactPhone" label="联系电话" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="name" label="主账号姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="登录手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="address" label="地址" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="scopeType" label="管辖类型" rules={[{ required: true }]}>
|
||||
<Select options={SCOPE_OPTIONS} onChange={(v) => setCreateScopeType(v)} />
|
||||
</Form.Item>
|
||||
{createScopeType === CityPartnerScopeType.DISTRICT && (
|
||||
<Form.Item name="districtCodes" label="区县" rules={[{ required: true }]}>
|
||||
<Cascader options={CHINA_REGION_OPTIONS} multiple changeOnSelect />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space style={{ width: '100%' }} size="large">
|
||||
<Form.Item name="orderCommissionRate" label="订单佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
<Form.Item name="redeemCommissionRate" label="核销佣金 %"><InputNumber min={0} max={100} precision={2} /></Form.Item>
|
||||
</Space>
|
||||
{createCityId && (
|
||||
<Form.Item name="managedWarehouseId" label="管仓仓库(可选)">
|
||||
<Select allowClear options={warehouses.map((w) => ({ value: w.id, label: w.name }))} />
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="添加子账号"
|
||||
open={subOpen}
|
||||
onCancel={() => setSubOpen(false)}
|
||||
onOk={async () => {
|
||||
if (!detail) return;
|
||||
const v = await subForm.validateFields();
|
||||
await request('/admin/partner-accounts', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ...v, parentAccountId: detail.id }),
|
||||
});
|
||||
message.success('已创建');
|
||||
setSubOpen(false);
|
||||
void openPartner(detail.id);
|
||||
}}
|
||||
>
|
||||
<Form form={subForm} layout="vertical">
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="手机号" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="permissions" label="权限">
|
||||
<Select mode="multiple" options={PERM_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Form,
|
||||
Image,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
@@ -57,8 +58,7 @@ type CityOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
partnerId?: string | null;
|
||||
partner?: { id: string; companyName: string };
|
||||
partnerBindings?: Array<{ partnerAccountId: string; partnerCompanyName?: string }>;
|
||||
};
|
||||
|
||||
export default function StoresPage() {
|
||||
@@ -87,12 +87,12 @@ export default function StoresPage() {
|
||||
const [cities, setCities] = useState<CityOption[]>([]);
|
||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||
|
||||
const selectedPartnerId = Form.useWatch('partnerId', createForm);
|
||||
const selectedPartnerId = Form.useWatch('partnerAccountId', createForm);
|
||||
const selectedRegionCodes = Form.useWatch('regionCodes', createForm);
|
||||
const selectedCityId = Form.useWatch('cityId', createForm);
|
||||
|
||||
function bindRegionSelection(codes: string[], partnerId?: string) {
|
||||
const binding = resolveRegionBinding(codes, cities, partnerId ?? selectedPartnerId);
|
||||
function bindRegionSelection(codes: string[], partnerAccountId?: string) {
|
||||
const binding = resolveRegionBinding(codes, cities, partnerAccountId ?? selectedPartnerId);
|
||||
if (!binding) {
|
||||
createForm.setFieldsValue({ regionCodes: codes, cityId: undefined });
|
||||
return;
|
||||
@@ -114,7 +114,7 @@ export default function StoresPage() {
|
||||
if (binding.cityId && binding.matchedCity) {
|
||||
return `已匹配开城城市:${binding.matchedCity.name}(区划 ${binding.cityCode},区县 ${binding.region.districtCode})`;
|
||||
}
|
||||
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 开城城市」添加`;
|
||||
return `区划 ${binding.cityCode} 暂未开城,请先在「开城 → 城市」添加`;
|
||||
}, [selectedRegionCodes, cities, selectedPartnerId]);
|
||||
|
||||
async function loadOptions() {
|
||||
@@ -127,8 +127,8 @@ export default function StoresPage() {
|
||||
]);
|
||||
setPartners(p.items);
|
||||
setCities(c.items);
|
||||
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 开城合伙人」中创建');
|
||||
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 开城城市」中创建');
|
||||
if (!p.items.length) message.warning('暂无开城合伙人,请先在「开城 → 城市」详情中创建合伙人');
|
||||
if (!c.items.length) message.warning('暂无开城城市,请先在「开城 → 城市」中创建');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '加载合伙人/城市失败');
|
||||
} finally {
|
||||
@@ -147,6 +147,7 @@ export default function StoresPage() {
|
||||
void loadOptions();
|
||||
createForm.setFieldsValue({
|
||||
envPhotoUrls: ['', '', ''],
|
||||
settlementRate: 60,
|
||||
});
|
||||
setCreateStep(0);
|
||||
setCreateError('');
|
||||
@@ -162,7 +163,7 @@ export default function StoresPage() {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
|
||||
await createForm.validateFields(['partnerAccountId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
@@ -184,7 +185,7 @@ export default function StoresPage() {
|
||||
await request('/admin/stores', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
partnerId: values.partnerId,
|
||||
partnerAccountId: values.partnerAccountId,
|
||||
cityId: values.cityId,
|
||||
province: values.province,
|
||||
city: values.city,
|
||||
@@ -199,6 +200,7 @@ export default function StoresPage() {
|
||||
bankAccountName: values.bankAccountName.trim(),
|
||||
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
||||
bankBranch: values.bankBranch.trim(),
|
||||
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
||||
}),
|
||||
});
|
||||
message.success('门店已创建');
|
||||
@@ -208,7 +210,7 @@ export default function StoresPage() {
|
||||
if (e && typeof e === 'object' && 'errorFields' in e) {
|
||||
const fields = e as { errorFields?: Array<{ name: string[] }> };
|
||||
const first = fields.errorFields?.[0]?.name?.[0];
|
||||
if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') {
|
||||
if (first === 'partnerAccountId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') {
|
||||
setCreateStep(0);
|
||||
}
|
||||
return;
|
||||
@@ -239,7 +241,15 @@ export default function StoresPage() {
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
const d = await request<Record<string, unknown>>(`/admin/stores/${row.id}`);
|
||||
setDetail(d);
|
||||
editForm.setFieldsValue({ name: d.name, phone: d.phone, intro: d.intro, coverUrl: d.coverUrl, address: d.address, district: d.district });
|
||||
editForm.setFieldsValue({
|
||||
name: d.name,
|
||||
phone: d.phone,
|
||||
intro: d.intro,
|
||||
coverUrl: d.coverUrl,
|
||||
address: d.address,
|
||||
district: d.district,
|
||||
settlementRate: d.settlementRate != null ? Number(d.settlementRate) * 100 : 60,
|
||||
});
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
@@ -279,7 +289,11 @@ export default function StoresPage() {
|
||||
}} />
|
||||
<Button type="primary" onClick={async () => {
|
||||
const v = await editForm.validateFields();
|
||||
await request(`/admin/stores/${detail.id}`, { method: 'PUT', body: JSON.stringify(v) });
|
||||
const payload = {
|
||||
...v,
|
||||
settlementRate: v.settlementRate != null ? Number(v.settlementRate) / 100 : undefined,
|
||||
};
|
||||
await request(`/admin/stores/${detail.id}`, { method: 'PUT', body: JSON.stringify(payload) });
|
||||
message.success('已保存');
|
||||
setDetail({ ...detail, ...v });
|
||||
void reload();
|
||||
@@ -291,6 +305,9 @@ export default function StoresPage() {
|
||||
<Descriptions column={1} bordered size="small" style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="ID">{String(detail.id)}</Descriptions.Item>
|
||||
<Descriptions.Item label="地址">{String(detail.province)}{String(detail.cityName)}{String(detail.district)}{String(detail.address)}</Descriptions.Item>
|
||||
<Descriptions.Item label="核销结算比例">
|
||||
{detail.settlementRate != null ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '60%'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="核销数">{String(detail.redeemCount ?? 0)}</Descriptions.Item>
|
||||
<Descriptions.Item label="操作">
|
||||
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate(`/logs/stores?storeId=${detail.id}`)}>
|
||||
@@ -312,6 +329,9 @@ export default function StoresPage() {
|
||||
<Form.Item name="intro" label="介绍"><Input.TextArea rows={4} /></Form.Item>
|
||||
<Form.Item name="district" label="区县"><Input /></Form.Item>
|
||||
<Form.Item name="address" label="详细地址"><Input /></Form.Item>
|
||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
@@ -339,16 +359,16 @@ export default function StoresPage() {
|
||||
)}
|
||||
<Form form={createForm} layout="vertical" preserve>
|
||||
<div style={{ display: createStep === 0 ? 'block' : 'none' }}>
|
||||
<Form.Item name="partnerId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
|
||||
<Form.Item name="partnerAccountId" label="开城合伙人" rules={[{ required: true, message: '请选择开城合伙人' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
loading={optionsLoading}
|
||||
optionFilterProp="label"
|
||||
placeholder={optionsLoading ? '加载中…' : '请选择开城合伙人'}
|
||||
options={partners.map((p) => ({ value: p.id, label: p.companyName }))}
|
||||
onChange={(partnerId) => {
|
||||
onChange={(partnerAccountId) => {
|
||||
const codes = createForm.getFieldValue('regionCodes') as string[] | undefined;
|
||||
if (codes?.length === 3) bindRegionSelection(codes, partnerId);
|
||||
if (codes?.length === 3) bindRegionSelection(codes, partnerAccountId);
|
||||
else createForm.setFieldValue('cityId', undefined);
|
||||
}}
|
||||
/>
|
||||
@@ -418,6 +438,9 @@ export default function StoresPage() {
|
||||
<Form.Item name="bankBranch" label="开户支行" rules={[{ required: true, message: '请填写开户支行' }]}>
|
||||
<Input placeholder="例如:中国工商银行洛阳分行" />
|
||||
</Form.Item>
|
||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true, message: '请填写结算比例' }]}>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
|
||||
@@ -48,6 +48,8 @@ function accountFromProfile(profile: PartnerSessionProfile): PartnerAccount {
|
||||
companyName: profile.companyName ?? '',
|
||||
isPrimary: profile.isPrimary ?? true,
|
||||
staffRole: profile.staffRole,
|
||||
permissions: profile.permissions,
|
||||
primaryAccountId: profile.primaryAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||||
'/partner/auth/login/wechat',
|
||||
];
|
||||
|
||||
export type PartnerSessionProfile = Pick<PartnerMe, 'id' | 'name' | 'phone' | 'companyName' | 'isPrimary'> & {
|
||||
export type PartnerSessionProfile = Pick<PartnerMe, 'id' | 'name' | 'phone' | 'companyName' | 'isPrimary' | 'permissions' | 'primaryAccountId'> & {
|
||||
staffRole?: PartnerStaffRole;
|
||||
};
|
||||
|
||||
@@ -104,6 +104,8 @@ function profileFromMe(me: PartnerMe): PartnerSessionProfile {
|
||||
companyName: me.companyName,
|
||||
isPrimary: me.isPrimary,
|
||||
staffRole: me.staffRole ?? undefined,
|
||||
permissions: me.permissions,
|
||||
primaryAccountId: me.primaryAccountId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PartnerMe } from '@dukang/shared-types';
|
||||
import type { PartnerMe, PartnerPermissionKey } from '@dukang/shared-types';
|
||||
|
||||
export function isPrimaryAccount(account: PartnerMe | null | undefined): boolean {
|
||||
return account?.isPrimary !== false;
|
||||
@@ -8,6 +8,15 @@ export function isSubAccount(account: PartnerMe | null | undefined): boolean {
|
||||
return !!account && account.isPrimary === false;
|
||||
}
|
||||
|
||||
export function hasPartnerPermission(
|
||||
account: PartnerMe | null | undefined,
|
||||
permission: PartnerPermissionKey,
|
||||
): boolean {
|
||||
if (!account) return false;
|
||||
if (isPrimaryAccount(account)) return true;
|
||||
return account.permissions?.includes(permission) ?? false;
|
||||
}
|
||||
|
||||
export function partnerHomePath(account: PartnerMe | null | undefined): string {
|
||||
return isSubAccount(account) ? '/stores/new?step=1' : '/';
|
||||
}
|
||||
|
||||
@@ -1,26 +1,31 @@
|
||||
name: dukang-v1
|
||||
|
||||
# 与 dukang-v3(6014/6015)隔离;本栈使用 6016/6017
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: dukang-mysql
|
||||
container_name: dukang-v1-mysql
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
MYSQL_DATABASE: dukang_haoke
|
||||
ports:
|
||||
- '3306:3306'
|
||||
- '6016:3306'
|
||||
volumes:
|
||||
- mysql_data:/var/lib/mysql
|
||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: dukang-redis
|
||||
container_name: dukang-v1-redis
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '6379:6379'
|
||||
- '6017:6379'
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
volumes:
|
||||
mysql_data:
|
||||
name: dukang-v1_mysql_data
|
||||
redis_data:
|
||||
name: dukang-v1_redis_data
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -125,3 +125,5 @@ export function orderTabToStatuses(tab: string): string[] | undefined {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export * from './city-partner';
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface CityDto {
|
||||
name: string;
|
||||
province: string;
|
||||
status: string;
|
||||
/** 订单+核销佣金合计上限(小数,默认 0.05) */
|
||||
maxPartnerCommissionRate?: number;
|
||||
}
|
||||
|
||||
export interface ProductDto {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -72,6 +72,46 @@ export enum PartnerStaffRole {
|
||||
PROMOTER = 'PROMOTER',
|
||||
}
|
||||
|
||||
export enum CityPartnerScopeType {
|
||||
CITY_WIDE = 'CITY_WIDE',
|
||||
DISTRICT = 'DISTRICT',
|
||||
}
|
||||
|
||||
export enum CityPartnerStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
PAUSED = 'PAUSED',
|
||||
}
|
||||
|
||||
export enum WarehouseManagerType {
|
||||
HQ = 'HQ',
|
||||
PARTNER = 'PARTNER',
|
||||
}
|
||||
|
||||
export enum WarehouseStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
PAUSED = 'PAUSED',
|
||||
}
|
||||
|
||||
export const CITY_PARTNER_SCOPE_LABELS: Record<CityPartnerScopeType, string> = {
|
||||
[CityPartnerScopeType.CITY_WIDE]: '全城合伙人',
|
||||
[CityPartnerScopeType.DISTRICT]: '区域合伙人',
|
||||
};
|
||||
|
||||
export const WAREHOUSE_MANAGER_LABELS: Record<WarehouseManagerType, string> = {
|
||||
[WarehouseManagerType.HQ]: '总部直管',
|
||||
[WarehouseManagerType.PARTNER]: '合伙人管仓',
|
||||
};
|
||||
|
||||
export const CITY_PARTNER_STATUS_LABELS: Record<CityPartnerStatus, string> = {
|
||||
[CityPartnerStatus.ACTIVE]: '启用',
|
||||
[CityPartnerStatus.PAUSED]: '暂停',
|
||||
};
|
||||
|
||||
export const WAREHOUSE_STATUS_LABELS: Record<WarehouseStatus, string> = {
|
||||
[WarehouseStatus.ACTIVE]: '启用',
|
||||
[WarehouseStatus.PAUSED]: '暂停',
|
||||
};
|
||||
|
||||
export enum AccountStatus {
|
||||
ACTIVE = 'ACTIVE',
|
||||
DISABLED = 'DISABLED',
|
||||
|
||||
@@ -15,3 +15,5 @@ export * from './partner-log';
|
||||
export * from './promo';
|
||||
export * from './hq-permissions';
|
||||
export * from './partner';
|
||||
export * from './city-partner';
|
||||
export * from './city-warehouse';
|
||||
|
||||
@@ -1,34 +1,47 @@
|
||||
export type PartnerLogCategory =
|
||||
| 'login'
|
||||
| 'wechat_auth'
|
||||
| 'account_ops'
|
||||
| 'store_ops'
|
||||
| 'shipping'
|
||||
| 'settlement';
|
||||
| 'settlement'
|
||||
| 'warehouse_ops';
|
||||
|
||||
export const PARTNER_LOG_EVENT_CATEGORIES: Record<PartnerLogCategory, readonly string[]> = {
|
||||
login: ['partner_sms_send', 'partner_sms_login', 'partner_sms_verify_fail', 'partner_login_success'],
|
||||
wechat_auth: ['partner_wechat_login', 'partner_wechat_bind'],
|
||||
account_ops: [
|
||||
'partner_staff_create',
|
||||
'partner_staff_update',
|
||||
'partner_staff_delete',
|
||||
'partner_staff_permission_update',
|
||||
],
|
||||
store_ops: ['partner_store_create', 'partner_store_status_change'],
|
||||
shipping: ['partner_order_ship', 'partner_delivery_advance'],
|
||||
settlement: ['partner_bill_view', 'partner_bill_detail_view'],
|
||||
warehouse_ops: ['partner_warehouse_view', 'partner_warehouse_update'],
|
||||
};
|
||||
|
||||
export const PARTNER_LOG_CATEGORY_OPTIONS: Array<{ value: PartnerLogCategory | ''; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: 'login', label: '登录' },
|
||||
{ value: 'wechat_auth', label: '微信授权' },
|
||||
{ value: 'account_ops', label: '子账号管理' },
|
||||
{ value: 'store_ops', label: '门店操作' },
|
||||
{ value: 'shipping', label: '发货/配送' },
|
||||
{ value: 'settlement', label: '结算' },
|
||||
{ value: 'warehouse_ops', label: '仓库操作' },
|
||||
];
|
||||
|
||||
export const PARTNER_LOG_CATEGORY_LABELS: Record<PartnerLogCategory | '', string> = {
|
||||
'': '全部',
|
||||
login: '登录',
|
||||
wechat_auth: '微信授权',
|
||||
account_ops: '子账号管理',
|
||||
store_ops: '门店操作',
|
||||
shipping: '发货/配送',
|
||||
settlement: '结算',
|
||||
warehouse_ops: '仓库操作',
|
||||
};
|
||||
|
||||
export function resolvePartnerLogCategory(eventName: string): PartnerLogCategory | null {
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface PartnerMe {
|
||||
companyName?: string;
|
||||
hasWechat?: boolean;
|
||||
staffRole?: PartnerStaffRole;
|
||||
permissions?: string[];
|
||||
primaryAccountId?: string;
|
||||
}
|
||||
|
||||
export interface PartnerStaffItem {
|
||||
@@ -15,6 +17,7 @@ export interface PartnerStaffItem {
|
||||
name: string;
|
||||
phone: string;
|
||||
staffRole: PartnerStaffRole;
|
||||
permissions?: string[];
|
||||
status: AccountStatus;
|
||||
lastLoginAt?: string;
|
||||
}
|
||||
@@ -22,11 +25,13 @@ export interface PartnerStaffItem {
|
||||
export interface CreatePartnerStaffRequest {
|
||||
phone: string;
|
||||
name: string;
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export interface UpdatePartnerStaffRequest {
|
||||
name?: string;
|
||||
staffRole?: PartnerStaffRole;
|
||||
permissions?: string[];
|
||||
status?: AccountStatus;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
|
||||
|
||||
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
|
||||
const DEFAULT_MOCK_CODE = process.env.MOCK_SMS_CODE ?? '123456';
|
||||
const REDIS_CONTAINER = process.env.REDIS_CONTAINER ?? 'dukang-redis';
|
||||
const REDIS_CONTAINER = process.env.REDIS_CONTAINER ?? 'dukang-v1-redis';
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
# 服务器:仅使用 .env.production(bash deploy/sync-api-env.sh production)
|
||||
# .env.development — 本地参考模板,不部署到服务器
|
||||
|
||||
DATABASE_URL="mysql://root:root@localhost:3306/dukang_haoke"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
DATABASE_URL="mysql://root:root@localhost:6016/dukang_haoke"
|
||||
REDIS_URL="redis://localhost:6017"
|
||||
JWT_SECRET="dukang-prev1-dev-secret-change-in-prod"
|
||||
JWT_EXPIRES_IN="7d"
|
||||
PORT=3000
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"prisma:migrate": "prisma migrate dev",
|
||||
"prisma:validate": "prisma validate",
|
||||
"prisma:seed": "ts-node --transpile-only prisma/seed-v31.ts",
|
||||
"prisma:migrate-city-partner": "ts-node --transpile-only prisma/migrate-city-partner.ts",
|
||||
"prisma:seed-legacy": "ts-node --transpile-only prisma/seed-prev1.ts",
|
||||
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
|
||||
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -148,25 +148,7 @@ CREATE TABLE common_promo_code (
|
||||
UNIQUE KEY uk_common_promo_code_code (code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码';
|
||||
|
||||
-- ===================== PARTNER(先于 city/store) =============================
|
||||
|
||||
DROP TABLE IF EXISTS partner_partner;
|
||||
CREATE TABLE partner_partner (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
company_name VARCHAR(128) NOT NULL,
|
||||
address VARCHAR(256) NOT NULL,
|
||||
contact_phone VARCHAR(20) NOT NULL,
|
||||
contract_no VARCHAR(64) DEFAULT NULL COMMENT '合同编号',
|
||||
contract_signed_at DATETIME(3) DEFAULT NULL,
|
||||
contract_expire_at DATETIME(3) DEFAULT NULL,
|
||||
bank_account_name VARCHAR(64) DEFAULT NULL,
|
||||
bank_account_no VARCHAR(32) DEFAULT NULL,
|
||||
bank_branch VARCHAR(128) DEFAULT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_partner_partner_phone (contact_phone)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市合伙人主体';
|
||||
-- ===================== PARTNER(城市合伙人主账号 + 子账号) =============================
|
||||
|
||||
DROP TABLE IF EXISTS common_city;
|
||||
CREATE TABLE common_city (
|
||||
@@ -175,71 +157,99 @@ CREATE TABLE common_city (
|
||||
name VARCHAR(64) NOT NULL,
|
||||
province VARCHAR(32) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
|
||||
partner_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
local_min_qty INT NOT NULL DEFAULT 2,
|
||||
cross_min_qty INT NOT NULL DEFAULT 6,
|
||||
max_partner_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0500 COMMENT '订单+核销佣金合计上限',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_common_city_code (code),
|
||||
KEY idx_common_city_partner (partner_id),
|
||||
CONSTRAINT fk_common_city_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE SET NULL
|
||||
UNIQUE KEY uk_common_city_code (code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='开城配置';
|
||||
|
||||
DROP TABLE IF EXISTS common_city_commission_rule;
|
||||
CREATE TABLE common_city_commission_rule (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
city_id BIGINT UNSIGNED NOT NULL,
|
||||
order_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
|
||||
redeem_commission_rate DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
|
||||
partner_profit_rate DECIMAL(5,4) NOT NULL DEFAULT 0.3500,
|
||||
store_settlement_rate DECIMAL(5,4) NOT NULL DEFAULT 0.6000,
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_common_city_commission_city (city_id),
|
||||
CONSTRAINT fk_common_city_commission_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市佣金规则';
|
||||
|
||||
DROP TABLE IF EXISTS partner_account;
|
||||
CREATE TABLE partner_account (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
partner_id BIGINT UNSIGNED NOT NULL,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(64) NOT NULL,
|
||||
wx_open_id VARCHAR(64) DEFAULT NULL,
|
||||
wx_union_id VARCHAR(64) DEFAULT NULL,
|
||||
is_primary TINYINT NOT NULL DEFAULT 0,
|
||||
parent_account_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
staff_role VARCHAR(16) DEFAULT NULL COMMENT 'PARTNER|INTERNAL|PROMOTER',
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
last_login_at DATETIME(3) DEFAULT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
name VARCHAR(64) NOT NULL,
|
||||
wx_open_id VARCHAR(64) DEFAULT NULL,
|
||||
wx_union_id VARCHAR(64) DEFAULT NULL,
|
||||
is_primary TINYINT NOT NULL DEFAULT 0,
|
||||
parent_account_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
staff_role VARCHAR(16) DEFAULT NULL COMMENT 'PARTNER|INTERNAL|PROMOTER',
|
||||
permissions JSON DEFAULT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
last_login_at DATETIME(3) DEFAULT NULL,
|
||||
city_id BIGINT UNSIGNED DEFAULT NULL COMMENT '主账号绑定城市',
|
||||
scope_type VARCHAR(16) DEFAULT NULL COMMENT 'CITY_WIDE|DISTRICT',
|
||||
district_codes JSON DEFAULT NULL,
|
||||
order_commission_rate DECIMAL(5,4) DEFAULT 0.0000,
|
||||
redeem_commission_rate DECIMAL(5,4) DEFAULT 0.0300,
|
||||
binding_status VARCHAR(16) DEFAULT 'ACTIVE',
|
||||
company_name VARCHAR(128) DEFAULT NULL,
|
||||
address VARCHAR(256) DEFAULT NULL,
|
||||
contact_phone VARCHAR(20) DEFAULT NULL,
|
||||
contract_no VARCHAR(64) DEFAULT NULL,
|
||||
contract_signed_at DATETIME(3) DEFAULT NULL,
|
||||
contract_expire_at DATETIME(3) DEFAULT NULL,
|
||||
bank_account_name VARCHAR(64) DEFAULT NULL,
|
||||
bank_account_no VARCHAR(32) DEFAULT NULL,
|
||||
bank_branch VARCHAR(128) DEFAULT NULL,
|
||||
weekly_store_target INT DEFAULT 20,
|
||||
managed_warehouse_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_partner_account_phone (phone),
|
||||
KEY idx_partner_account_partner (partner_id),
|
||||
CONSTRAINT fk_partner_account_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE RESTRICT,
|
||||
UNIQUE KEY uk_partner_account_managed_warehouse (managed_warehouse_id),
|
||||
KEY idx_partner_account_city_scope (city_id, scope_type),
|
||||
KEY idx_partner_account_city_primary (city_id, is_primary),
|
||||
KEY idx_partner_account_parent (parent_account_id),
|
||||
KEY idx_partner_account_contact_phone (contact_phone),
|
||||
CONSTRAINT fk_partner_account_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_partner_account_parent FOREIGN KEY (parent_account_id) REFERENCES partner_account(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合伙人账号';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市合伙人账号(主账号=实体+城市绑定)';
|
||||
|
||||
DROP TABLE IF EXISTS common_city_warehouse;
|
||||
CREATE TABLE common_city_warehouse (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
city_id BIGINT UNSIGNED NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
address VARCHAR(256) NOT NULL,
|
||||
contact_name VARCHAR(64) NOT NULL,
|
||||
contact_phone VARCHAR(20) NOT NULL,
|
||||
manager_type VARCHAR(16) NOT NULL COMMENT 'HQ|PARTNER',
|
||||
partner_account_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_city_warehouse_city (city_id),
|
||||
KEY idx_city_warehouse_partner_account (partner_account_id),
|
||||
CONSTRAINT fk_city_warehouse_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_city_warehouse_partner_account FOREIGN KEY (partner_account_id) REFERENCES partner_account(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='城市仓库';
|
||||
|
||||
ALTER TABLE partner_account
|
||||
ADD CONSTRAINT fk_partner_account_managed_warehouse FOREIGN KEY (managed_warehouse_id) REFERENCES common_city_warehouse(id) ON DELETE SET NULL;
|
||||
|
||||
DROP TABLE IF EXISTS partner_bill;
|
||||
CREATE TABLE partner_bill (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
bill_no VARCHAR(32) NOT NULL,
|
||||
partner_id BIGINT UNSIGNED NOT NULL,
|
||||
period_start DATETIME(3) NOT NULL,
|
||||
period_end DATETIME(3) NOT NULL,
|
||||
order_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '下单佣金汇总',
|
||||
redeem_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '核销佣金汇总',
|
||||
total_amount DECIMAL(10,2) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
|
||||
confirmed_at DATETIME(3) DEFAULT NULL,
|
||||
paid_at DATETIME(3) DEFAULT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
bill_no VARCHAR(32) NOT NULL,
|
||||
partner_account_id BIGINT UNSIGNED NOT NULL,
|
||||
period_start DATETIME(3) NOT NULL,
|
||||
period_end DATETIME(3) NOT NULL,
|
||||
order_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '下单佣金汇总',
|
||||
redeem_commission DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '核销佣金汇总',
|
||||
total_amount DECIMAL(10,2) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'DRAFT',
|
||||
confirmed_at DATETIME(3) DEFAULT NULL,
|
||||
paid_at DATETIME(3) DEFAULT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_partner_bill_no (bill_no),
|
||||
KEY idx_partner_bill_partner_status (partner_id, status),
|
||||
CONSTRAINT fk_partner_bill_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE RESTRICT
|
||||
KEY idx_partner_bill_account_status (partner_account_id, status),
|
||||
CONSTRAINT fk_partner_bill_account FOREIGN KEY (partner_account_id) REFERENCES partner_account(id) ON DELETE RESTRICT
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='合伙人T+30账单';
|
||||
|
||||
-- ===================== HQ =============================
|
||||
@@ -345,7 +355,7 @@ DROP TABLE IF EXISTS store_store;
|
||||
CREATE TABLE store_store (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
city_id BIGINT UNSIGNED NOT NULL,
|
||||
partner_id BIGINT UNSIGNED NOT NULL,
|
||||
partner_account_id BIGINT UNSIGNED NOT NULL,
|
||||
category_id BIGINT UNSIGNED DEFAULT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
@@ -366,13 +376,14 @@ CREATE TABLE store_store (
|
||||
bank_account_name VARCHAR(64) DEFAULT NULL,
|
||||
bank_account_no VARCHAR(32) DEFAULT NULL,
|
||||
bank_branch VARCHAR(128) DEFAULT NULL,
|
||||
settlement_rate DECIMAL(5,4) NOT NULL DEFAULT 0.6000 COMMENT '门店核销结算比例',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_store_store_city_status (city_id, status),
|
||||
KEY idx_store_store_partner (partner_id),
|
||||
KEY idx_store_store_partner_account (partner_account_id),
|
||||
CONSTRAINT fk_store_store_city FOREIGN KEY (city_id) REFERENCES common_city(id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_store_store_partner FOREIGN KEY (partner_id) REFERENCES partner_partner(id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_store_store_partner_account FOREIGN KEY (partner_account_id) REFERENCES partner_account(id) ON DELETE RESTRICT,
|
||||
CONSTRAINT fk_store_store_category FOREIGN KEY (category_id) REFERENCES common_store_category(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='餐饮门店';
|
||||
|
||||
@@ -446,7 +457,9 @@ CREATE TABLE user_order (
|
||||
shipped_at DATETIME(3) DEFAULT NULL COMMENT '发货时间(冗余=user_order_delivery.shipping_at)',
|
||||
completed_at DATETIME(3) DEFAULT NULL COMMENT '完成时间',
|
||||
cancelled_at DATETIME(3) DEFAULT NULL COMMENT '取消时间',
|
||||
pay_expire_at DATETIME(3) DEFAULT NULL COMMENT '待付款过期时间',
|
||||
pay_expire_at DATETIME(3) DEFAULT NULL COMMENT '待付款过期时间',
|
||||
partner_account_id_at_pay BIGINT UNSIGNED DEFAULT NULL,
|
||||
order_commission_rate_at_pay DECIMAL(5,4) DEFAULT NULL,
|
||||
remark VARCHAR(512) DEFAULT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 将 legacy common_city.partner_id 迁移至 common_city_partner(CITY_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);
|
||||
});
|
||||
@@ -106,6 +106,26 @@ enum CityStatus {
|
||||
PAUSED
|
||||
}
|
||||
|
||||
enum CityPartnerScopeType {
|
||||
CITY_WIDE
|
||||
DISTRICT
|
||||
}
|
||||
|
||||
enum CityPartnerStatus {
|
||||
ACTIVE
|
||||
PAUSED
|
||||
}
|
||||
|
||||
enum WarehouseManagerType {
|
||||
HQ
|
||||
PARTNER
|
||||
}
|
||||
|
||||
enum WarehouseStatus {
|
||||
ACTIVE
|
||||
PAUSED
|
||||
}
|
||||
|
||||
enum PartnerStaffRole {
|
||||
PARTNER
|
||||
INTERNAL
|
||||
@@ -391,103 +411,109 @@ model CommonCity {
|
||||
name String @db.VarChar(64)
|
||||
province String @db.VarChar(32)
|
||||
status CityStatus @default(PENDING)
|
||||
partnerId BigInt? @map("partner_id") @db.UnsignedBigInt
|
||||
localMinQty Int @default(2) @map("local_min_qty")
|
||||
crossMinQty Int @default(6) @map("cross_min_qty")
|
||||
maxPartnerCommissionRate Decimal @default(0.05) @map("max_partner_commission_rate") @db.Decimal(5, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
partner Partner? @relation(fields: [partnerId], references: [id], onDelete: SetNull)
|
||||
commissionRule CommonCityCommissionRule?
|
||||
stores Store[]
|
||||
orders Order[]
|
||||
warehouses CityWarehouse[]
|
||||
partnerAccounts PartnerAccount[] @relation("PartnerAccountCity")
|
||||
stores Store[]
|
||||
orders Order[]
|
||||
|
||||
@@index([partnerId])
|
||||
@@map("common_city")
|
||||
}
|
||||
|
||||
model CommonCityCommissionRule {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
cityId BigInt @unique @map("city_id") @db.UnsignedBigInt
|
||||
orderCommissionRate Decimal @default(0) @map("order_commission_rate") @db.Decimal(5, 4)
|
||||
redeemCommissionRate Decimal @default(0) @map("redeem_commission_rate") @db.Decimal(5, 4)
|
||||
partnerProfitRate Decimal @default(0.35) @map("partner_profit_rate") @db.Decimal(5, 4)
|
||||
storeSettlementRate Decimal @default(0.60) @map("store_settlement_rate") @db.Decimal(5, 4)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
model CityWarehouse {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
cityId BigInt @map("city_id") @db.UnsignedBigInt
|
||||
name String @db.VarChar(128)
|
||||
address String @db.VarChar(256)
|
||||
contactName String @map("contact_name") @db.VarChar(64)
|
||||
contactPhone String @map("contact_phone") @db.VarChar(20)
|
||||
managerType WarehouseManagerType @map("manager_type")
|
||||
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
|
||||
status WarehouseStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
|
||||
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Cascade)
|
||||
partnerAccount PartnerAccount? @relation("WarehouseManager", fields: [partnerAccountId], references: [id], onDelete: SetNull)
|
||||
managedBy PartnerAccount? @relation("ManagedWarehouse")
|
||||
|
||||
@@map("common_city_commission_rule")
|
||||
@@index([cityId])
|
||||
@@index([partnerAccountId])
|
||||
@@map("common_city_warehouse")
|
||||
}
|
||||
|
||||
// ─── PARTNER ──────────────────────────────────────────
|
||||
|
||||
model Partner {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
companyName String @map("company_name") @db.VarChar(128)
|
||||
address String @db.VarChar(256)
|
||||
contactPhone String @map("contact_phone") @db.VarChar(20)
|
||||
contractNo String? @map("contract_no") @db.VarChar(64)
|
||||
contractSignedAt DateTime? @map("contract_signed_at") @db.DateTime(3)
|
||||
contractExpireAt DateTime? @map("contract_expire_at") @db.DateTime(3)
|
||||
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
|
||||
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
|
||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||
weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
cities CommonCity[]
|
||||
accounts PartnerAccount[]
|
||||
stores Store[]
|
||||
bills PartnerBill[]
|
||||
|
||||
@@index([contactPhone])
|
||||
@@map("partner_partner")
|
||||
}
|
||||
|
||||
model PartnerAccount {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
|
||||
phone String @unique @db.VarChar(20)
|
||||
name String @db.VarChar(64)
|
||||
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
||||
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
||||
isPrimary Int @default(0) @map("is_primary") @db.TinyInt
|
||||
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
|
||||
staffRole PartnerStaffRole? @map("staff_role")
|
||||
status AccountStatus @default(ACTIVE)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
phone String @unique @db.VarChar(20)
|
||||
name String @db.VarChar(64)
|
||||
wxOpenId String? @map("wx_open_id") @db.VarChar(64)
|
||||
wxUnionId String? @map("wx_union_id") @db.VarChar(64)
|
||||
isPrimary Int @default(0) @map("is_primary") @db.TinyInt
|
||||
parentAccountId BigInt? @map("parent_account_id") @db.UnsignedBigInt
|
||||
staffRole PartnerStaffRole? @map("staff_role")
|
||||
permissions Json?
|
||||
status AccountStatus @default(ACTIVE)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.DateTime(3)
|
||||
cityId BigInt? @map("city_id") @db.UnsignedBigInt
|
||||
scopeType CityPartnerScopeType? @map("scope_type")
|
||||
districtCodes Json? @map("district_codes")
|
||||
orderCommissionRate Decimal? @default(0) @map("order_commission_rate") @db.Decimal(5, 4)
|
||||
redeemCommissionRate Decimal? @default(0.03) @map("redeem_commission_rate") @db.Decimal(5, 4)
|
||||
bindingStatus CityPartnerStatus? @default(ACTIVE) @map("binding_status")
|
||||
companyName String? @map("company_name") @db.VarChar(128)
|
||||
address String? @db.VarChar(256)
|
||||
contactPhone String? @map("contact_phone") @db.VarChar(20)
|
||||
contractNo String? @map("contract_no") @db.VarChar(64)
|
||||
contractSignedAt DateTime? @map("contract_signed_at") @db.DateTime(3)
|
||||
contractExpireAt DateTime? @map("contract_expire_at") @db.DateTime(3)
|
||||
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
|
||||
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
|
||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||
weeklyStoreTarget Int? @default(20) @map("weekly_store_target")
|
||||
managedWarehouseId BigInt? @unique @map("managed_warehouse_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
|
||||
parent PartnerAccount? @relation("PartnerAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: SetNull)
|
||||
children PartnerAccount[] @relation("PartnerAccountHierarchy")
|
||||
city CommonCity? @relation("PartnerAccountCity", fields: [cityId], references: [id], onDelete: Restrict)
|
||||
managedWarehouse CityWarehouse? @relation("ManagedWarehouse", fields: [managedWarehouseId], references: [id], onDelete: SetNull)
|
||||
managedWarehouses CityWarehouse[] @relation("WarehouseManager")
|
||||
parent PartnerAccount? @relation("PartnerAccountHierarchy", fields: [parentAccountId], references: [id], onDelete: SetNull)
|
||||
children PartnerAccount[] @relation("PartnerAccountHierarchy")
|
||||
stores Store[]
|
||||
bills PartnerBill[]
|
||||
|
||||
@@index([partnerId])
|
||||
@@index([cityId, scopeType])
|
||||
@@index([cityId, isPrimary])
|
||||
@@index([parentAccountId])
|
||||
@@index([wxOpenId])
|
||||
@@index([contactPhone])
|
||||
@@map("partner_account")
|
||||
}
|
||||
|
||||
model PartnerBill {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
billNo String @unique @map("bill_no") @db.VarChar(32)
|
||||
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
|
||||
periodStart DateTime @map("period_start") @db.DateTime(3)
|
||||
periodEnd DateTime @map("period_end") @db.DateTime(3)
|
||||
orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2)
|
||||
redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
|
||||
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
|
||||
status PartnerBillStatus @default(DRAFT)
|
||||
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
|
||||
paidAt DateTime? @map("paid_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
billNo String @unique @map("bill_no") @db.VarChar(32)
|
||||
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
|
||||
periodStart DateTime @map("period_start") @db.DateTime(3)
|
||||
periodEnd DateTime @map("period_end") @db.DateTime(3)
|
||||
orderCommission Decimal @default(0) @map("order_commission") @db.Decimal(10, 2)
|
||||
redeemCommission Decimal @default(0) @map("redeem_commission") @db.Decimal(10, 2)
|
||||
totalAmount Decimal @map("total_amount") @db.Decimal(10, 2)
|
||||
status PartnerBillStatus @default(DRAFT)
|
||||
confirmedAt DateTime? @map("confirmed_at") @db.DateTime(3)
|
||||
paidAt DateTime? @map("paid_at") @db.DateTime(3)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
|
||||
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([partnerId, status])
|
||||
@@index([partnerAccountId, status])
|
||||
@@map("partner_bill")
|
||||
}
|
||||
|
||||
@@ -626,7 +652,7 @@ model UserPromoAttribution {
|
||||
model Store {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
cityId BigInt @map("city_id") @db.UnsignedBigInt
|
||||
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
|
||||
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
|
||||
categoryId BigInt? @map("category_id") @db.UnsignedBigInt
|
||||
name String @db.VarChar(128)
|
||||
phone String @db.VarChar(20)
|
||||
@@ -647,11 +673,12 @@ model Store {
|
||||
bankAccountName String? @map("bank_account_name") @db.VarChar(64)
|
||||
bankAccountNo String? @map("bank_account_no") @db.VarChar(32)
|
||||
bankBranch String? @map("bank_branch") @db.VarChar(128)
|
||||
settlementRate Decimal @default(0.60) @map("settlement_rate") @db.Decimal(5, 4)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
cityRef CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
|
||||
partner Partner @relation(fields: [partnerId], references: [id], onDelete: Restrict)
|
||||
partnerAccount PartnerAccount @relation(fields: [partnerAccountId], references: [id], onDelete: Restrict)
|
||||
category CommonStoreCategory? @relation(fields: [categoryId], references: [id], onDelete: SetNull)
|
||||
coverResource CommonResource? @relation("StoreCover", fields: [coverResourceId], references: [id], onDelete: SetNull)
|
||||
account StoreAccount?
|
||||
@@ -660,7 +687,7 @@ model Store {
|
||||
payouts StorePayout[]
|
||||
|
||||
@@index([cityId, status])
|
||||
@@index([partnerId])
|
||||
@@index([partnerAccountId])
|
||||
@@map("store_store")
|
||||
}
|
||||
|
||||
@@ -730,10 +757,12 @@ model Order {
|
||||
shippedAt DateTime? @map("shipped_at") @db.DateTime(3)
|
||||
completedAt DateTime? @map("completed_at") @db.DateTime(3)
|
||||
cancelledAt DateTime? @map("cancelled_at") @db.DateTime(3)
|
||||
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
|
||||
remark String? @db.VarChar(512)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
payExpireAt DateTime? @map("pay_expire_at") @db.DateTime(3)
|
||||
partnerAccountIdAtPay BigInt? @map("partner_account_id_at_pay") @db.UnsignedBigInt
|
||||
orderCommissionRateAtPay Decimal? @map("order_commission_rate_at_pay") @db.Decimal(5, 4)
|
||||
remark String? @db.VarChar(512)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||
city CommonCity @relation(fields: [cityId], references: [id], onDelete: Restrict)
|
||||
@@ -915,8 +944,7 @@ model LogStoreAnalytics {
|
||||
|
||||
model LogPartnerAnalytics {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
partnerAccountId BigInt? @map("partner_account_id") @db.UnsignedBigInt
|
||||
partnerId BigInt @map("partner_id") @db.UnsignedBigInt
|
||||
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
|
||||
eventName String @map("event_name") @db.VarChar(64)
|
||||
clientApp ClientApp? @map("client_app")
|
||||
refType String? @map("ref_type") @db.VarChar(32)
|
||||
@@ -924,7 +952,6 @@ model LogPartnerAnalytics {
|
||||
extraJson Json? @map("extra_json")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
@@index([partnerId, createdAt])
|
||||
@@index([partnerAccountId, createdAt])
|
||||
@@index([eventName, createdAt])
|
||||
@@map("log_partner_analytics")
|
||||
|
||||
@@ -1,421 +1,873 @@
|
||||
import { PrismaClient, ResourceBizType, ResourceMediaType, ResourceOwnerType } from '@prisma/client';
|
||||
|
||||
import { hashPassword } from '../src/common/crypto/password.util';
|
||||
import { DEFAULT_PRODUCT_DETAIL_TEMPLATES } from './seeds/product-detail-templates.default';
|
||||
|
||||
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
|
||||
|
||||
async function createMockResource(
|
||||
|
||||
ownerType: ResourceOwnerType,
|
||||
|
||||
ownerId: bigint,
|
||||
|
||||
bizType: ResourceBizType,
|
||||
|
||||
url: string,
|
||||
|
||||
) {
|
||||
|
||||
return prisma.commonResource.create({
|
||||
|
||||
data: {
|
||||
|
||||
ownerType,
|
||||
|
||||
ownerId,
|
||||
|
||||
bizType,
|
||||
|
||||
mediaType: ResourceMediaType.IMAGE,
|
||||
|
||||
ossBucket: 'mock-dukang',
|
||||
|
||||
ossKey: `mock/${ownerType.toLowerCase()}/${ownerId}/${bizType.toLowerCase()}`,
|
||||
|
||||
url,
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async function main() {
|
||||
|
||||
console.log('Seeding v3.1 data...');
|
||||
|
||||
|
||||
|
||||
await prisma.logUserAnalytics.deleteMany();
|
||||
|
||||
await prisma.logPartnerAnalytics.deleteMany();
|
||||
|
||||
await prisma.logThirdParty.deleteMany();
|
||||
|
||||
await prisma.storePayout.deleteMany();
|
||||
|
||||
await prisma.storeRating.deleteMany();
|
||||
|
||||
await prisma.redeemRecord.deleteMany();
|
||||
|
||||
await prisma.benefitCoupon.deleteMany();
|
||||
|
||||
await prisma.orderDelivery.deleteMany();
|
||||
|
||||
await prisma.order.deleteMany();
|
||||
|
||||
await prisma.commonEvent.deleteMany();
|
||||
|
||||
await prisma.commonTicket.deleteMany();
|
||||
|
||||
await prisma.userPromoAttribution.deleteMany();
|
||||
|
||||
await prisma.userCityPreference.deleteMany();
|
||||
|
||||
await prisma.userAddress.deleteMany();
|
||||
|
||||
await prisma.user.deleteMany();
|
||||
|
||||
await prisma.storeAccount.deleteMany();
|
||||
|
||||
await prisma.store.deleteMany();
|
||||
|
||||
await prisma.partnerBill.deleteMany();
|
||||
|
||||
await prisma.cityWarehouse.deleteMany();
|
||||
|
||||
await prisma.partnerAccount.deleteMany();
|
||||
await prisma.commonCityCommissionRule.deleteMany();
|
||||
|
||||
await prisma.commonCity.deleteMany();
|
||||
await prisma.partner.deleteMany();
|
||||
|
||||
await prisma.commonProductItem.deleteMany();
|
||||
|
||||
await prisma.commonProductDetailTemplate.deleteMany();
|
||||
|
||||
await prisma.commonStoreCategory.deleteMany();
|
||||
|
||||
await prisma.commonPromoCode.deleteMany();
|
||||
|
||||
await prisma.commonResource.deleteMany();
|
||||
|
||||
await prisma.hqAccount.deleteMany();
|
||||
|
||||
const partner = await prisma.partner.create({
|
||||
data: {
|
||||
companyName: '郑州城市合伙人',
|
||||
address: '河南省郑州市金水区',
|
||||
contactPhone: '13700000001',
|
||||
bankAccountName: '郑州合伙人公司',
|
||||
bankAccountNo: '6222021234567890',
|
||||
bankBranch: '工商银行郑州分行',
|
||||
weeklyStoreTarget: 20,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
const city = await prisma.commonCity.create({
|
||||
|
||||
data: {
|
||||
|
||||
code: '410100',
|
||||
|
||||
name: '郑州市',
|
||||
|
||||
province: '河南省',
|
||||
|
||||
status: 'ACTIVE',
|
||||
partnerId: partner.id,
|
||||
|
||||
localMinQty: 2,
|
||||
|
||||
crossMinQty: 6,
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
await prisma.commonCityCommissionRule.create({
|
||||
|
||||
|
||||
const primaryAccount = await prisma.partnerAccount.create({
|
||||
|
||||
data: {
|
||||
|
||||
phone: '13700000001',
|
||||
|
||||
name: '郑州合伙人主账号',
|
||||
|
||||
isPrimary: 1,
|
||||
|
||||
staffRole: 'PARTNER',
|
||||
|
||||
status: 'ACTIVE',
|
||||
|
||||
cityId: city.id,
|
||||
|
||||
scopeType: 'CITY_WIDE',
|
||||
|
||||
orderCommissionRate: 0,
|
||||
|
||||
redeemCommissionRate: 0.03,
|
||||
partnerProfitRate: 0.35,
|
||||
storeSettlementRate: 0.6,
|
||||
|
||||
bindingStatus: 'ACTIVE',
|
||||
|
||||
companyName: '郑州城市合伙人',
|
||||
|
||||
address: '河南省郑州市金水区',
|
||||
|
||||
contactPhone: '13700000001',
|
||||
|
||||
bankAccountName: '郑州合伙人公司',
|
||||
|
||||
bankAccountNo: '6222021234567890',
|
||||
|
||||
bankBranch: '工商银行郑州分行',
|
||||
|
||||
weeklyStoreTarget: 20,
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
const warehouse = await prisma.cityWarehouse.create({
|
||||
|
||||
data: {
|
||||
|
||||
cityId: city.id,
|
||||
|
||||
name: '郑州中央仓',
|
||||
|
||||
address: '河南省郑州市金水区物流园1号',
|
||||
|
||||
contactName: '仓管张',
|
||||
|
||||
contactPhone: '13700000009',
|
||||
|
||||
managerType: 'PARTNER',
|
||||
|
||||
partnerAccountId: primaryAccount.id,
|
||||
|
||||
status: 'ACTIVE',
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
await prisma.partnerAccount.update({
|
||||
|
||||
where: { id: primaryAccount.id },
|
||||
|
||||
data: { managedWarehouseId: warehouse.id },
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
await prisma.partnerAccount.create({
|
||||
|
||||
data: {
|
||||
|
||||
phone: '13700000002',
|
||||
|
||||
name: '拓店员小李',
|
||||
|
||||
isPrimary: 0,
|
||||
|
||||
parentAccountId: primaryAccount.id,
|
||||
|
||||
staffRole: 'INTERNAL',
|
||||
|
||||
permissions: ['store:create', 'store:view'],
|
||||
|
||||
status: 'DISABLED',
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
const categories = await Promise.all([
|
||||
|
||||
prisma.commonStoreCategory.create({ data: { code: 'HOTPOT', name: '火锅', sort: 1 } }),
|
||||
|
||||
prisma.commonStoreCategory.create({ data: { code: 'LOCAL', name: '地方菜', sort: 2 } }),
|
||||
|
||||
]);
|
||||
|
||||
|
||||
|
||||
for (const tpl of DEFAULT_PRODUCT_DETAIL_TEMPLATES) {
|
||||
|
||||
await prisma.commonProductDetailTemplate.create({
|
||||
|
||||
data: {
|
||||
|
||||
code: tpl.code,
|
||||
|
||||
name: tpl.name,
|
||||
|
||||
description: tpl.description,
|
||||
|
||||
aromaType: tpl.aromaType as 'QINGXIANG' | 'JIANGXIANG' | 'NONGXIANG' | null,
|
||||
|
||||
storyTitle: tpl.storyTitle,
|
||||
|
||||
storyText: tpl.storyText,
|
||||
|
||||
features: tpl.features,
|
||||
|
||||
detailImageUrls: [],
|
||||
|
||||
suggestedDetailImageCount: tpl.suggestedDetailImageCount,
|
||||
|
||||
sortOrder: tpl.sortOrder,
|
||||
|
||||
status: 'ACTIVE',
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const productDefs = [
|
||||
|
||||
{ skuCode: 'JZ-10', name: '酒祖杜康(国标特级10)', subtitle: '清香型 53度', price: 128, sortOrder: 1, img: 'https://picsum.photos/seed/jiuzu10/400/400' },
|
||||
|
||||
{ skuCode: 'JZ-15', name: '酒祖杜康(国标特级15)', subtitle: '清香型 53度', price: 168, sortOrder: 2, img: 'https://picsum.photos/seed/jiuzu15/400/400' },
|
||||
|
||||
{ skuCode: 'JZ-20', name: '酒祖杜康(国标特级20)', subtitle: '清香型 53度', price: 298, sortOrder: 3, img: 'https://picsum.photos/seed/jiuzu20/400/400' },
|
||||
|
||||
{ skuCode: 'JZ-30', name: '酒祖杜康(国标特级30)', subtitle: '清香型 53度', price: 498, sortOrder: 4, img: 'https://picsum.photos/seed/jiuzu30/400/400' },
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
const products = [];
|
||||
|
||||
for (const [i, def] of productDefs.entries()) {
|
||||
|
||||
const product = await prisma.commonProductItem.create({
|
||||
|
||||
data: {
|
||||
|
||||
skuCode: def.skuCode,
|
||||
|
||||
barcode69: `69000000000${i + 1}`,
|
||||
|
||||
name: def.name,
|
||||
|
||||
subtitle: def.subtitle,
|
||||
|
||||
aromaType: 'QINGXIANG',
|
||||
|
||||
spec: '500ml | 53度',
|
||||
|
||||
price: def.price,
|
||||
|
||||
benefitAmount: def.price,
|
||||
|
||||
status: 'ON_SALE',
|
||||
|
||||
sortOrder: def.sortOrder,
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
const cover = await createMockResource(ResourceOwnerType.PRODUCT, product.id, ResourceBizType.COVER, def.img);
|
||||
|
||||
await prisma.commonProductItem.update({
|
||||
|
||||
where: { id: product.id },
|
||||
|
||||
data: { coverResourceId: cover.id },
|
||||
|
||||
});
|
||||
|
||||
products.push(product);
|
||||
|
||||
}
|
||||
|
||||
await prisma.partnerAccount.create({
|
||||
data: {
|
||||
partnerId: partner.id,
|
||||
phone: '13700000001',
|
||||
name: '郑州合伙人主账号',
|
||||
isPrimary: 1,
|
||||
staffRole: 'PARTNER',
|
||||
},
|
||||
});
|
||||
|
||||
const primaryAccount = await prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { phone: '13700000001' },
|
||||
});
|
||||
|
||||
await prisma.partnerAccount.create({
|
||||
data: {
|
||||
partnerId: partner.id,
|
||||
phone: '13700000002',
|
||||
name: '拓店员小李',
|
||||
isPrimary: 0,
|
||||
parentAccountId: primaryAccount.id,
|
||||
staffRole: 'INTERNAL',
|
||||
status: 'DISABLED',
|
||||
},
|
||||
});
|
||||
|
||||
const storeDefs = [
|
||||
|
||||
{
|
||||
|
||||
name: '郑州老城店',
|
||||
|
||||
phone: '13910000001',
|
||||
|
||||
district: '金水区',
|
||||
|
||||
address: '花园路100号',
|
||||
|
||||
intro: '正宗河南菜,欢迎核销好客权益',
|
||||
|
||||
img: 'https://picsum.photos/seed/store1/400/300',
|
||||
|
||||
categoryId: categories[0].id,
|
||||
|
||||
settlementRate: 0.6,
|
||||
|
||||
withAccount: true,
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
name: '郑州美食城店',
|
||||
|
||||
phone: '13910000002',
|
||||
|
||||
district: '二七区',
|
||||
|
||||
address: '大学路200号',
|
||||
|
||||
intro: '地方特色餐饮',
|
||||
|
||||
img: 'https://picsum.photos/seed/store2/400/300',
|
||||
|
||||
categoryId: categories[1].id,
|
||||
|
||||
settlementRate: 0.65,
|
||||
|
||||
withAccount: false,
|
||||
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
const createdStores: { id: bigint; name: string }[] = [];
|
||||
|
||||
for (const def of storeDefs) {
|
||||
|
||||
const store = await prisma.store.create({
|
||||
|
||||
data: {
|
||||
|
||||
cityId: city.id,
|
||||
partnerId: partner.id,
|
||||
|
||||
partnerAccountId: primaryAccount.id,
|
||||
|
||||
categoryId: def.categoryId,
|
||||
|
||||
name: def.name,
|
||||
|
||||
phone: def.phone,
|
||||
|
||||
province: '河南省',
|
||||
|
||||
cityName: '郑州市',
|
||||
|
||||
district: def.district,
|
||||
|
||||
address: def.address,
|
||||
|
||||
intro: def.intro,
|
||||
|
||||
settlementRate: def.settlementRate,
|
||||
|
||||
status: 'OPEN',
|
||||
|
||||
openTime: '10:00',
|
||||
|
||||
closeTime: '22:00',
|
||||
|
||||
bankAccountName: def.name,
|
||||
|
||||
bankAccountNo: '6222029876543210',
|
||||
|
||||
bankBranch: '建设银行郑州分行',
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img);
|
||||
|
||||
await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
|
||||
|
||||
if (def.withAccount) {
|
||||
|
||||
await prisma.storeAccount.create({
|
||||
|
||||
data: { storeId: store.id, phone: def.phone, name: def.name },
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
createdStores.push({ id: store.id, name: def.name });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const weekStart = (() => {
|
||||
|
||||
const d = new Date();
|
||||
|
||||
d.setHours(12, 0, 0, 0);
|
||||
|
||||
const day = d.getDay();
|
||||
|
||||
const diff = day === 0 ? 6 : day - 1;
|
||||
|
||||
d.setDate(d.getDate() - diff);
|
||||
|
||||
d.setHours(10, 0, 0, 0);
|
||||
|
||||
return d;
|
||||
|
||||
})();
|
||||
|
||||
|
||||
|
||||
const newWeekStore = await prisma.store.create({
|
||||
|
||||
data: {
|
||||
|
||||
cityId: city.id,
|
||||
partnerId: partner.id,
|
||||
|
||||
partnerAccountId: primaryAccount.id,
|
||||
|
||||
categoryId: categories[0].id,
|
||||
|
||||
name: '本周新签体验店',
|
||||
|
||||
phone: '13910000003',
|
||||
|
||||
province: '河南省',
|
||||
|
||||
cityName: '郑州市',
|
||||
|
||||
district: '中原区',
|
||||
|
||||
address: '建设路88号',
|
||||
|
||||
intro: '本周新签约门店',
|
||||
|
||||
settlementRate: 0.6,
|
||||
|
||||
status: 'OPEN',
|
||||
|
||||
openTime: '10:00',
|
||||
|
||||
closeTime: '22:00',
|
||||
|
||||
bankAccountName: '本周新签体验店',
|
||||
|
||||
bankAccountNo: '6222029876543211',
|
||||
|
||||
bankBranch: '农业银行郑州分行',
|
||||
|
||||
createdAt: new Date(weekStart.getTime() + 2 * 24 * 60 * 60 * 1000),
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
createdStores.push({ id: newWeekStore.id, name: newWeekStore.name });
|
||||
|
||||
|
||||
|
||||
await prisma.user.create({
|
||||
|
||||
data: {
|
||||
|
||||
userNo: 'DK88293401',
|
||||
|
||||
phone: '13800000001',
|
||||
|
||||
phoneVerifiedAt: new Date(),
|
||||
|
||||
nickname: '测试用户',
|
||||
|
||||
cityPreference: {
|
||||
|
||||
create: {
|
||||
|
||||
selectedCityCode: '410100',
|
||||
|
||||
selectedDistrict: '郑州市',
|
||||
|
||||
locateCityCode: '410100',
|
||||
|
||||
locateDistrict: '金水区',
|
||||
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
await prisma.hqAccount.create({
|
||||
|
||||
data: {
|
||||
|
||||
phone: '13600000001',
|
||||
|
||||
loginName: 'admin',
|
||||
|
||||
passwordHash: hashPassword('dukang@123!'),
|
||||
|
||||
name: '总部管理员',
|
||||
|
||||
adminRole: 'SUPER_ADMIN',
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
await prisma.commonPromoCode.create({
|
||||
|
||||
data: {
|
||||
|
||||
code: 'DKHQ001',
|
||||
|
||||
name: '总部品鉴会',
|
||||
|
||||
status: 'ACTIVE',
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
await prisma.commonPromoCode.create({
|
||||
|
||||
data: {
|
||||
|
||||
code: 'DKDEMO1',
|
||||
|
||||
name: '郑州品鉴会演示',
|
||||
|
||||
status: 'ACTIVE',
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { phone: '13800000001' } });
|
||||
|
||||
const product = products[0];
|
||||
|
||||
|
||||
|
||||
const orderDefs = [
|
||||
|
||||
{ dayOffset: 0, payAmount: 1198, quantity: 2 },
|
||||
|
||||
{ dayOffset: 1, payAmount: 599, quantity: 1 },
|
||||
|
||||
{ dayOffset: 2, payAmount: 1760, quantity: 2 },
|
||||
|
||||
{ dayOffset: 4, payAmount: 1299, quantity: 1 },
|
||||
|
||||
{ dayOffset: 5, payAmount: 880, quantity: 1 },
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
const coupons: { id: bigint; balance: number }[] = [];
|
||||
|
||||
for (const [index, def] of orderDefs.entries()) {
|
||||
|
||||
const paidAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 14 * 60 * 60 * 1000);
|
||||
|
||||
const listAmount = def.payAmount;
|
||||
|
||||
const order = await prisma.order.create({
|
||||
|
||||
data: {
|
||||
|
||||
orderNo: `WK${Date.now()}${index}`,
|
||||
|
||||
userId: user.id,
|
||||
|
||||
cityId: city.id,
|
||||
|
||||
status: 'COMPLETED',
|
||||
|
||||
payStatus: 'PAID',
|
||||
|
||||
deliveryType: 'LOCAL',
|
||||
|
||||
productId: product.id,
|
||||
|
||||
barcode69: product.barcode69,
|
||||
|
||||
productName: product.name,
|
||||
|
||||
productSpec: product.spec,
|
||||
|
||||
quantity: def.quantity,
|
||||
|
||||
listUnitPrice: product.price,
|
||||
|
||||
listAmount,
|
||||
|
||||
productAmount: listAmount,
|
||||
|
||||
payAmount: listAmount,
|
||||
|
||||
benefitAmount: listAmount,
|
||||
|
||||
receiverName: '测试用户',
|
||||
receiverPhone: user.phone,
|
||||
|
||||
receiverPhone: user.phone!,
|
||||
|
||||
receiverAddress: '郑州市金水区测试路1号',
|
||||
|
||||
receiverProvince: '河南省',
|
||||
|
||||
receiverCity: '郑州市',
|
||||
|
||||
receiverDistrict: '金水区',
|
||||
|
||||
paidAt,
|
||||
|
||||
completedAt: paidAt,
|
||||
|
||||
partnerAccountIdAtPay: primaryAccount.id,
|
||||
|
||||
orderCommissionRateAtPay: 0,
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
const coupon = await prisma.benefitCoupon.create({
|
||||
|
||||
data: {
|
||||
|
||||
couponNo: `CPN${Date.now()}${index}`,
|
||||
|
||||
userId: user.id,
|
||||
|
||||
orderId: order.id,
|
||||
|
||||
totalAmount: listAmount,
|
||||
|
||||
balance: listAmount,
|
||||
|
||||
sourceProduct: product.name,
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
coupons.push({ id: coupon.id, balance: listAmount });
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const redeemDefs = [
|
||||
{ storeIndex: 0, amount: 42800, dayOffset: 1 },
|
||||
{ storeIndex: 1, amount: 38400, dayOffset: 2 },
|
||||
{ storeIndex: 2, amount: 31200, dayOffset: 3 },
|
||||
|
||||
{ storeIndex: 0, amount: 42800, dayOffset: 1, settlementRate: 0.6 },
|
||||
|
||||
{ storeIndex: 1, amount: 38400, dayOffset: 2, settlementRate: 0.65 },
|
||||
|
||||
{ storeIndex: 2, amount: 31200, dayOffset: 3, settlementRate: 0.6 },
|
||||
|
||||
];
|
||||
|
||||
for (const [index, def] of redeemDefs.entries()) {
|
||||
|
||||
const coupon = coupons[index];
|
||||
|
||||
const store = createdStores[def.storeIndex];
|
||||
|
||||
const createdAt = new Date(weekStart.getTime() + def.dayOffset * 24 * 60 * 60 * 1000 + 16 * 60 * 60 * 1000);
|
||||
|
||||
const settleAmount = Math.round(def.amount * def.settlementRate * 100) / 100;
|
||||
|
||||
await prisma.redeemRecord.create({
|
||||
|
||||
data: {
|
||||
|
||||
redeemNo: `RD${Date.now()}${index}`,
|
||||
|
||||
userId: user.id,
|
||||
|
||||
couponId: coupon.id,
|
||||
|
||||
storeId: store.id,
|
||||
|
||||
amount: def.amount,
|
||||
settleAmount: Math.round(def.amount * 0.6 * 100) / 100,
|
||||
|
||||
settleAmount,
|
||||
|
||||
createdAt,
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
await prisma.benefitCoupon.update({
|
||||
|
||||
where: { id: coupon.id },
|
||||
|
||||
data: {
|
||||
|
||||
usedAmount: def.amount,
|
||||
|
||||
balance: Math.max(0, coupon.balance - def.amount),
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const periodStart = new Date(now.getFullYear(), now.getMonth(), 1);
|
||||
|
||||
const periodEnd = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
||||
|
||||
await prisma.partnerBill.create({
|
||||
|
||||
data: {
|
||||
|
||||
billNo: `PB${Date.now()}`,
|
||||
partnerId: partner.id,
|
||||
|
||||
partnerAccountId: primaryAccount.id,
|
||||
|
||||
periodStart,
|
||||
|
||||
periodEnd,
|
||||
|
||||
orderCommission: 1200,
|
||||
|
||||
redeemCommission: 800,
|
||||
|
||||
totalAmount: 2000,
|
||||
|
||||
status: 'CONFIRMED',
|
||||
|
||||
confirmedAt: now,
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
console.log('Seed complete:', {
|
||||
|
||||
city: city.name,
|
||||
|
||||
warehouse: warehouse.name,
|
||||
|
||||
primaryAccount: primaryAccount.phone,
|
||||
|
||||
detailTemplates: DEFAULT_PRODUCT_DETAIL_TEMPLATES.length,
|
||||
|
||||
products: products.length,
|
||||
stores: storeDefs.length,
|
||||
|
||||
stores: createdStores.length,
|
||||
|
||||
testPhones: {
|
||||
|
||||
user: '13800000001',
|
||||
|
||||
partner: '13700000001',
|
||||
|
||||
partnerStaff: '13700000002',
|
||||
|
||||
hq: '13600000001',
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
main()
|
||||
|
||||
.catch((e) => {
|
||||
|
||||
console.error(e);
|
||||
|
||||
process.exit(1);
|
||||
|
||||
})
|
||||
|
||||
.finally(async () => {
|
||||
|
||||
await prisma.$disconnect();
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { SettlementModule } from './modules/settlement/settlement.module';
|
||||
import { AnalyticsModule } from './modules/analytics/analytics.module';
|
||||
import { JobsModule } from './jobs/jobs.module';
|
||||
import { OpsModule } from './modules/ops/ops.module';
|
||||
import { CityScopeModule } from './modules/city-scope/city-scope.module';
|
||||
import { CommonModule } from './modules/common/common.module';
|
||||
import { HqOperationModule } from './common/hq-operation/hq-operation.module';
|
||||
import { CallbacksModule } from './callbacks/callbacks.module';
|
||||
@@ -41,6 +42,7 @@ import { CallbacksModule } from './callbacks/callbacks.module';
|
||||
AnalyticsModule,
|
||||
JobsModule,
|
||||
OpsModule,
|
||||
CityScopeModule,
|
||||
CommonModule,
|
||||
HqOperationModule,
|
||||
CallbacksModule,
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
export const HqOperationAction = {
|
||||
CITY_CREATE: 'CITY_CREATE',
|
||||
CITY_UPDATE: 'CITY_UPDATE',
|
||||
CITY_DELETE: 'CITY_DELETE',
|
||||
CITY_PARTNER_BIND: 'CITY_PARTNER_BIND',
|
||||
CITY_PARTNER_UPDATE: 'CITY_PARTNER_UPDATE',
|
||||
CITY_PARTNER_UNBIND: 'CITY_PARTNER_UNBIND',
|
||||
WAREHOUSE_CREATE: 'WAREHOUSE_CREATE',
|
||||
WAREHOUSE_UPDATE: 'WAREHOUSE_UPDATE',
|
||||
WAREHOUSE_DELETE: 'WAREHOUSE_DELETE',
|
||||
PARTNER_CREATE: 'PARTNER_CREATE',
|
||||
PARTNER_UPDATE: 'PARTNER_UPDATE',
|
||||
PARTNER_ACCOUNT_CREATE: 'PARTNER_ACCOUNT_CREATE',
|
||||
@@ -47,6 +54,13 @@ export type HqOperationActionCode = (typeof HqOperationAction)[keyof typeof HqOp
|
||||
export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.CITY_CREATE]: '新增开城城市',
|
||||
[HqOperationAction.CITY_UPDATE]: '编辑开城城市',
|
||||
[HqOperationAction.CITY_DELETE]: '删除开城城市',
|
||||
[HqOperationAction.CITY_PARTNER_BIND]: '绑定城市合伙人',
|
||||
[HqOperationAction.CITY_PARTNER_UPDATE]: '编辑城市合伙人绑定',
|
||||
[HqOperationAction.CITY_PARTNER_UNBIND]: '解绑城市合伙人',
|
||||
[HqOperationAction.WAREHOUSE_CREATE]: '新增城市仓库',
|
||||
[HqOperationAction.WAREHOUSE_UPDATE]: '编辑城市仓库',
|
||||
[HqOperationAction.WAREHOUSE_DELETE]: '删除城市仓库',
|
||||
[HqOperationAction.PARTNER_CREATE]: '新增城市合伙人',
|
||||
[HqOperationAction.PARTNER_UPDATE]: '编辑城市合伙人',
|
||||
[HqOperationAction.PARTNER_ACCOUNT_CREATE]: '新增合伙人账户',
|
||||
|
||||
@@ -17,8 +17,8 @@ export type TrackStoreEventInput = TrackEventInput & {
|
||||
};
|
||||
|
||||
export type TrackPartnerEventInput = TrackEventInput & {
|
||||
partnerAccountId?: bigint;
|
||||
partnerId: bigint;
|
||||
/** 主账号 ID,用于合伙人维度聚合 */
|
||||
partnerAccountId: bigint;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -64,21 +64,21 @@ export class AnalyticsService {
|
||||
}
|
||||
|
||||
async trackPartnerOne(
|
||||
partnerAccountId: bigint | undefined,
|
||||
actorAccountId: bigint | undefined,
|
||||
clientApp: ClientApp | string,
|
||||
event: TrackPartnerEventInput,
|
||||
) {
|
||||
await this.prisma.logPartnerAnalytics.create({
|
||||
data: this.toPartnerRow(partnerAccountId, clientApp, event),
|
||||
data: this.toPartnerRow(actorAccountId, clientApp, event),
|
||||
});
|
||||
}
|
||||
|
||||
trackPartnerOneSafe(
|
||||
partnerAccountId: bigint | undefined,
|
||||
actorAccountId: bigint | undefined,
|
||||
clientApp: ClientApp | string,
|
||||
event: TrackPartnerEventInput,
|
||||
) {
|
||||
void this.trackPartnerOne(partnerAccountId, clientApp, event).catch(() => {});
|
||||
void this.trackPartnerOne(actorAccountId, clientApp, event).catch(() => {});
|
||||
}
|
||||
|
||||
private toRow(userId: bigint, clientApp: ClientApp | string, event: TrackEventInput) {
|
||||
@@ -110,17 +110,16 @@ export class AnalyticsService {
|
||||
}
|
||||
|
||||
private toPartnerRow(
|
||||
partnerAccountId: bigint | undefined,
|
||||
actorAccountId: bigint | undefined,
|
||||
clientApp: ClientApp | string,
|
||||
event: TrackPartnerEventInput,
|
||||
) {
|
||||
return {
|
||||
partnerAccountId,
|
||||
partnerId: event.partnerId,
|
||||
partnerAccountId: event.partnerAccountId,
|
||||
eventName: event.eventName,
|
||||
clientApp: clientApp as ClientApp,
|
||||
refType: event.refType,
|
||||
refId: event.refId,
|
||||
refId: event.refId ?? actorAccountId,
|
||||
extraJson: event.extraJson as never,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,10 +10,28 @@ export class CatalogService {
|
||||
async listCities() {
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
include: {
|
||||
partnerAccounts: {
|
||||
where: { isPrimary: 1, bindingStatus: 'ACTIVE' },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
select: { id: true, companyName: true, scopeType: true },
|
||||
},
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return serializeBigInt(cities);
|
||||
return serializeBigInt(
|
||||
cities.map((city) => ({
|
||||
...city,
|
||||
partnerBindingCount: city.partnerAccounts.length,
|
||||
partnerBindings: city.partnerAccounts.map((bp) => ({
|
||||
partnerAccountId: bp.id.toString(),
|
||||
partnerId: bp.id.toString(),
|
||||
companyName: bp.companyName,
|
||||
scopeType: bp.scopeType,
|
||||
})),
|
||||
partnerAccounts: undefined,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async listProducts(aromaType?: string, cityCode?: string) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PartnerCityService } from './partner-city.service';
|
||||
import { CityWarehouseService } from './city-warehouse.service';
|
||||
|
||||
@Module({
|
||||
providers: [PartnerCityService, CityWarehouseService],
|
||||
exports: [PartnerCityService, CityWarehouseService],
|
||||
})
|
||||
export class CityScopeModule {}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma, WarehouseManagerType, WarehouseStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerCityService } from './partner-city.service';
|
||||
import type { AdminCityWarehousesQueryDto } from '../ops/dto/admin-query.dto';
|
||||
|
||||
export type CreateCityWarehouseInput = {
|
||||
name: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
managerType: WarehouseManagerType;
|
||||
partnerAccountId?: bigint;
|
||||
status?: WarehouseStatus;
|
||||
};
|
||||
|
||||
export type UpdateCityWarehouseInput = Partial<CreateCityWarehouseInput>;
|
||||
|
||||
@Injectable()
|
||||
export class CityWarehouseService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
) {}
|
||||
|
||||
async listByCity(cityId: bigint) {
|
||||
const rows = await this.prisma.cityWarehouse.findMany({
|
||||
where: { cityId },
|
||||
include: {
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async listAll(query: AdminCityWarehousesQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.CityWarehouseWhereInput = {};
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.name) where.name = { contains: query.name };
|
||||
if (query.managerType) where.managerType = query.managerType as Prisma.EnumWarehouseManagerTypeFilter['equals'];
|
||||
if (query.status) where.status = query.status as Prisma.EnumWarehouseStatusFilter['equals'];
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.cityWarehouse.findMany({
|
||||
where,
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
city: { select: { id: true, name: true, code: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
this.prisma.cityWarehouse.count({ where }),
|
||||
]);
|
||||
|
||||
return serializeBigInt({
|
||||
items: rows.map((row) => ({
|
||||
...this.toDto(row),
|
||||
cityName: row.city.name,
|
||||
cityCode: row.city.code,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async create(cityId: bigint, input: CreateCityWarehouseInput) {
|
||||
await this.assertCityExists(cityId);
|
||||
await this.validateManager(input.managerType, input.partnerAccountId, cityId);
|
||||
|
||||
const row = await this.prisma.cityWarehouse.create({
|
||||
data: {
|
||||
cityId,
|
||||
name: input.name.trim(),
|
||||
address: input.address.trim(),
|
||||
contactName: input.contactName.trim(),
|
||||
contactPhone: input.contactPhone.trim(),
|
||||
managerType: input.managerType,
|
||||
partnerAccountId: input.managerType === 'PARTNER' ? input.partnerAccountId : null,
|
||||
status: input.status ?? 'ACTIVE',
|
||||
},
|
||||
include: {
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
},
|
||||
});
|
||||
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(id: bigint, input: UpdateCityWarehouseInput) {
|
||||
const current = await this.prisma.cityWarehouse.findUnique({ where: { id } });
|
||||
if (!current) throw new NotFoundException('仓库不存在');
|
||||
|
||||
const managerType = input.managerType ?? (current.managerType as WarehouseManagerType);
|
||||
const partnerAccountId =
|
||||
managerType === 'PARTNER'
|
||||
? input.partnerAccountId ?? current.partnerAccountId ?? undefined
|
||||
: null;
|
||||
|
||||
await this.validateManager(managerType, partnerAccountId ?? undefined, current.cityId);
|
||||
|
||||
const row = await this.prisma.cityWarehouse.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(input.name !== undefined ? { name: input.name.trim() } : {}),
|
||||
...(input.address !== undefined ? { address: input.address.trim() } : {}),
|
||||
...(input.contactName !== undefined ? { contactName: input.contactName.trim() } : {}),
|
||||
...(input.contactPhone !== undefined ? { contactPhone: input.contactPhone.trim() } : {}),
|
||||
...(input.managerType !== undefined ? { managerType: input.managerType } : {}),
|
||||
...(input.managerType !== undefined || input.partnerAccountId !== undefined
|
||||
? { partnerAccountId: managerType === 'PARTNER' ? partnerAccountId : null }
|
||||
: {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
},
|
||||
include: {
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
},
|
||||
});
|
||||
await this.syncManagedWarehouse(row.id, row.managerType as WarehouseManagerType, row.partnerAccountId);
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async remove(id: bigint) {
|
||||
const current = await this.prisma.cityWarehouse.findUnique({ where: { id } });
|
||||
if (!current) throw new NotFoundException('仓库不存在');
|
||||
await this.prisma.partnerAccount.updateMany({
|
||||
where: { managedWarehouseId: id },
|
||||
data: { managedWarehouseId: null },
|
||||
});
|
||||
await this.prisma.cityWarehouse.delete({ where: { id } });
|
||||
return { ok: true, id: id.toString() };
|
||||
}
|
||||
|
||||
private async syncManagedWarehouse(
|
||||
warehouseId: bigint,
|
||||
managerType: WarehouseManagerType,
|
||||
partnerAccountId: bigint | null,
|
||||
) {
|
||||
await this.prisma.partnerAccount.updateMany({
|
||||
where: { managedWarehouseId: warehouseId },
|
||||
data: { managedWarehouseId: null },
|
||||
});
|
||||
|
||||
if (managerType === 'PARTNER' && partnerAccountId) {
|
||||
await this.prisma.partnerAccount.updateMany({
|
||||
where: { id: partnerAccountId, managedWarehouseId: { not: warehouseId } },
|
||||
data: { managedWarehouseId: null },
|
||||
});
|
||||
await this.prisma.partnerAccount.update({
|
||||
where: { id: partnerAccountId },
|
||||
data: { managedWarehouseId: warehouseId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private validateManager(
|
||||
managerType: WarehouseManagerType,
|
||||
partnerAccountId: bigint | undefined,
|
||||
cityId: bigint,
|
||||
) {
|
||||
if (managerType === 'PARTNER') {
|
||||
if (!partnerAccountId) throw new BadRequestException('合伙人管仓须指定合伙人');
|
||||
return this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, cityId);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCityExists(cityId: bigint) {
|
||||
const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } });
|
||||
if (!city) throw new NotFoundException('开城城市不存在');
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: bigint;
|
||||
cityId: bigint;
|
||||
name: string;
|
||||
address: string;
|
||||
contactName: string;
|
||||
contactPhone: string;
|
||||
managerType: string;
|
||||
partnerAccountId: bigint | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
partnerAccount?: { id: bigint; companyName: string | null } | null;
|
||||
}) {
|
||||
return serializeBigInt({
|
||||
id: row.id.toString(),
|
||||
cityId: row.cityId.toString(),
|
||||
name: row.name,
|
||||
address: row.address,
|
||||
contactName: row.contactName,
|
||||
contactPhone: row.contactPhone,
|
||||
managerType: row.managerType,
|
||||
partnerAccountId: row.partnerAccountId?.toString() ?? null,
|
||||
partnerCompanyName: row.partnerAccount?.companyName ?? null,
|
||||
status: row.status,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
|
||||
import { resolveOrderCityPartner, validatePartnerCityBinding } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
const PRIMARY_WHERE = { isPrimary: 1 } as const;
|
||||
|
||||
@Injectable()
|
||||
export class PartnerCityService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listByCity(cityId: bigint) {
|
||||
const rows = await this.prisma.partnerAccount.findMany({
|
||||
where: { ...PRIMARY_WHERE, cityId },
|
||||
include: { city: { select: { id: true, code: true, name: true } } },
|
||||
orderBy: [{ scopeType: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return rows.map((row) => this.toDto(row));
|
||||
}
|
||||
|
||||
async listCityIdsForPartnerAccount(partnerAccountId: bigint): Promise<bigint[]> {
|
||||
const primary = await this.resolvePrimaryAccount(partnerAccountId);
|
||||
if (!primary.cityId) return [];
|
||||
return [primary.cityId];
|
||||
}
|
||||
|
||||
async assertPartnerAccountBoundToCity(partnerAccountId: bigint, cityId: bigint) {
|
||||
const row = await this.prisma.partnerAccount.findFirst({
|
||||
where: {
|
||||
id: partnerAccountId,
|
||||
...PRIMARY_WHERE,
|
||||
cityId,
|
||||
bindingStatus: 'ACTIVE',
|
||||
},
|
||||
});
|
||||
if (!row) {
|
||||
throw new BadRequestException('合伙人未绑定该开城城市');
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
async assertPartnerBoundToCity(partnerAccountId: bigint, cityId: bigint) {
|
||||
return this.assertPartnerAccountBoundToCity(partnerAccountId, cityId);
|
||||
}
|
||||
|
||||
async resolveForOrder(cityId: bigint, receiverDistrict?: string | null) {
|
||||
const bindings = await this.prisma.partnerAccount.findMany({
|
||||
where: { ...PRIMARY_WHERE, cityId, bindingStatus: 'ACTIVE' },
|
||||
});
|
||||
const ref = resolveOrderCityPartner(
|
||||
bindings.map((b) => ({
|
||||
id: b.id.toString(),
|
||||
partnerAccountId: b.id.toString(),
|
||||
scopeType: b.scopeType as CityPartnerScopeType,
|
||||
districtCodes: this.parseDistrictCodes(b.districtCodes),
|
||||
orderCommissionRate: Number(b.orderCommissionRate ?? 0),
|
||||
redeemCommissionRate: Number(b.redeemCommissionRate ?? 0.03),
|
||||
bindingStatus: b.bindingStatus as CityPartnerStatus,
|
||||
})),
|
||||
receiverDistrict,
|
||||
);
|
||||
if (!ref) return null;
|
||||
return {
|
||||
partnerAccountId: BigInt(ref.partnerAccountId),
|
||||
orderCommissionRate: ref.orderCommissionRate,
|
||||
redeemCommissionRate: ref.redeemCommissionRate,
|
||||
};
|
||||
}
|
||||
|
||||
async validatePrimaryBinding(
|
||||
cityId: bigint,
|
||||
input: {
|
||||
partnerAccountId?: string;
|
||||
scopeType: CityPartnerScopeType;
|
||||
districtCodes?: string[];
|
||||
},
|
||||
excludeId?: bigint,
|
||||
) {
|
||||
const existing = await this.prisma.partnerAccount.findMany({
|
||||
where: { ...PRIMARY_WHERE, cityId, ...(excludeId ? { NOT: { id: excludeId } } : {}) },
|
||||
});
|
||||
const validation = validatePartnerCityBinding(
|
||||
existing.map((r) => ({
|
||||
id: r.id.toString(),
|
||||
partnerAccountId: r.id.toString(),
|
||||
scopeType: r.scopeType as CityPartnerScopeType,
|
||||
districtCodes: this.parseDistrictCodes(r.districtCodes),
|
||||
})),
|
||||
{
|
||||
partnerAccountId: input.partnerAccountId ?? 'new',
|
||||
scopeType: input.scopeType,
|
||||
districtCodes: input.districtCodes,
|
||||
},
|
||||
excludeId?.toString(),
|
||||
);
|
||||
if (!validation.ok) throw new BadRequestException(validation.message);
|
||||
}
|
||||
|
||||
async buildPartnerOrderWhere(partnerAccountId: bigint): Promise<Prisma.OrderWhereInput> {
|
||||
const primary = await this.resolvePrimaryAccount(partnerAccountId);
|
||||
if (!primary.cityId) return { id: -1n };
|
||||
return { cityId: primary.cityId };
|
||||
}
|
||||
|
||||
async buildPartnerCityWhere(partnerAccountId: bigint): Promise<Prisma.CommonCityWhereInput> {
|
||||
const primary = await this.resolvePrimaryAccount(partnerAccountId);
|
||||
if (!primary.cityId) return { id: -1n };
|
||||
return { id: primary.cityId };
|
||||
}
|
||||
|
||||
async resolvePrimaryAccount(accountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUnique({ where: { id: accountId } });
|
||||
if (!account) throw new NotFoundException('合伙人账号不存在');
|
||||
if (account.isPrimary === 1) return account;
|
||||
if (!account.parentAccountId) {
|
||||
throw new BadRequestException('子账号缺少主账号');
|
||||
}
|
||||
return this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: account.parentAccountId } });
|
||||
}
|
||||
|
||||
parseDistrictCodes(value: Prisma.JsonValue | null): string[] | null {
|
||||
if (!value || !Array.isArray(value)) return null;
|
||||
return value.map((v) => String(v));
|
||||
}
|
||||
|
||||
toDto(row: {
|
||||
id: bigint;
|
||||
cityId: bigint | null;
|
||||
companyName: string | null;
|
||||
phone: string;
|
||||
name: string;
|
||||
scopeType: string | null;
|
||||
districtCodes: Prisma.JsonValue | null;
|
||||
orderCommissionRate: Prisma.Decimal | null;
|
||||
redeemCommissionRate: Prisma.Decimal | null;
|
||||
bindingStatus: string | null;
|
||||
managedWarehouseId: bigint | null;
|
||||
status: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
city?: { id: bigint; code: string; name: string } | null;
|
||||
}) {
|
||||
return serializeBigInt({
|
||||
id: row.id.toString(),
|
||||
cityId: row.cityId?.toString() ?? null,
|
||||
cityName: row.city?.name ?? null,
|
||||
cityCode: row.city?.code ?? null,
|
||||
companyName: row.companyName,
|
||||
phone: row.phone,
|
||||
name: row.name,
|
||||
scopeType: row.scopeType,
|
||||
districtCodes: this.parseDistrictCodes(row.districtCodes),
|
||||
orderCommissionRate: Number(row.orderCommissionRate ?? 0),
|
||||
redeemCommissionRate: Number(row.redeemCommissionRate ?? 0.03),
|
||||
bindingStatus: row.bindingStatus,
|
||||
managedWarehouseId: row.managedWarehouseId?.toString() ?? null,
|
||||
status: row.status,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -168,15 +168,15 @@ export class AuthService {
|
||||
}
|
||||
|
||||
private trackPartnerEvent(
|
||||
partnerAccountId: bigint | undefined,
|
||||
partnerId: bigint,
|
||||
actorAccountId: bigint | undefined,
|
||||
primaryAccountId: bigint,
|
||||
clientApp: ClientApp | string,
|
||||
eventName: string,
|
||||
extraJson?: Record<string, unknown>,
|
||||
ref?: { refType?: string; refId?: bigint },
|
||||
) {
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, clientApp, {
|
||||
partnerId,
|
||||
this.analyticsService.trackPartnerOneSafe(actorAccountId, clientApp, {
|
||||
partnerAccountId: primaryAccountId,
|
||||
eventName,
|
||||
refType: ref?.refType,
|
||||
refId: ref?.refId,
|
||||
@@ -184,10 +184,42 @@ export class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
private async resolvePrimaryAccount(accountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUnique({ where: { id: accountId } });
|
||||
if (!account) throw new NotFoundException('合伙人账号不存在');
|
||||
if (account.isPrimary === 1) return account;
|
||||
if (!account.parentAccountId) {
|
||||
throw new BadRequestException('子账号缺少主账号');
|
||||
}
|
||||
return this.prisma.partnerAccount.findUniqueOrThrow({ where: { id: account.parentAccountId } });
|
||||
}
|
||||
|
||||
private partnerTokenPayload(
|
||||
account: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
phone: string;
|
||||
isPrimary: number;
|
||||
staffRole: string | null;
|
||||
permissions?: unknown;
|
||||
},
|
||||
primary: { id: bigint; companyName: string | null },
|
||||
) {
|
||||
return {
|
||||
id: account.id.toString(),
|
||||
primaryAccountId: primary.id.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
isPrimary: account.isPrimary === 1,
|
||||
staffRole: account.staffRole ?? undefined,
|
||||
companyName: primary.companyName ?? undefined,
|
||||
permissions: Array.isArray(account.permissions) ? account.permissions : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private async assertPartnerAccountByPhone(phone: string) {
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { phone },
|
||||
include: { partner: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('未找到合伙人账号');
|
||||
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
|
||||
@@ -197,11 +229,12 @@ export class AuthService {
|
||||
async checkPartnerPhone(phone: string) {
|
||||
const normalizedPhone = this.assertMobilePhone(phone);
|
||||
const account = await this.assertPartnerAccountByPhone(normalizedPhone);
|
||||
const primary = await this.resolvePrimaryAccount(account.id);
|
||||
return {
|
||||
ok: true,
|
||||
maskedPhone: this.maskPhone(normalizedPhone),
|
||||
name: account.name,
|
||||
companyName: account.partner.companyName,
|
||||
companyName: primary.companyName,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -306,12 +339,13 @@ export class AuthService {
|
||||
) {
|
||||
const partnerAccount = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: actorRef.refId },
|
||||
select: { id: true, partnerId: true },
|
||||
select: { id: true, isPrimary: true, parentAccountId: true },
|
||||
});
|
||||
if (partnerAccount) {
|
||||
const primary = await this.resolvePrimaryAccount(partnerAccount.id);
|
||||
this.trackPartnerEvent(
|
||||
partnerAccount.id,
|
||||
partnerAccount.partnerId,
|
||||
primary.id,
|
||||
clientApp,
|
||||
'partner_sms_send',
|
||||
{
|
||||
@@ -415,20 +449,12 @@ export class AuthService {
|
||||
private async buildPartnerSessionResponse(accountId: bigint, clientApp: ClientApp) {
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: accountId },
|
||||
include: { partner: true },
|
||||
});
|
||||
if (!account || account.status !== 'ACTIVE') {
|
||||
throw new UnauthorizedException('Invalid refresh token');
|
||||
}
|
||||
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
|
||||
id: account.id.toString(),
|
||||
partnerId: account.partnerId.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
isPrimary: account.isPrimary === 1,
|
||||
staffRole: account.staffRole ?? undefined,
|
||||
companyName: account.partner.companyName,
|
||||
});
|
||||
const primary = await this.resolvePrimaryAccount(account.id);
|
||||
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
|
||||
}
|
||||
|
||||
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
||||
@@ -601,7 +627,8 @@ export class AuthService {
|
||||
} catch (err) {
|
||||
const account = await this.prisma.partnerAccount.findUnique({ where: { phone: normalizedPhone } });
|
||||
if (account) {
|
||||
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_sms_verify_fail', {
|
||||
const primary = await this.resolvePrimaryAccount(account.id);
|
||||
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_sms_verify_fail', {
|
||||
phone: this.maskPhone(normalizedPhone),
|
||||
reason: err instanceof BadRequestException ? err.message : '验证码错误',
|
||||
});
|
||||
@@ -610,29 +637,21 @@ export class AuthService {
|
||||
}
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { phone: normalizedPhone },
|
||||
include: { partner: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('未找到合伙人账号');
|
||||
if (account.status !== 'ACTIVE') throw new BadRequestException('合伙人账号已停用');
|
||||
const primary = await this.resolvePrimaryAccount(account.id);
|
||||
await this.prisma.partnerAccount.update({
|
||||
where: { id: account.id },
|
||||
data: { lastLoginAt: new Date() },
|
||||
});
|
||||
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_sms_login', {
|
||||
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_sms_login', {
|
||||
phone: this.maskPhone(normalizedPhone),
|
||||
});
|
||||
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_login_success', {
|
||||
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_login_success', {
|
||||
method: 'sms',
|
||||
});
|
||||
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
|
||||
id: account.id.toString(),
|
||||
partnerId: account.partnerId.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
isPrimary: account.isPrimary === 1,
|
||||
staffRole: account.staffRole ?? undefined,
|
||||
companyName: account.partner.companyName,
|
||||
});
|
||||
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
|
||||
}
|
||||
|
||||
async loginHq(phone: string, code: string, clientApp: ClientApp) {
|
||||
@@ -734,9 +753,14 @@ export class AuthService {
|
||||
if (actorType === 'PARTNER') {
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: actorId },
|
||||
include: { partner: true },
|
||||
});
|
||||
return serializeBigInt(account);
|
||||
if (!account) return null;
|
||||
const primary = await this.resolvePrimaryAccount(account.id);
|
||||
return serializeBigInt({
|
||||
...account,
|
||||
primaryAccountId: primary.id,
|
||||
companyName: primary.companyName,
|
||||
});
|
||||
}
|
||||
if (actorType === 'HQ') {
|
||||
const account = await this.prisma.hqAccount.findUnique({ where: { id: actorId } });
|
||||
@@ -1102,7 +1126,6 @@ export class AuthService {
|
||||
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: partnerAccountId },
|
||||
include: { partner: true },
|
||||
});
|
||||
if (!account) throw new BadRequestException('合伙人账号不存在');
|
||||
|
||||
@@ -1120,19 +1143,12 @@ export class AuthService {
|
||||
wxUnionId: session.unionId ?? account.wxUnionId,
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
include: { partner: true },
|
||||
});
|
||||
const primary = await this.resolvePrimaryAccount(updated.id);
|
||||
|
||||
this.trackPartnerEvent(updated.id, updated.partnerId, clientApp, 'partner_wechat_bind', { platform });
|
||||
this.trackPartnerEvent(updated.id, primary.id, clientApp, 'partner_wechat_bind', { platform });
|
||||
|
||||
return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, {
|
||||
id: updated.id.toString(),
|
||||
partnerId: updated.partnerId.toString(),
|
||||
name: updated.name,
|
||||
phone: updated.phone,
|
||||
isPrimary: updated.isPrimary === 1,
|
||||
companyName: updated.partner.companyName,
|
||||
});
|
||||
return this.issueToken('PARTNER', updated.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(updated, primary));
|
||||
}
|
||||
|
||||
async loginPartnerWechat(code: string, clientApp: ClientApp, platform: 'h5' | 'mini' = 'h5') {
|
||||
@@ -1144,7 +1160,6 @@ export class AuthService {
|
||||
|
||||
let account = await this.prisma.partnerAccount.findFirst({
|
||||
where: { wxOpenId: session.openId },
|
||||
include: { partner: true },
|
||||
});
|
||||
|
||||
if (!account && this.wechatProvider.isMock()) {
|
||||
@@ -1152,7 +1167,6 @@ export class AuthService {
|
||||
account = await this.prisma.partnerAccount.findFirst({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: [{ isPrimary: 'desc' }, { id: 'asc' }],
|
||||
include: { partner: true },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1167,23 +1181,15 @@ export class AuthService {
|
||||
wxUnionId: session.unionId ?? account.wxUnionId,
|
||||
lastLoginAt: new Date(),
|
||||
},
|
||||
include: { partner: true },
|
||||
});
|
||||
const primary = await this.resolvePrimaryAccount(account.id);
|
||||
|
||||
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_wechat_login', { platform });
|
||||
this.trackPartnerEvent(account.id, account.partnerId, clientApp, 'partner_login_success', {
|
||||
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_wechat_login', { platform });
|
||||
this.trackPartnerEvent(account.id, primary.id, clientApp, 'partner_login_success', {
|
||||
method: 'wechat',
|
||||
});
|
||||
|
||||
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, {
|
||||
id: account.id.toString(),
|
||||
partnerId: account.partnerId.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
isPrimary: account.isPrimary === 1,
|
||||
staffRole: account.staffRole ?? undefined,
|
||||
companyName: account.partner.companyName,
|
||||
});
|
||||
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
|
||||
}
|
||||
|
||||
private async mergeUsers(guestId: bigint, primaryId: bigint): Promise<UserRow> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
import { AccountStatus, PartnerStaffRole } from '@dukang/shared-types';
|
||||
|
||||
export class CreatePartnerStaffDto {
|
||||
@@ -15,6 +15,11 @@ export class CreatePartnerStaffDto {
|
||||
@IsString()
|
||||
@IsIn(Object.values(PartnerStaffRole))
|
||||
staffRole?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export class UpdatePartnerStaffDto {
|
||||
@@ -27,6 +32,11 @@ export class UpdatePartnerStaffDto {
|
||||
@IsOptional()
|
||||
staffRole?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissions?: string[];
|
||||
|
||||
@IsString()
|
||||
@IsIn(Object.values(AccountStatus))
|
||||
@IsOptional()
|
||||
|
||||
@@ -17,7 +17,7 @@ export class PartnerStaffController {
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthUser, @Body() dto: CreatePartnerStaffDto) {
|
||||
return this.staffService.createStaff(user.actorId, dto);
|
||||
return this.staffService.createStaff(user, dto);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@@ -26,11 +26,11 @@ export class PartnerStaffController {
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdatePartnerStaffDto,
|
||||
) {
|
||||
return this.staffService.updateStaff(user.actorId, BigInt(id), dto);
|
||||
return this.staffService.updateStaff(user, BigInt(id), dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.staffService.deleteStaff(user.actorId, BigInt(id));
|
||||
return this.staffService.deleteStaff(user, BigInt(id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +1,182 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { PartnerStaffRole } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
|
||||
|
||||
@Injectable()
|
||||
export class PartnerStaffService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listStaff(parentAccountId: bigint) {
|
||||
const rows = await this.prisma.partnerAccount.findMany({
|
||||
where: { parentAccountId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return rows.map((row) => this.toStaffItem(row));
|
||||
}
|
||||
|
||||
async createStaff(parentAccountId: bigint, dto: CreatePartnerStaffDto) {
|
||||
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: parentAccountId },
|
||||
});
|
||||
if (parent.isPrimary !== 1) {
|
||||
throw new BadRequestException('仅主账号可添加子账号');
|
||||
}
|
||||
|
||||
const phone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
}
|
||||
|
||||
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
||||
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||
|
||||
const account = await this.prisma.partnerAccount.create({
|
||||
data: {
|
||||
partnerId: parent.partnerId,
|
||||
phone,
|
||||
name,
|
||||
staffRole: (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL,
|
||||
isPrimary: 0,
|
||||
parentAccountId: parent.id,
|
||||
status: 'DISABLED',
|
||||
},
|
||||
});
|
||||
|
||||
return this.toStaffItem(account);
|
||||
}
|
||||
|
||||
async updateStaff(parentAccountId: bigint, staffId: bigint, dto: UpdatePartnerStaffDto) {
|
||||
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
||||
const data: Record<string, unknown> = {};
|
||||
if (dto.name !== undefined) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||
data.name = name;
|
||||
}
|
||||
if (dto.staffRole !== undefined) {
|
||||
data.staffRole = dto.staffRole as PartnerStaffRole;
|
||||
}
|
||||
if (dto.status !== undefined) {
|
||||
data.status = dto.status;
|
||||
}
|
||||
const updated = await this.prisma.partnerAccount.update({
|
||||
where: { id: staff.id },
|
||||
data,
|
||||
});
|
||||
return this.toStaffItem(updated);
|
||||
}
|
||||
|
||||
async deleteStaff(parentAccountId: bigint, staffId: bigint) {
|
||||
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
||||
await this.prisma.partnerAccount.delete({ where: { id: staff.id } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async assertStaffOwned(parentAccountId: bigint, staffId: bigint) {
|
||||
const staff = await this.prisma.partnerAccount.findFirst({
|
||||
where: { id: staffId, parentAccountId },
|
||||
});
|
||||
if (!staff) throw new NotFoundException('子账号不存在');
|
||||
return staff;
|
||||
}
|
||||
|
||||
private toStaffItem(row: {
|
||||
id: bigint;
|
||||
name: string;
|
||||
phone: string;
|
||||
staffRole: string | null;
|
||||
status: string;
|
||||
lastLoginAt: Date | null;
|
||||
}) {
|
||||
return serializeBigInt({
|
||||
id: row.id.toString(),
|
||||
name: row.name,
|
||||
phone: this.maskPhone(row.phone),
|
||||
staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL,
|
||||
status: row.status,
|
||||
lastLoginAt: row.lastLoginAt?.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
private maskPhone(phone: string): string {
|
||||
if (phone.length !== 11) return phone;
|
||||
return `${phone.slice(0, 3)} **** ${phone.slice(7)}`;
|
||||
}
|
||||
}
|
||||
import {
|
||||
|
||||
BadRequestException,
|
||||
|
||||
Injectable,
|
||||
|
||||
NotFoundException,
|
||||
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { PartnerStaffRole } from '@dukang/shared-types';
|
||||
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
import type { AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
|
||||
import { CreatePartnerStaffDto, UpdatePartnerStaffDto } from './dto/partner-staff.dto';
|
||||
|
||||
|
||||
|
||||
@Injectable()
|
||||
|
||||
export class PartnerStaffService {
|
||||
|
||||
constructor(
|
||||
|
||||
private readonly prisma: PrismaService,
|
||||
|
||||
private readonly analytics: AnalyticsService,
|
||||
|
||||
) {}
|
||||
|
||||
|
||||
|
||||
async listStaff(parentAccountId: bigint) {
|
||||
|
||||
const rows = await this.prisma.partnerAccount.findMany({
|
||||
|
||||
where: { parentAccountId },
|
||||
|
||||
orderBy: { createdAt: 'desc' },
|
||||
|
||||
});
|
||||
|
||||
return rows.map((row) => this.toStaffItem(row));
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async createStaff(actor: AuthUser, dto: CreatePartnerStaffDto) {
|
||||
|
||||
const parentAccountId = actor.actorId;
|
||||
|
||||
const parent = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
|
||||
where: { id: parentAccountId },
|
||||
|
||||
});
|
||||
|
||||
if (parent.isPrimary !== 1) {
|
||||
|
||||
throw new BadRequestException('仅主账号可添加子账号');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const phone = dto.phone.trim();
|
||||
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
const existing = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
||||
|
||||
if (existing) throw new BadRequestException('该手机号已被使用');
|
||||
|
||||
|
||||
|
||||
const name = dto.name.trim();
|
||||
|
||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||
|
||||
|
||||
|
||||
const staffRole = (dto.staffRole as PartnerStaffRole | undefined) ?? PartnerStaffRole.INTERNAL;
|
||||
|
||||
|
||||
|
||||
const account = await this.prisma.partnerAccount.create({
|
||||
|
||||
data: {
|
||||
|
||||
phone,
|
||||
|
||||
name,
|
||||
|
||||
staffRole,
|
||||
|
||||
permissions: dto.permissions ?? undefined,
|
||||
|
||||
isPrimary: 0,
|
||||
|
||||
parentAccountId: parent.id,
|
||||
|
||||
status: 'DISABLED',
|
||||
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
this.trackStaffEvent(actor, parent.id, 'partner_staff_create', account.id, {
|
||||
|
||||
name,
|
||||
|
||||
phone: this.maskPhone(phone),
|
||||
|
||||
staffRole,
|
||||
|
||||
status: account.status,
|
||||
|
||||
});
|
||||
|
||||
|
||||
|
||||
return this.toStaffItem(account);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
async updateStaff(actor: AuthUser, staffId: bigint, dto: UpdatePartnerStaffDto) {
|
||||
|
||||
const parentAccountId = actor.actorId;
|
||||
|
||||
const staff = await this.assertStaffOwned(parentAccountId, staffId);
|
||||
|
||||
const before = {
|
||||
|
||||
name: staff.name,
|
||||
|
||||
staffRole: staff.staffRole,
|
||||
|
||||
status: staff.status,
|
||||
|
||||
};
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
|
||||
const name = dto.name.trim();
|
||||
|
||||
if (!name) throw new BadRequestException('请填写真实姓名');
|
||||
|
||||
data.name = name;
|
||||
|
||||
}
|
||||
|
||||
if (dto.staffRole !== undefined) {
|
||||
|
||||
data.staffRole = dto.staffRole as PartnerStaffRole;
|
||||
|
||||
}
|
||||
|
||||
if (dto.permissions !== undefined) {
|
||||
|
||||
data.permissions = dto.permissions;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import type { AdminCitiesQueryDto } from './dto/admin-query.dto';
|
||||
import type { CreateCityDto, UpdateCityDto } from './dto/admin-mutate.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AdminCitiesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
) {}
|
||||
|
||||
async list(query: AdminCitiesQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
@@ -16,7 +21,9 @@ export class AdminCitiesService {
|
||||
if (query.name) where.name = { contains: query.name };
|
||||
if (query.code) where.code = { contains: query.code };
|
||||
if (query.status) where.status = query.status as Prisma.EnumCityStatusFilter['equals'];
|
||||
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
|
||||
if (query.partnerId) {
|
||||
where.partnerAccounts = { some: { id: BigInt(query.partnerId), isPrimary: 1 } };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.commonCity.findMany({
|
||||
@@ -25,8 +32,12 @@ export class AdminCitiesService {
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
partner: { select: { id: true, companyName: true } },
|
||||
_count: { select: { stores: true, orders: true } },
|
||||
partnerAccounts: {
|
||||
where: { isPrimary: 1 },
|
||||
select: { id: true, companyName: true, scopeType: true, bindingStatus: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
_count: { select: { stores: true, orders: true, partnerAccounts: true, warehouses: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.commonCity.count({ where }),
|
||||
@@ -34,8 +45,18 @@ export class AdminCitiesService {
|
||||
return serializeBigInt({
|
||||
items: items.map((c) => ({
|
||||
...c,
|
||||
partnerBindings: c.partnerAccounts.map((bp) => ({
|
||||
id: bp.id.toString(),
|
||||
partnerAccountId: bp.id.toString(),
|
||||
partnerCompanyName: bp.companyName,
|
||||
scopeType: bp.scopeType,
|
||||
status: bp.bindingStatus,
|
||||
})),
|
||||
partnerAccounts: undefined,
|
||||
storeCount: c._count.stores,
|
||||
orderCount: c._count.orders,
|
||||
partnerBindingCount: c.partnerAccounts.length,
|
||||
warehouseCount: c._count.warehouses,
|
||||
_count: undefined,
|
||||
})),
|
||||
total,
|
||||
@@ -48,13 +69,22 @@ export class AdminCitiesService {
|
||||
const city = await this.prisma.commonCity.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
partner: true,
|
||||
commissionRule: true,
|
||||
warehouses: {
|
||||
include: { partnerAccount: { select: { id: true, companyName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
},
|
||||
_count: { select: { stores: true, orders: true } },
|
||||
},
|
||||
});
|
||||
if (!city) throw new NotFoundException('开城城市不存在');
|
||||
return serializeBigInt(city);
|
||||
const cityPartners = await this.partnerCityService.listByCity(id);
|
||||
return serializeBigInt({
|
||||
...city,
|
||||
cityPartners,
|
||||
storeCount: city._count.stores,
|
||||
orderCount: city._count.orders,
|
||||
_count: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateCityDto) {
|
||||
@@ -65,31 +95,48 @@ export class AdminCitiesService {
|
||||
code: dto.code,
|
||||
name: dto.name,
|
||||
province: dto.province,
|
||||
partnerId: dto.partnerId ? BigInt(dto.partnerId) : null,
|
||||
status: (dto.status ?? 'PENDING') as 'PENDING' | 'ACTIVE' | 'PAUSED',
|
||||
commissionRule: {
|
||||
create: {
|
||||
orderCommissionRate: 0,
|
||||
redeemCommissionRate: 0.03,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return serializeBigInt(city);
|
||||
}
|
||||
|
||||
async update(id: bigint, dto: UpdateCityDto) {
|
||||
if (dto.maxPartnerCommissionRate !== undefined) {
|
||||
const maxRate = resolveMaxPartnerCommissionRate(dto.maxPartnerCommissionRate);
|
||||
const partners = await this.prisma.partnerAccount.findMany({
|
||||
where: { cityId: id, isPrimary: 1 },
|
||||
select: {
|
||||
companyName: true,
|
||||
orderCommissionRate: true,
|
||||
redeemCommissionRate: true,
|
||||
},
|
||||
});
|
||||
for (const partner of partners) {
|
||||
const check = validatePartnerCommissionRates(
|
||||
Number(partner.orderCommissionRate ?? 0),
|
||||
Number(partner.redeemCommissionRate ?? 0.03),
|
||||
maxRate,
|
||||
);
|
||||
if (!check.ok) {
|
||||
throw new BadRequestException(
|
||||
`无法保存:合伙人「${partner.companyName ?? '—'}」${check.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const city = await this.prisma.commonCity.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name } : {}),
|
||||
...(dto.province !== undefined ? { province: dto.province } : {}),
|
||||
...(dto.partnerId !== undefined
|
||||
? { partnerId: dto.partnerId ? BigInt(dto.partnerId) : null }
|
||||
: {}),
|
||||
...(dto.status !== undefined ? { status: dto.status as 'PENDING' | 'ACTIVE' | 'PAUSED' } : {}),
|
||||
...(dto.localMinQty !== undefined ? { localMinQty: dto.localMinQty } : {}),
|
||||
...(dto.crossMinQty !== undefined ? { crossMinQty: dto.crossMinQty } : {}),
|
||||
...(dto.maxPartnerCommissionRate !== undefined
|
||||
? { maxPartnerCommissionRate: dto.maxPartnerCommissionRate }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
return serializeBigInt(city);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { CityWarehouseService } from '../city-scope/city-warehouse.service';
|
||||
import { CreateCityWarehouseDto, UpdateCityWarehouseDto } from './dto/admin-mutate.dto';
|
||||
import { AdminCityWarehousesQueryDto } from './dto/admin-query.dto';
|
||||
import type { WarehouseManagerType, WarehouseStatus } from '@prisma/client';
|
||||
|
||||
@Controller('admin/cities/:cityId/warehouses')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminCityWarehousesController {
|
||||
constructor(private readonly service: CityWarehouseService) {}
|
||||
|
||||
@Get()
|
||||
list(@Param('cityId') cityId: string) {
|
||||
return this.service.listByCity(BigInt(cityId));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WAREHOUSE_CREATE,
|
||||
refType: 'WAREHOUSE',
|
||||
refIdField: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
create(@Param('cityId') cityId: string, @Body() dto: CreateCityWarehouseDto) {
|
||||
return this.service.create(BigInt(cityId), {
|
||||
name: dto.name,
|
||||
address: dto.address,
|
||||
contactName: dto.contactName,
|
||||
contactPhone: dto.contactPhone,
|
||||
managerType: dto.managerType as WarehouseManagerType,
|
||||
partnerAccountId: dto.partnerAccountId ? BigInt(dto.partnerAccountId) : undefined,
|
||||
status: dto.status as WarehouseStatus | undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/city-warehouses')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminCityWarehouseMutationsController {
|
||||
constructor(private readonly service: CityWarehouseService) {}
|
||||
|
||||
@Get()
|
||||
listAll(@Query() query: AdminCityWarehousesQueryDto) {
|
||||
return this.service.listAll(query);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WAREHOUSE_UPDATE,
|
||||
refType: 'WAREHOUSE',
|
||||
refIdParam: 'id',
|
||||
includeBody: true,
|
||||
})
|
||||
update(@Param('id') id: string, @Body() dto: UpdateCityWarehouseDto) {
|
||||
return this.service.update(BigInt(id), {
|
||||
name: dto.name,
|
||||
address: dto.address,
|
||||
contactName: dto.contactName,
|
||||
contactPhone: dto.contactPhone,
|
||||
managerType: dto.managerType as WarehouseManagerType | undefined,
|
||||
partnerAccountId:
|
||||
dto.partnerAccountId === null
|
||||
? undefined
|
||||
: dto.partnerAccountId
|
||||
? BigInt(dto.partnerAccountId)
|
||||
: undefined,
|
||||
status: dto.status as WarehouseStatus | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.WAREHOUSE_DELETE,
|
||||
refType: 'WAREHOUSE',
|
||||
refIdParam: 'id',
|
||||
})
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(BigInt(id));
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ export class AdminDashboardService {
|
||||
_count: { status: true },
|
||||
}),
|
||||
this.prisma.store.count(),
|
||||
this.prisma.partner.count(),
|
||||
this.prisma.partnerAccount.count({ where: { isPrimary: 1 } }),
|
||||
this.prisma.redeemRecord.count({ where: { createdAt: { gte: todayStart } } }),
|
||||
this.prisma.orderDelivery.count(),
|
||||
this.prisma.storePayout.count({ where: { status: 'PENDING' } }),
|
||||
|
||||
@@ -15,12 +15,12 @@ export class AdminPartnerLogsService {
|
||||
async list(query: AdminPartnerLogsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const partnerIds = await this.resolvePartnerIds(query);
|
||||
if (partnerIds && partnerIds.length === 0) {
|
||||
const partnerAccountIds = await this.resolvePartnerAccountIds(query);
|
||||
if (partnerAccountIds && partnerAccountIds.length === 0) {
|
||||
return { items: [], total: 0, page, pageSize };
|
||||
}
|
||||
|
||||
const where = this.buildWhere(query, partnerIds);
|
||||
const where = this.buildWhere(query, partnerAccountIds);
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.logPartnerAnalytics.findMany({
|
||||
where,
|
||||
@@ -44,10 +44,10 @@ export class AdminPartnerLogsService {
|
||||
|
||||
private buildWhere(
|
||||
query: AdminPartnerLogsQueryDto,
|
||||
partnerIds?: bigint[],
|
||||
partnerAccountIds?: bigint[],
|
||||
): Prisma.LogPartnerAnalyticsWhereInput {
|
||||
const where: Prisma.LogPartnerAnalyticsWhereInput = {};
|
||||
if (partnerIds) where.partnerId = { in: partnerIds };
|
||||
if (partnerAccountIds) where.partnerAccountId = { in: partnerAccountIds };
|
||||
if (query.partnerAccountId) where.partnerAccountId = BigInt(query.partnerAccountId);
|
||||
const categoryEvents = query.eventName
|
||||
? [query.eventName]
|
||||
@@ -64,40 +64,23 @@ export class AdminPartnerLogsService {
|
||||
return where;
|
||||
}
|
||||
|
||||
private async resolvePartnerIds(query: AdminPartnerLogsQueryDto): Promise<bigint[] | undefined> {
|
||||
private async resolvePartnerAccountIds(
|
||||
query: AdminPartnerLogsQueryDto,
|
||||
): Promise<bigint[] | undefined> {
|
||||
if (query.partnerAccountId) return [BigInt(query.partnerAccountId)];
|
||||
if (query.partnerId) return [BigInt(query.partnerId)];
|
||||
|
||||
const partnerWhere: Prisma.PartnerWhereInput = {};
|
||||
if (query.companyName) partnerWhere.companyName = { contains: query.companyName };
|
||||
const accountWhere: Prisma.PartnerAccountWhereInput = { isPrimary: 1 };
|
||||
if (query.companyName) accountWhere.companyName = { contains: query.companyName };
|
||||
if (query.phone) accountWhere.phone = { contains: query.phone };
|
||||
|
||||
if (query.partnerAccountId || query.phone) {
|
||||
const accountWhere: Prisma.PartnerAccountWhereInput = {};
|
||||
if (query.partnerAccountId) accountWhere.id = BigInt(query.partnerAccountId);
|
||||
if (query.phone) accountWhere.phone = { contains: query.phone };
|
||||
if (query.companyName || query.phone) {
|
||||
const accounts = await this.prisma.partnerAccount.findMany({
|
||||
where: accountWhere,
|
||||
select: { partnerId: true },
|
||||
take: 100,
|
||||
});
|
||||
if (accounts.length === 0) return [];
|
||||
const ids = [...new Set(accounts.map((a) => a.partnerId))];
|
||||
if (query.companyName) {
|
||||
const partners = await this.prisma.partner.findMany({
|
||||
where: { id: { in: ids }, ...partnerWhere },
|
||||
select: { id: true },
|
||||
});
|
||||
return partners.map((p) => p.id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
if (query.companyName) {
|
||||
const partners = await this.prisma.partner.findMany({
|
||||
where: partnerWhere,
|
||||
select: { id: true },
|
||||
take: 100,
|
||||
});
|
||||
return partners.map((p) => p.id);
|
||||
return accounts.map((a) => a.id);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
@@ -106,8 +89,7 @@ export class AdminPartnerLogsService {
|
||||
private async enrichRows(
|
||||
rows: Array<{
|
||||
id: bigint;
|
||||
partnerAccountId: bigint | null;
|
||||
partnerId: bigint;
|
||||
partnerAccountId: bigint;
|
||||
eventName: string;
|
||||
clientApp: string | null;
|
||||
refType: string | null;
|
||||
@@ -116,37 +98,25 @@ export class AdminPartnerLogsService {
|
||||
createdAt: Date;
|
||||
}>,
|
||||
) {
|
||||
const partnerIds = [...new Set(rows.map((r) => r.partnerId))];
|
||||
const accountIds = [...new Set(rows.map((r) => r.partnerAccountId).filter((id): id is bigint => id != null))];
|
||||
const accountIds = [...new Set(rows.map((r) => r.partnerAccountId))];
|
||||
const accounts = accountIds.length
|
||||
? await this.prisma.partnerAccount.findMany({
|
||||
where: { id: { in: accountIds } },
|
||||
select: { id: true, name: true, phone: true, companyName: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const [partners, accounts] = await Promise.all([
|
||||
partnerIds.length
|
||||
? this.prisma.partner.findMany({
|
||||
where: { id: { in: partnerIds } },
|
||||
select: { id: true, companyName: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
accountIds.length
|
||||
? this.prisma.partnerAccount.findMany({
|
||||
where: { id: { in: accountIds } },
|
||||
select: { id: true, name: true, phone: true },
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const partnerMap = new Map(partners.map((p) => [p.id.toString(), p] as const));
|
||||
const accountMap = new Map(accounts.map((a) => [a.id.toString(), a] as const));
|
||||
|
||||
return rows.map((row) => {
|
||||
const partner = partnerMap.get(row.partnerId.toString());
|
||||
const account = row.partnerAccountId ? accountMap.get(row.partnerAccountId.toString()) : undefined;
|
||||
const account = accountMap.get(row.partnerAccountId.toString());
|
||||
return {
|
||||
id: row.id.toString(),
|
||||
partnerId: row.partnerId.toString(),
|
||||
partnerAccountId: row.partnerAccountId?.toString() ?? null,
|
||||
partnerId: row.partnerAccountId.toString(),
|
||||
partnerAccountId: row.partnerAccountId.toString(),
|
||||
accountName: account?.name ?? null,
|
||||
accountPhone: account?.phone ?? null,
|
||||
companyName: partner?.companyName ?? null,
|
||||
companyName: account?.companyName ?? null,
|
||||
category: resolvePartnerLogCategory(row.eventName),
|
||||
eventName: row.eventName,
|
||||
clientApp: row.clientApp,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Prisma, CityPartnerScopeType, CityPartnerStatus } from '@prisma/client';
|
||||
import { resolveMaxPartnerCommissionRate, validatePartnerCommissionRates } from '@dukang/domain';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import type { AdminPartnerAccountsQueryDto, AdminPartnersQueryDto } from './dto/admin-query.dto';
|
||||
import type {
|
||||
CreatePartnerAccountDto,
|
||||
@@ -10,77 +12,36 @@ import type {
|
||||
UpdatePartnerDto,
|
||||
} from './dto/admin-mutate.dto';
|
||||
|
||||
const PRIMARY_WHERE = { isPrimary: 1 } as const;
|
||||
|
||||
function assertPartnerCommissionRates(
|
||||
city: { maxPartnerCommissionRate: Prisma.Decimal | number | null },
|
||||
orderCommissionRate: number,
|
||||
redeemCommissionRate: number,
|
||||
) {
|
||||
const maxRate = resolveMaxPartnerCommissionRate(
|
||||
city.maxPartnerCommissionRate != null ? Number(city.maxPartnerCommissionRate) : null,
|
||||
);
|
||||
const check = validatePartnerCommissionRates(orderCommissionRate, redeemCommissionRate, maxRate);
|
||||
if (!check.ok) throw new BadRequestException(check.message);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminPartnersService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
) {}
|
||||
|
||||
async listPartners(query: AdminPartnersQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.PartnerWhereInput = {};
|
||||
const where: Prisma.PartnerAccountWhereInput = { ...PRIMARY_WHERE };
|
||||
if (query.companyName) where.companyName = { contains: query.companyName };
|
||||
if (query.contactPhone) where.contactPhone = { contains: query.contactPhone };
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.partner.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
_count: { select: { stores: true, accounts: true, cities: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.partner.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
items: items.map((p) => ({
|
||||
...p,
|
||||
storeCount: p._count.stores,
|
||||
accountCount: p._count.accounts,
|
||||
cityCount: p._count.cities,
|
||||
_count: undefined,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async detailPartner(id: bigint) {
|
||||
const partner = await this.prisma.partner.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
cities: { select: { id: true, code: true, name: true, status: true } },
|
||||
accounts: { select: { id: true, phone: true, name: true, isPrimary: true, status: true } },
|
||||
stores: { select: { id: true, name: true, status: true }, take: 10, orderBy: { createdAt: 'desc' } },
|
||||
_count: { select: { stores: true, accounts: true } },
|
||||
},
|
||||
});
|
||||
if (!partner) throw new NotFoundException('开城合伙人不存在');
|
||||
return serializeBigInt(partner);
|
||||
}
|
||||
|
||||
async createPartner(dto: CreatePartnerDto) {
|
||||
const partner = await this.prisma.partner.create({ data: dto });
|
||||
return serializeBigInt(partner);
|
||||
}
|
||||
|
||||
async updatePartner(id: bigint, dto: UpdatePartnerDto) {
|
||||
const partner = await this.prisma.partner.update({ where: { id }, data: dto });
|
||||
return serializeBigInt(partner);
|
||||
}
|
||||
|
||||
async listPartnerAccounts(query: AdminPartnerAccountsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.PartnerAccountWhereInput = {};
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
|
||||
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
||||
if (query.isPrimary === '0' || query.isPrimary === '1') {
|
||||
where.isPrimary = Number(query.isPrimary);
|
||||
}
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.partnerId) where.id = BigInt(query.partnerId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.partnerAccount.findMany({
|
||||
@@ -89,8 +50,257 @@ export class AdminPartnersService {
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
partner: { select: { id: true, companyName: true } },
|
||||
parent: { select: { id: true, name: true, phone: true } },
|
||||
city: { select: { id: true, code: true, name: true, status: true, maxPartnerCommissionRate: true } },
|
||||
managedWarehouse: { select: { id: true, name: true } },
|
||||
children: {
|
||||
select: {
|
||||
id: true,
|
||||
phone: true,
|
||||
name: true,
|
||||
staffRole: true,
|
||||
permissions: true,
|
||||
status: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
_count: { select: { stores: true, children: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.partnerAccount.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({
|
||||
items: items.map((p) => ({
|
||||
id: p.id.toString(),
|
||||
companyName: p.companyName,
|
||||
contactPhone: p.contactPhone,
|
||||
phone: p.phone,
|
||||
name: p.name,
|
||||
cityId: p.cityId?.toString() ?? null,
|
||||
cityName: p.city?.name ?? null,
|
||||
maxPartnerCommissionRate:
|
||||
p.city?.maxPartnerCommissionRate != null ? Number(p.city.maxPartnerCommissionRate) : null,
|
||||
scopeType: p.scopeType,
|
||||
orderCommissionRate: Number(p.orderCommissionRate ?? 0),
|
||||
redeemCommissionRate: Number(p.redeemCommissionRate ?? 0.03),
|
||||
bindingStatus: p.bindingStatus,
|
||||
managedWarehouseId: p.managedWarehouseId?.toString() ?? null,
|
||||
managedWarehouseName: p.managedWarehouse?.name ?? null,
|
||||
storeCount: p._count.stores,
|
||||
accountCount: p._count.children + 1,
|
||||
children: p.children.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
phone: c.phone,
|
||||
name: c.name,
|
||||
staffRole: c.staffRole,
|
||||
permissions: c.permissions,
|
||||
status: c.status,
|
||||
})),
|
||||
createdAt: p.createdAt,
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async detailPartner(id: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findFirst({
|
||||
where: { id, ...PRIMARY_WHERE },
|
||||
include: {
|
||||
city: { select: { id: true, code: true, name: true, status: true, maxPartnerCommissionRate: true } },
|
||||
managedWarehouse: { select: { id: true, name: true } },
|
||||
children: {
|
||||
where: { isPrimary: 0 },
|
||||
select: {
|
||||
id: true,
|
||||
phone: true,
|
||||
name: true,
|
||||
staffRole: true,
|
||||
permissions: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
},
|
||||
stores: { select: { id: true, name: true, status: true }, take: 10, orderBy: { createdAt: 'desc' } },
|
||||
_count: { select: { stores: true, children: true } },
|
||||
},
|
||||
});
|
||||
if (!account) throw new NotFoundException('开城合伙人不存在');
|
||||
return serializeBigInt({
|
||||
...this.partnerCityService.toDto({
|
||||
...account,
|
||||
city: account.city,
|
||||
}),
|
||||
contactPhone: account.contactPhone,
|
||||
address: account.address,
|
||||
bankAccountName: account.bankAccountName,
|
||||
bankAccountNo: account.bankAccountNo,
|
||||
bankBranch: account.bankBranch,
|
||||
managedWarehouseName: account.managedWarehouse?.name ?? null,
|
||||
accountCount: account._count.children + 1,
|
||||
maxPartnerCommissionRate:
|
||||
account.city?.maxPartnerCommissionRate != null
|
||||
? Number(account.city.maxPartnerCommissionRate)
|
||||
: null,
|
||||
children: account.children.map((c) => ({
|
||||
id: c.id.toString(),
|
||||
phone: c.phone,
|
||||
name: c.name,
|
||||
staffRole: c.staffRole,
|
||||
permissions: c.permissions,
|
||||
status: c.status,
|
||||
createdAt: c.createdAt,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
async createPartner(dto: CreatePartnerDto) {
|
||||
const phone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new BadRequestException('请输入正确的登录手机号');
|
||||
}
|
||||
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
||||
if (phoneTaken) throw new BadRequestException('该手机号已被使用');
|
||||
|
||||
const cityId = BigInt(dto.cityId);
|
||||
const city = await this.prisma.commonCity.findUnique({ where: { id: cityId } });
|
||||
if (!city) throw new NotFoundException('开城城市不存在');
|
||||
|
||||
await this.partnerCityService.validatePrimaryBinding(cityId, {
|
||||
scopeType: dto.scopeType as CityPartnerScopeType,
|
||||
districtCodes: dto.districtCodes,
|
||||
});
|
||||
|
||||
const orderCommissionRate = dto.orderCommissionRate ?? 0;
|
||||
const redeemCommissionRate = dto.redeemCommissionRate ?? 0.03;
|
||||
assertPartnerCommissionRates(city, orderCommissionRate, redeemCommissionRate);
|
||||
|
||||
const account = await this.prisma.partnerAccount.create({
|
||||
data: {
|
||||
phone,
|
||||
name: dto.name.trim(),
|
||||
isPrimary: 1,
|
||||
staffRole: 'PARTNER',
|
||||
status: 'ACTIVE',
|
||||
cityId,
|
||||
scopeType: dto.scopeType as CityPartnerScopeType,
|
||||
districtCodes:
|
||||
dto.scopeType === 'DISTRICT' ? (dto.districtCodes ?? []) : Prisma.JsonNull,
|
||||
orderCommissionRate: orderCommissionRate,
|
||||
redeemCommissionRate: redeemCommissionRate,
|
||||
bindingStatus: (dto.bindingStatus ?? 'ACTIVE') as CityPartnerStatus,
|
||||
companyName: dto.companyName.trim(),
|
||||
address: dto.address.trim(),
|
||||
contactPhone: dto.contactPhone?.trim() ?? phone,
|
||||
contractNo: dto.contractNo,
|
||||
bankAccountName: dto.bankAccountName,
|
||||
bankAccountNo: dto.bankAccountNo,
|
||||
bankBranch: dto.bankBranch,
|
||||
weeklyStoreTarget: dto.weeklyStoreTarget ?? 20,
|
||||
},
|
||||
include: { city: { select: { id: true, code: true, name: true } } },
|
||||
});
|
||||
|
||||
return serializeBigInt(this.partnerCityService.toDto(account));
|
||||
}
|
||||
|
||||
async updatePartner(id: bigint, dto: UpdatePartnerDto) {
|
||||
const existing = await this.prisma.partnerAccount.findFirst({
|
||||
where: { id, ...PRIMARY_WHERE },
|
||||
});
|
||||
if (!existing) throw new NotFoundException('开城合伙人不存在');
|
||||
|
||||
const city = await this.prisma.commonCity.findUniqueOrThrow({ where: { id: existing.cityId! } });
|
||||
const cityId = existing.cityId!;
|
||||
const scopeType = (dto.scopeType ?? existing.scopeType) as CityPartnerScopeType;
|
||||
const districtCodes =
|
||||
scopeType === 'DISTRICT'
|
||||
? dto.districtCodes ?? this.partnerCityService.parseDistrictCodes(existing.districtCodes)
|
||||
: null;
|
||||
|
||||
await this.partnerCityService.validatePrimaryBinding(
|
||||
cityId,
|
||||
{
|
||||
partnerAccountId: id.toString(),
|
||||
scopeType,
|
||||
districtCodes: districtCodes ?? undefined,
|
||||
},
|
||||
id,
|
||||
);
|
||||
|
||||
if (dto.phone !== undefined) {
|
||||
const phone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new BadRequestException('请输入正确的登录手机号');
|
||||
}
|
||||
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
||||
if (phoneTaken && phoneTaken.id !== id) {
|
||||
throw new BadRequestException('该手机号已被使用');
|
||||
}
|
||||
}
|
||||
|
||||
const orderCommissionRate =
|
||||
dto.orderCommissionRate !== undefined
|
||||
? dto.orderCommissionRate
|
||||
: Number(existing.orderCommissionRate ?? 0);
|
||||
const redeemCommissionRate =
|
||||
dto.redeemCommissionRate !== undefined
|
||||
? dto.redeemCommissionRate
|
||||
: Number(existing.redeemCommissionRate ?? 0.03);
|
||||
if (dto.orderCommissionRate !== undefined || dto.redeemCommissionRate !== undefined) {
|
||||
assertPartnerCommissionRates(city, orderCommissionRate, redeemCommissionRate);
|
||||
}
|
||||
|
||||
const account = await this.prisma.partnerAccount.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||
...(dto.phone !== undefined ? { phone: dto.phone.trim() } : {}),
|
||||
...(dto.companyName !== undefined ? { companyName: dto.companyName.trim() } : {}),
|
||||
...(dto.address !== undefined ? { address: dto.address.trim() } : {}),
|
||||
...(dto.contactPhone !== undefined ? { contactPhone: dto.contactPhone.trim() } : {}),
|
||||
...(dto.scopeType !== undefined ? { scopeType: dto.scopeType as CityPartnerScopeType } : {}),
|
||||
...(dto.scopeType !== undefined || dto.districtCodes !== undefined
|
||||
? {
|
||||
districtCodes:
|
||||
scopeType === 'DISTRICT'
|
||||
? ((districtCodes ?? []) as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
}
|
||||
: {}),
|
||||
...(dto.orderCommissionRate !== undefined ? { orderCommissionRate: dto.orderCommissionRate } : {}),
|
||||
...(dto.redeemCommissionRate !== undefined ? { redeemCommissionRate: dto.redeemCommissionRate } : {}),
|
||||
...(dto.bindingStatus !== undefined ? { bindingStatus: dto.bindingStatus as CityPartnerStatus } : {}),
|
||||
...(dto.contractNo !== undefined ? { contractNo: dto.contractNo } : {}),
|
||||
...(dto.bankAccountName !== undefined ? { bankAccountName: dto.bankAccountName } : {}),
|
||||
...(dto.bankAccountNo !== undefined ? { bankAccountNo: dto.bankAccountNo } : {}),
|
||||
...(dto.bankBranch !== undefined ? { bankBranch: dto.bankBranch } : {}),
|
||||
...(dto.weeklyStoreTarget !== undefined ? { weeklyStoreTarget: dto.weeklyStoreTarget } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status as 'ACTIVE' | 'DISABLED' } : {}),
|
||||
},
|
||||
include: { city: { select: { id: true, code: true, name: true } } },
|
||||
});
|
||||
|
||||
return serializeBigInt(this.partnerCityService.toDto(account));
|
||||
}
|
||||
|
||||
async listPartnerAccounts(query: AdminPartnerAccountsQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.PartnerAccountWhereInput = { isPrimary: 0 };
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
if (query.partnerId) where.parentAccountId = BigInt(query.partnerId);
|
||||
if (query.status) where.status = query.status as Prisma.EnumAccountStatusFilter['equals'];
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.partnerAccount.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: {
|
||||
parent: { select: { id: true, name: true, phone: true, companyName: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.partnerAccount.count({ where }),
|
||||
@@ -98,14 +308,14 @@ export class AdminPartnersService {
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async listPartnerAccountTree(partnerId?: bigint) {
|
||||
const where: Prisma.PartnerAccountWhereInput = {};
|
||||
if (partnerId) where.partnerId = partnerId;
|
||||
async listPartnerAccountTree(primaryAccountId?: bigint) {
|
||||
const where: Prisma.PartnerAccountWhereInput = primaryAccountId
|
||||
? { OR: [{ id: primaryAccountId }, { parentAccountId: primaryAccountId }] }
|
||||
: { isPrimary: 1 };
|
||||
|
||||
const accounts = await this.prisma.partnerAccount.findMany({
|
||||
where,
|
||||
orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }],
|
||||
include: { partner: { select: { id: true, companyName: true } } },
|
||||
});
|
||||
|
||||
type TreeNode = (typeof accounts)[number] & { children: TreeNode[] };
|
||||
@@ -122,7 +332,7 @@ export class AdminPartnersService {
|
||||
const parent = nodeMap.get(account.parentAccountId.toString());
|
||||
if (parent) parent.children.push(node);
|
||||
else roots.push(node);
|
||||
} else {
|
||||
} else if (account.isPrimary === 1) {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
@@ -134,8 +344,9 @@ export class AdminPartnersService {
|
||||
status: node.status,
|
||||
isPrimary: node.isPrimary,
|
||||
staffRole: node.staffRole,
|
||||
permissions: node.permissions,
|
||||
parentAccountId: node.parentAccountId,
|
||||
partner: node.partner,
|
||||
companyName: node.companyName,
|
||||
createdAt: node.createdAt,
|
||||
lastLoginAt: node.lastLoginAt,
|
||||
children: node.children.length ? node.children.map(mapNode) : undefined,
|
||||
@@ -148,27 +359,21 @@ export class AdminPartnersService {
|
||||
const account = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
partner: {
|
||||
select: {
|
||||
id: true,
|
||||
companyName: true,
|
||||
contactPhone: true,
|
||||
address: true,
|
||||
},
|
||||
},
|
||||
parent: { select: { id: true, name: true, phone: true } },
|
||||
parent: { select: { id: true, name: true, phone: true, companyName: true } },
|
||||
},
|
||||
});
|
||||
if (!account) throw new NotFoundException('开城合伙人账号不存在');
|
||||
if (!account) throw new NotFoundException('合伙人账号不存在');
|
||||
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(id);
|
||||
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
const [bills, orders] = await Promise.all([
|
||||
this.prisma.partnerBill.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
where: { partnerAccountId: primary.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
}),
|
||||
this.prisma.order.findMany({
|
||||
where: { city: { partnerId: account.partnerId } },
|
||||
where: orderWhere,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
select: {
|
||||
@@ -182,10 +387,14 @@ export class AdminPartnersService {
|
||||
}),
|
||||
]);
|
||||
|
||||
return serializeBigInt({ ...account, bills, orders });
|
||||
return serializeBigInt({ ...account, primaryAccountId: primary.id, bills, orders });
|
||||
}
|
||||
|
||||
async createPartnerAccount(dto: CreatePartnerAccountDto) {
|
||||
if (!dto.parentAccountId) {
|
||||
throw new BadRequestException('请指定主账号 parentAccountId 创建子账号');
|
||||
}
|
||||
|
||||
const phone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
throw new BadRequestException('请输入正确的手机号码');
|
||||
@@ -193,53 +402,40 @@ export class AdminPartnersService {
|
||||
const phoneTaken = await this.prisma.partnerAccount.findUnique({ where: { phone } });
|
||||
if (phoneTaken) throw new BadRequestException('该手机号已被使用');
|
||||
|
||||
if (dto.parentAccountId) {
|
||||
const parent = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: BigInt(dto.parentAccountId) },
|
||||
});
|
||||
if (!parent) throw new BadRequestException('主账号不存在');
|
||||
if (parent.isPrimary !== 1) throw new BadRequestException('仅可向主账号添加子账号');
|
||||
if (dto.partnerId && dto.partnerId !== parent.partnerId.toString()) {
|
||||
throw new BadRequestException('开城合伙人与主账号不匹配');
|
||||
}
|
||||
|
||||
const account = await this.prisma.partnerAccount.create({
|
||||
data: {
|
||||
partnerId: parent.partnerId,
|
||||
phone,
|
||||
name: dto.name.trim(),
|
||||
staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER',
|
||||
isPrimary: 0,
|
||||
parentAccountId: parent.id,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
include: { partner: { select: { id: true, companyName: true } } },
|
||||
});
|
||||
return serializeBigInt(account);
|
||||
const parent = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: BigInt(dto.parentAccountId) },
|
||||
});
|
||||
if (!parent) throw new BadRequestException('主账号不存在');
|
||||
if (parent.isPrimary !== 1) {
|
||||
throw new BadRequestException('仅可向主账号添加子账号,不支持多级子账号');
|
||||
}
|
||||
|
||||
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId!) } });
|
||||
if (!partner) throw new BadRequestException('开城合伙人不存在');
|
||||
const account = await this.prisma.partnerAccount.create({
|
||||
data: {
|
||||
partnerId: partner.id,
|
||||
phone,
|
||||
name: dto.name.trim(),
|
||||
staffRole: dto.staffRole ? (dto.staffRole as 'PARTNER' | 'INTERNAL' | 'PROMOTER') : undefined,
|
||||
staffRole: (dto.staffRole ?? 'INTERNAL') as 'PARTNER' | 'INTERNAL' | 'PROMOTER',
|
||||
permissions: dto.permissions ?? undefined,
|
||||
isPrimary: 0,
|
||||
parentAccountId: parent.id,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
include: { partner: { select: { id: true, companyName: true } } },
|
||||
});
|
||||
return serializeBigInt(account);
|
||||
}
|
||||
|
||||
async updatePartnerAccount(id: bigint, dto: UpdatePartnerAccountDto) {
|
||||
const existing = await this.prisma.partnerAccount.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('开城合伙人账号不存在');
|
||||
if (!existing) throw new NotFoundException('合伙人账号不存在');
|
||||
if (existing.isPrimary === 1) {
|
||||
throw new BadRequestException('请通过开城合伙人接口编辑主账号');
|
||||
}
|
||||
|
||||
const data: Prisma.PartnerAccountUpdateInput = {};
|
||||
if (dto.name !== undefined) data.name = dto.name;
|
||||
if (dto.status !== undefined) data.status = dto.status as 'ACTIVE' | 'DISABLED';
|
||||
if (dto.staffRole !== undefined) data.staffRole = dto.staffRole as 'PARTNER' | 'INTERNAL' | 'PROMOTER';
|
||||
if (dto.permissions !== undefined) data.permissions = dto.permissions;
|
||||
if (dto.phone !== undefined) {
|
||||
const phone = dto.phone.trim();
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
@@ -258,7 +454,7 @@ export class AdminPartnersService {
|
||||
|
||||
async deletePartnerSubAccount(id: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUnique({ where: { id } });
|
||||
if (!account) throw new NotFoundException('开城合伙人账号不存在');
|
||||
if (!account) throw new NotFoundException('合伙人账号不存在');
|
||||
if (!account.parentAccountId) {
|
||||
throw new BadRequestException('仅可删除子账号');
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export class AdminRedeemService {
|
||||
where: { id },
|
||||
include: {
|
||||
user: true,
|
||||
store: { include: { partner: { select: { id: true, companyName: true } } } },
|
||||
store: { include: { partnerAccount: { select: { id: true, companyName: true } } } },
|
||||
coupon: true,
|
||||
payout: true,
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import type {
|
||||
CreateStoreAccountDto,
|
||||
CreateStoreDto,
|
||||
@@ -16,7 +17,10 @@ import type {
|
||||
|
||||
@Injectable()
|
||||
export class AdminStoresService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
) {}
|
||||
|
||||
async listStores(query: AdminStoresQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
@@ -25,7 +29,7 @@ export class AdminStoresService {
|
||||
if (query.name) where.name = { contains: query.name };
|
||||
if (query.status) where.status = query.status as Prisma.EnumStoreStatusFilter['equals'];
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
|
||||
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
||||
if (query.phone) where.phone = { contains: query.phone };
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
@@ -36,7 +40,7 @@ export class AdminStoresService {
|
||||
take: pageSize,
|
||||
include: {
|
||||
cityRef: { select: { id: true, name: true, code: true } },
|
||||
partner: { select: { id: true, companyName: true } },
|
||||
partnerAccount: { select: { id: true, companyName: true } },
|
||||
account: { select: { id: true, phone: true, name: true, status: true } },
|
||||
coverResource: { select: { id: true, url: true } },
|
||||
},
|
||||
@@ -56,7 +60,7 @@ export class AdminStoresService {
|
||||
where: { id },
|
||||
include: {
|
||||
cityRef: true,
|
||||
partner: true,
|
||||
partnerAccount: true,
|
||||
category: true,
|
||||
account: true,
|
||||
coverResource: true,
|
||||
@@ -123,6 +127,7 @@ export class AdminStoresService {
|
||||
...(dto.intro !== undefined ? { intro: dto.intro } : {}),
|
||||
...(dto.address !== undefined ? { address: dto.address } : {}),
|
||||
...(dto.district !== undefined ? { district: dto.district } : {}),
|
||||
...(dto.settlementRate !== undefined ? { settlementRate: dto.settlementRate } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -162,18 +167,22 @@ export class AdminStoresService {
|
||||
});
|
||||
if (existingAccount) throw new BadRequestException('该手机号已绑定门店');
|
||||
|
||||
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } });
|
||||
if (!partner) throw new BadRequestException('开城合伙人不存在');
|
||||
const partnerAccountId = BigInt(dto.partnerAccountId);
|
||||
const partnerAccount = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
if (!partnerAccount || partnerAccount.isPrimary !== 1) {
|
||||
throw new BadRequestException('开城合伙人不存在');
|
||||
}
|
||||
const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } });
|
||||
if (!city) throw new BadRequestException('开城城市不存在');
|
||||
if (city.partnerId && city.partnerId !== partner.id) {
|
||||
throw new BadRequestException('开城城市与合伙人不匹配');
|
||||
}
|
||||
await this.partnerCityService.assertPartnerAccountBoundToCity(partnerAccountId, city.id);
|
||||
|
||||
const store = await this.prisma.store.create({
|
||||
data: {
|
||||
cityId: city.id,
|
||||
partnerId: partner.id,
|
||||
partnerAccountId,
|
||||
settlementRate: dto.settlementRate ?? 0.6,
|
||||
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
|
||||
name: dto.name,
|
||||
phone: normalizedPhone,
|
||||
@@ -359,7 +368,7 @@ export class AdminStoresService {
|
||||
async detailStoreAccount(id: bigint) {
|
||||
const account = await this.prisma.storeAccount.findUnique({
|
||||
where: { id },
|
||||
include: { store: { include: { cityRef: true, partner: true } } },
|
||||
include: { store: { include: { cityRef: true, partnerAccount: true } } },
|
||||
});
|
||||
if (!account) throw new NotFoundException('门店账号不存在');
|
||||
return serializeBigInt(account);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { BenefitService } from '../benefit/benefit.service';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import { TicketService } from '../common/ticket.service';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import type { TicketListQueryDto } from '../common/dto/common-query.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -13,6 +14,7 @@ export class AdminTicketsService {
|
||||
private readonly ticketService: TicketService,
|
||||
private readonly tradeService: TradeService,
|
||||
private readonly benefitService: BenefitService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
) {}
|
||||
|
||||
list(query: TicketListQueryDto) {
|
||||
@@ -82,15 +84,9 @@ export class AdminTicketsService {
|
||||
}
|
||||
|
||||
async listPartnerReshipments(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
select: { id: true },
|
||||
});
|
||||
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(partnerAccountId);
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { cityId: { in: cities.map((c) => c.id) } },
|
||||
where: orderWhere,
|
||||
select: { id: true },
|
||||
});
|
||||
const orderIds = orders.map((o) => o.id);
|
||||
|
||||
@@ -213,11 +213,23 @@ export class AdminWechatBindingsService {
|
||||
|
||||
const accounts = await this.prisma.partnerAccount.findMany({
|
||||
where,
|
||||
include: { partner: { select: { id: true, companyName: true } } },
|
||||
select: {
|
||||
id: true,
|
||||
phone: true,
|
||||
name: true,
|
||||
wxOpenId: true,
|
||||
wxUnionId: true,
|
||||
lastLoginAt: true,
|
||||
status: true,
|
||||
isPrimary: true,
|
||||
parentAccountId: true,
|
||||
companyName: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const a of accounts) {
|
||||
if (!a.wxOpenId) continue;
|
||||
const refId = a.isPrimary === 1 ? a.id : (a.parentAccountId ?? a.id);
|
||||
rows.push({
|
||||
actorType: 'PARTNER',
|
||||
actorId: a.id,
|
||||
@@ -225,8 +237,8 @@ export class AdminWechatBindingsService {
|
||||
name: a.name,
|
||||
wxOpenId: a.wxOpenId,
|
||||
wxUnionId: a.wxUnionId,
|
||||
refId: a.partnerId,
|
||||
refLabel: a.partner.companyName,
|
||||
refId,
|
||||
refLabel: a.companyName,
|
||||
lastLoginAt: a.lastLoginAt,
|
||||
status: a.status,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
Max,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
@@ -22,7 +23,7 @@ export class UpdateStoreStatusDto {
|
||||
export class CreateStoreDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
partnerId: string;
|
||||
partnerAccountId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@@ -88,6 +89,11 @@ export class CreateStoreDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contractUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
settlementRate?: number;
|
||||
}
|
||||
|
||||
export class UpdateStoreDto {
|
||||
@@ -114,6 +120,11 @@ export class UpdateStoreDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
district?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
settlementRate?: number;
|
||||
}
|
||||
|
||||
export class CreateStoreAccountDto {
|
||||
@@ -145,6 +156,18 @@ export class UpdateStoreAccountDto {
|
||||
}
|
||||
|
||||
export class CreatePartnerDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
cityId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
phone: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
companyName: string;
|
||||
@@ -153,9 +176,40 @@ export class CreatePartnerDto {
|
||||
@IsNotEmpty()
|
||||
address: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
contactPhone: string;
|
||||
contactPhone?: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['CITY_WIDE', 'DISTRICT'])
|
||||
scopeType: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
districtCodes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
orderCommissionRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
redeemCommissionRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'PAUSED'])
|
||||
bindingStatus?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
managedWarehouseId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contractNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -168,9 +222,21 @@ export class CreatePartnerDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bankBranch?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
weeklyStoreTarget?: number;
|
||||
}
|
||||
|
||||
export class UpdatePartnerDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
companyName?: string;
|
||||
@@ -183,6 +249,38 @@ export class UpdatePartnerDto {
|
||||
@IsString()
|
||||
contactPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['CITY_WIDE', 'DISTRICT'])
|
||||
scopeType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
districtCodes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
orderCommissionRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
redeemCommissionRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'PAUSED'])
|
||||
bindingStatus?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
managedWarehouseId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contractNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bankAccountName?: string;
|
||||
@@ -194,6 +292,14 @@ export class UpdatePartnerDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
bankBranch?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
weeklyStoreTarget?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class UpdatePartnerAccountDto {
|
||||
@@ -208,13 +314,21 @@ export class UpdatePartnerAccountDto {
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'DISABLED'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['PARTNER', 'INTERNAL', 'PROMOTER'])
|
||||
staffRole?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export class CreatePartnerAccountDto {
|
||||
@ValidateIf((o: CreatePartnerAccountDto) => !o.parentAccountId)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
partnerId?: string;
|
||||
parentAccountId: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@@ -228,10 +342,10 @@ export class CreatePartnerAccountDto {
|
||||
@IsIn(['PARTNER', 'INTERNAL', 'PROMOTER'])
|
||||
staffRole?: string;
|
||||
|
||||
/** 主账号 ID;传入则创建子账号 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentAccountId?: string;
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
permissions?: string[];
|
||||
}
|
||||
|
||||
export class CreateCityDto {
|
||||
@@ -247,10 +361,6 @@ export class CreateCityDto {
|
||||
@IsNotEmpty()
|
||||
province: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['PENDING', 'ACTIVE', 'PAUSED'])
|
||||
status?: string;
|
||||
@@ -265,10 +375,6 @@ export class UpdateCityDto {
|
||||
@IsString()
|
||||
province?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['PENDING', 'ACTIVE', 'PAUSED'])
|
||||
status?: string;
|
||||
@@ -278,6 +384,127 @@ export class UpdateCityDto {
|
||||
|
||||
@IsOptional()
|
||||
crossMinQty?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
maxPartnerCommissionRate?: number;
|
||||
}
|
||||
|
||||
export class BindCityPartnerDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
partnerId: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['CITY_WIDE', 'DISTRICT'])
|
||||
scopeType: 'CITY_WIDE' | 'DISTRICT';
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
districtCodes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
orderCommissionRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
redeemCommissionRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'PAUSED'])
|
||||
status?: 'ACTIVE' | 'PAUSED';
|
||||
}
|
||||
|
||||
export class UpdateCityPartnerDto {
|
||||
@IsOptional()
|
||||
@IsIn(['CITY_WIDE', 'DISTRICT'])
|
||||
scopeType?: 'CITY_WIDE' | 'DISTRICT';
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
districtCodes?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
orderCommissionRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
redeemCommissionRate?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'PAUSED'])
|
||||
status?: 'ACTIVE' | 'PAUSED';
|
||||
}
|
||||
|
||||
export class CreateCityWarehouseDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
address: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
contactName: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
contactPhone: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['HQ', 'PARTNER'])
|
||||
managerType: 'HQ' | 'PARTNER';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerAccountId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'PAUSED'])
|
||||
status?: 'ACTIVE' | 'PAUSED';
|
||||
}
|
||||
|
||||
export class UpdateCityWarehouseDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
address?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contactName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contactPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['HQ', 'PARTNER'])
|
||||
managerType?: 'HQ' | 'PARTNER';
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== null)
|
||||
@IsString()
|
||||
partnerAccountId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['ACTIVE', 'PAUSED'])
|
||||
status?: 'ACTIVE' | 'PAUSED';
|
||||
}
|
||||
|
||||
export class CreateStoreMediaDto {
|
||||
|
||||
@@ -109,6 +109,36 @@ export class AdminPartnersQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
contactPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cityId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
partnerId?: string;
|
||||
}
|
||||
|
||||
export class AdminCityWarehousesQueryDto extends PaginationQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cityId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
managerType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export class AdminPartnerAccountsQueryDto extends PaginationQueryDto {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { TradeModule } from '../trade/trade.module';
|
||||
import { AdminDashboardController } from './admin-dashboard.controller';
|
||||
@@ -12,6 +13,7 @@ import { AdminStoresService } from './admin-stores.service';
|
||||
import { AdminPartnersController, AdminPartnerAccountsController } from './admin-partners.controller';
|
||||
import { AdminPartnersService } from './admin-partners.service';
|
||||
import { AdminCitiesController } from './admin-cities.controller';
|
||||
import { AdminCityWarehousesController, AdminCityWarehouseMutationsController } from './admin-city-warehouses.controller';
|
||||
import { AdminCitiesService } from './admin-cities.service';
|
||||
import { AdminBenefitCouponsController, AdminBenefitLedgersController } from './admin-benefit.controller';
|
||||
import { AdminBenefitService } from './admin-benefit.service';
|
||||
@@ -50,7 +52,7 @@ import { AdminHqPermissionsController } from './admin-hq-permissions.controller'
|
||||
import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
||||
imports: [CityScopeModule, IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule, RedeemModule],
|
||||
controllers: [
|
||||
AdminDashboardController,
|
||||
AdminUsersController,
|
||||
@@ -61,6 +63,8 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
||||
AdminPartnersController,
|
||||
AdminPartnerAccountsController,
|
||||
AdminCitiesController,
|
||||
AdminCityWarehousesController,
|
||||
AdminCityWarehouseMutationsController,
|
||||
AdminBenefitCouponsController,
|
||||
AdminBenefitLedgersController,
|
||||
AdminRedeemRecordsController,
|
||||
@@ -104,5 +108,6 @@ import { AdminHqPermissionsService } from './admin-hq-permissions.service';
|
||||
AdminHqPermissionsService,
|
||||
SuperAdminGuard,
|
||||
],
|
||||
exports: [CityScopeModule],
|
||||
})
|
||||
export class OpsModule {}
|
||||
|
||||
@@ -236,10 +236,7 @@ export class RedeemService {
|
||||
}
|
||||
|
||||
const amount = tokenAmount;
|
||||
const cityRule = await this.prisma.commonCityCommissionRule.findUnique({
|
||||
where: { cityId: account.store.cityId },
|
||||
});
|
||||
const settlementRate = cityRule ? Number(cityRule.storeSettlementRate) : 0.6;
|
||||
const settlementRate = Number(account.store.settlementRate);
|
||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||
|
||||
let record;
|
||||
|
||||
@@ -152,15 +152,22 @@ export class PartnerMeController {
|
||||
async me(@CurrentUser() user: AuthUser) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: user.actorId },
|
||||
include: { partner: true },
|
||||
});
|
||||
let primary = account;
|
||||
if (account.isPrimary !== 1 && account.parentAccountId) {
|
||||
primary = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: account.parentAccountId },
|
||||
});
|
||||
}
|
||||
return {
|
||||
id: account.id.toString(),
|
||||
name: account.name,
|
||||
phone: account.phone,
|
||||
isPrimary: account.isPrimary === 1,
|
||||
staffRole: account.staffRole ?? undefined,
|
||||
companyName: account.partner.companyName,
|
||||
permissions: Array.isArray(account.permissions) ? account.permissions : undefined,
|
||||
primaryAccountId: primary.id.toString(),
|
||||
companyName: primary.companyName ?? undefined,
|
||||
hasWechat: !!account.wxOpenId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { SettlementService } from './settlement.service';
|
||||
import {
|
||||
AdminPartnerBillController,
|
||||
@@ -11,7 +12,7 @@ import {
|
||||
} from './settlement.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, AnalyticsModule],
|
||||
imports: [IamModule, AnalyticsModule, CityScopeModule],
|
||||
controllers: [
|
||||
SettlementController,
|
||||
PartnerMeController,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
|
||||
function generateBillNo() {
|
||||
return `PB${Date.now()}${Math.floor(Math.random() * 900 + 100)}`;
|
||||
@@ -13,6 +14,7 @@ export class SettlementService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
) {}
|
||||
|
||||
async createStorePayout(
|
||||
@@ -152,16 +154,13 @@ export class SettlementService {
|
||||
return results;
|
||||
}
|
||||
|
||||
async listPartnerBills(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
async listPartnerBills(partnerAccountId: bigint) { const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const bills = await this.prisma.partnerBill.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
where: { partnerAccountId: primary.id },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerId: account.partnerId,
|
||||
partnerAccountId: primary.id,
|
||||
eventName: 'partner_bill_view',
|
||||
extraJson: { count: bills.length },
|
||||
});
|
||||
@@ -178,7 +177,7 @@ export class SettlementService {
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.PartnerBillWhereInput = {};
|
||||
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
||||
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
|
||||
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.partnerBill.findMany({
|
||||
@@ -186,7 +185,7 @@ export class SettlementService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
include: { partnerAccount: { select: { companyName: true } } },
|
||||
}),
|
||||
this.prisma.partnerBill.count({ where }),
|
||||
]);
|
||||
@@ -196,20 +195,21 @@ export class SettlementService {
|
||||
async getAdminPartnerBill(id: bigint) {
|
||||
const bill = await this.prisma.partnerBill.findUnique({
|
||||
where: { id },
|
||||
include: { partner: true },
|
||||
include: { partnerAccount: true },
|
||||
});
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
return serializeBigInt(bill);
|
||||
}
|
||||
|
||||
async generatePartnerBill(body: { partnerId: string; year: number; month: number }) {
|
||||
const partnerId = BigInt(body.partnerId);
|
||||
const partnerAccountId = BigInt(body.partnerId);
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const periodStart = new Date(body.year, body.month - 1, 1);
|
||||
const periodEnd = new Date(body.year, body.month, 0, 23, 59, 59, 999);
|
||||
|
||||
const existing = await this.prisma.partnerBill.findFirst({
|
||||
where: {
|
||||
partnerId,
|
||||
partnerAccountId: primary.id,
|
||||
periodStart,
|
||||
status: { not: 'DRAFT' },
|
||||
},
|
||||
@@ -218,31 +218,33 @@ export class SettlementService {
|
||||
throw new BadRequestException('该月账单已确认,不可重复生成');
|
||||
}
|
||||
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId },
|
||||
include: { commissionRule: true },
|
||||
});
|
||||
const cityIds = cities.map((c) => c.id);
|
||||
const defaultOrderRate = cities[0]?.commissionRule?.orderCommissionRate
|
||||
? Number(cities[0].commissionRule.orderCommissionRate)
|
||||
: 0;
|
||||
const defaultRedeemRate = cities[0]?.commissionRule?.redeemCommissionRate
|
||||
? Number(cities[0].commissionRule.redeemCommissionRate)
|
||||
: 0.03;
|
||||
if (!primary.cityId) {
|
||||
throw new BadRequestException('合伙人未绑定开城城市');
|
||||
}
|
||||
|
||||
const orderCommissionRate = Number(primary.orderCommissionRate ?? 0);
|
||||
const redeemCommissionRate = Number(primary.redeemCommissionRate ?? 0.03);
|
||||
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: {
|
||||
cityId: { in: cityIds },
|
||||
cityId: primary.cityId,
|
||||
payStatus: 'PAID',
|
||||
paidAt: { gte: periodStart, lte: periodEnd },
|
||||
},
|
||||
});
|
||||
const orderCommission = orders.reduce(
|
||||
(sum, o) => sum + Number(o.payAmount) * defaultOrderRate,
|
||||
0,
|
||||
);
|
||||
const orderCommission = orders.reduce((sum, o) => {
|
||||
if (o.partnerAccountIdAtPay) {
|
||||
if (o.partnerAccountIdAtPay !== primary.id) return sum;
|
||||
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
|
||||
return sum + Number(o.payAmount) * rate;
|
||||
}
|
||||
return sum + Number(o.payAmount) * orderCommissionRate;
|
||||
}, 0);
|
||||
|
||||
const stores = await this.prisma.store.findMany({ where: { partnerId }, select: { id: true } });
|
||||
const stores = await this.prisma.store.findMany({
|
||||
where: { partnerAccountId: primary.id },
|
||||
select: { id: true },
|
||||
});
|
||||
const storeIds = stores.map((s) => s.id);
|
||||
const redeems = await this.prisma.redeemRecord.findMany({
|
||||
where: {
|
||||
@@ -251,14 +253,14 @@ export class SettlementService {
|
||||
},
|
||||
});
|
||||
const redeemCommission = redeems.reduce(
|
||||
(sum, r) => sum + Number(r.amount) * defaultRedeemRate,
|
||||
(sum, r) => sum + Number(r.amount) * redeemCommissionRate,
|
||||
0,
|
||||
);
|
||||
|
||||
const totalAmount = Math.round((orderCommission + redeemCommission) * 100) / 100;
|
||||
|
||||
const draft = await this.prisma.partnerBill.findFirst({
|
||||
where: { partnerId, periodStart, status: 'DRAFT' },
|
||||
where: { partnerAccountId: primary.id, periodStart, status: 'DRAFT' },
|
||||
});
|
||||
|
||||
const bill = draft
|
||||
@@ -269,7 +271,7 @@ export class SettlementService {
|
||||
: await this.prisma.partnerBill.create({
|
||||
data: {
|
||||
billNo: generateBillNo(),
|
||||
partnerId,
|
||||
partnerAccountId: primary.id,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
orderCommission,
|
||||
@@ -310,12 +312,12 @@ export class SettlementService {
|
||||
|
||||
async exportPartnerBills(query: { partnerId?: string; status?: string }) {
|
||||
const where: Prisma.PartnerBillWhereInput = {};
|
||||
if (query.partnerId) where.partnerId = BigInt(query.partnerId);
|
||||
if (query.partnerId) where.partnerAccountId = BigInt(query.partnerId);
|
||||
if (query.status) where.status = query.status as Prisma.EnumPartnerBillStatusFilter['equals'];
|
||||
|
||||
const bills = await this.prisma.partnerBill.findMany({
|
||||
where,
|
||||
include: { partner: { select: { companyName: true } } },
|
||||
include: { partnerAccount: { select: { companyName: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
@@ -323,7 +325,7 @@ export class SettlementService {
|
||||
const rows = bills.map((b) =>
|
||||
[
|
||||
b.billNo,
|
||||
b.partner.companyName,
|
||||
b.partnerAccount.companyName,
|
||||
b.periodStart.toISOString().slice(0, 10),
|
||||
b.periodEnd.toISOString().slice(0, 10),
|
||||
Number(b.orderCommission),
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { IamModule } from '../iam/iam.module';
|
||||
import { RedeemModule } from '../redeem/redeem.module';
|
||||
import { AnalyticsModule } from '../analytics/analytics.module';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { StoreService } from './store.service';
|
||||
import {
|
||||
PartnerDashboardController,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
} from './store.controller';
|
||||
|
||||
@Module({
|
||||
imports: [IamModule, AnalyticsModule, forwardRef(() => RedeemModule)],
|
||||
imports: [IamModule, AnalyticsModule, CityScopeModule, forwardRef(() => RedeemModule)],
|
||||
controllers: [
|
||||
PublicStoreController,
|
||||
PartnerStoreController,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||
import { parseBigIntParam } from '../../common/parse-bigint';
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
|
||||
@Injectable()
|
||||
export class StoreService {
|
||||
@@ -19,6 +20,7 @@ export class StoreService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
) {}
|
||||
|
||||
async listOpenStores(cityCode?: string) {
|
||||
@@ -48,9 +50,15 @@ export class StoreService {
|
||||
return serializeBigInt(mapStoreCompat({ ...store, media }));
|
||||
}
|
||||
|
||||
private async resolvePartnerScope(actorAccountId: bigint) {
|
||||
const account = await this.getPartnerAccount(actorAccountId);
|
||||
const primaryId = await this.getPartnerPrimaryId(actorAccountId);
|
||||
return { account, primaryId };
|
||||
}
|
||||
|
||||
async partnerListStores(partnerAccountId: bigint) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const where: { partnerId: bigint; id?: { in: bigint[] } } = { partnerId: account.partnerId };
|
||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||
const where: { partnerAccountId: bigint; id?: { in: bigint[] } } = { partnerAccountId: primaryId };
|
||||
if (this.isSubAccount(account)) {
|
||||
const storeIds = await this.getStoreIdsCreatedByAccount(partnerAccountId);
|
||||
if (storeIds.length === 0) return [];
|
||||
@@ -65,9 +73,9 @@ export class StoreService {
|
||||
}
|
||||
|
||||
async partnerGetStore(partnerAccountId: bigint, storeId: bigint) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id: storeId, partnerId: account.partnerId },
|
||||
where: { id: storeId, partnerAccountId: primaryId },
|
||||
include: { category: true, coverResource: true },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
@@ -88,10 +96,11 @@ export class StoreService {
|
||||
}
|
||||
|
||||
async partnerListCities(partnerAccountId: bigint) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||
const cityWhere = await this.partnerCityService.buildPartnerCityWhere(primaryId);
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
select: { id: true, name: true, code: true, province: true, partnerId: true },
|
||||
where: cityWhere,
|
||||
select: { id: true, name: true, code: true, province: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return serializeBigInt(cities);
|
||||
@@ -115,11 +124,11 @@ export class StoreService {
|
||||
}
|
||||
|
||||
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||
const normalizedPhone = String(body.phone).trim();
|
||||
await this.assertStorePhoneAvailable(normalizedPhone);
|
||||
|
||||
const city = await this.resolvePartnerCity(account.partnerId, body.cityId);
|
||||
const city = await this.resolvePartnerCity(partnerAccountId, body.cityId);
|
||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||
const envPhotoUrls = Array.isArray(body.envPhotoUrls)
|
||||
? body.envPhotoUrls.map((u) => String(u).trim()).filter(Boolean)
|
||||
@@ -133,7 +142,7 @@ export class StoreService {
|
||||
const store = await this.prisma.store.create({
|
||||
data: {
|
||||
cityId: city.id,
|
||||
partnerId: account.partnerId,
|
||||
partnerAccountId: primaryId,
|
||||
categoryId: body.categoryId ? parseBigIntParam(body.categoryId, '分类ID') : null,
|
||||
name: String(body.name),
|
||||
phone: normalizedPhone,
|
||||
@@ -220,7 +229,7 @@ export class StoreService {
|
||||
});
|
||||
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerId: account.partnerId,
|
||||
partnerAccountId: primaryId,
|
||||
eventName: 'partner_store_create',
|
||||
refType: 'STORE',
|
||||
refId: store.id,
|
||||
@@ -240,10 +249,10 @@ export class StoreService {
|
||||
storeId: bigint,
|
||||
status: 'OPEN' | 'PAUSED' | 'CLOSED',
|
||||
) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||
this.assertPrimaryAccount(account);
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id: storeId, partnerId: account.partnerId },
|
||||
where: { id: storeId, partnerAccountId: primaryId },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
if (store.status === 'CLOSED') {
|
||||
@@ -259,7 +268,7 @@ export class StoreService {
|
||||
include: { coverResource: true },
|
||||
});
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerId: account.partnerId,
|
||||
partnerAccountId: primaryId,
|
||||
eventName: 'partner_store_status_change',
|
||||
refType: 'STORE',
|
||||
refId: storeId,
|
||||
@@ -276,10 +285,10 @@ export class StoreService {
|
||||
storeId: bigint,
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||
this.assertPrimaryAccount(account);
|
||||
const store = await this.prisma.store.findFirst({
|
||||
where: { id: storeId, partnerId: account.partnerId },
|
||||
where: { id: storeId, partnerAccountId: primaryId },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
if (store.status === 'CLOSED') {
|
||||
@@ -343,20 +352,20 @@ export class StoreService {
|
||||
}
|
||||
|
||||
async partnerDashboard(partnerAccountId: bigint) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
const { account, primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||
this.assertPrimaryAccount(account);
|
||||
const partnerStoreIds = await this.prisma.store.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
where: { partnerAccountId: primaryId },
|
||||
select: { id: true },
|
||||
});
|
||||
const storeIds = partnerStoreIds.map((s) => s.id);
|
||||
const [storeCount, orderCount, recentStores, pendingAuditCount] = await Promise.all([
|
||||
this.prisma.store.count({ where: { partnerId: account.partnerId } }),
|
||||
this.prisma.store.count({ where: { partnerAccountId: primaryId } }),
|
||||
this.prisma.order.count({
|
||||
where: { city: { partnerId: account.partnerId } },
|
||||
where: await this.partnerCityService.buildPartnerOrderWhere(primaryId),
|
||||
}),
|
||||
this.prisma.store.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
where: { partnerAccountId: primaryId },
|
||||
select: { id: true, name: true, status: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
@@ -375,18 +384,17 @@ export class StoreService {
|
||||
return {
|
||||
storeCount,
|
||||
orderCount,
|
||||
companyName: account.partner.companyName,
|
||||
companyName: account.companyName ?? '',
|
||||
recentStores: serializeBigInt(recentStores),
|
||||
pendingAuditCount,
|
||||
};
|
||||
}
|
||||
|
||||
async partnerLeaderboard(partnerAccountId: bigint, period: PartnerLeaderboardPeriod = 'total') {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
this.assertPrimaryAccount(account);
|
||||
const { primaryId } = await this.resolvePartnerScope(partnerAccountId);
|
||||
|
||||
const accounts = await this.prisma.partnerAccount.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
where: { OR: [{ id: primaryId }, { parentAccountId: primaryId }] },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
|
||||
@@ -457,10 +465,9 @@ export class StoreService {
|
||||
return { period, list, self };
|
||||
}
|
||||
|
||||
async partnerWeeklyReport(partnerAccountId: bigint, startDate?: string) {
|
||||
const account = await this.getPartnerAccount(partnerAccountId);
|
||||
async partnerWeeklyReport(actorAccountId: bigint, startDate?: string) {
|
||||
const { account, primaryId } = await this.resolvePartnerScope(actorAccountId);
|
||||
this.assertPrimaryAccount(account);
|
||||
const partnerId = account.partnerId;
|
||||
|
||||
const currentWeekStart = this.startOfWeekMonday(new Date());
|
||||
const periodStart =
|
||||
@@ -472,16 +479,16 @@ export class StoreService {
|
||||
const prevPeriodEnd = periodStart;
|
||||
|
||||
const newStoreTarget =
|
||||
account.partner.weeklyStoreTarget ??
|
||||
account.weeklyStoreTarget ??
|
||||
Number(process.env.PARTNER_WEEKLY_STORE_TARGET ?? 20);
|
||||
|
||||
const orderWhere = {
|
||||
city: { partnerId },
|
||||
...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)),
|
||||
payStatus: 'PAID' as const,
|
||||
paidAt: { gte: periodStart, lt: periodEnd },
|
||||
};
|
||||
const prevOrderWhere = {
|
||||
city: { partnerId },
|
||||
...(await this.partnerCityService.buildPartnerOrderWhere(primaryId)),
|
||||
payStatus: 'PAID' as const,
|
||||
paidAt: { gte: prevPeriodStart, lt: prevPeriodEnd },
|
||||
};
|
||||
@@ -498,16 +505,16 @@ export class StoreService {
|
||||
] = await Promise.all([
|
||||
this.prisma.order.aggregate({ where: orderWhere, _sum: { payAmount: true } }),
|
||||
this.prisma.order.count({ where: orderWhere }),
|
||||
this.prisma.store.count({ where: { partnerId } }),
|
||||
this.prisma.store.count({ where: { partnerAccountId: primaryId } }),
|
||||
this.prisma.store.count({
|
||||
where: { partnerId, createdAt: { gte: periodStart, lt: periodEnd } },
|
||||
where: { partnerAccountId: primaryId, createdAt: { gte: periodStart, lt: periodEnd } },
|
||||
}),
|
||||
this.prisma.order.aggregate({ where: prevOrderWhere, _sum: { payAmount: true } }),
|
||||
this.prisma.redeemRecord.groupBy({
|
||||
by: ['storeId'],
|
||||
where: {
|
||||
createdAt: { gte: periodStart, lt: periodEnd },
|
||||
store: { partnerId },
|
||||
store: { partnerAccountId: primaryId },
|
||||
},
|
||||
_sum: { amount: true },
|
||||
orderBy: { _sum: { amount: 'desc' } },
|
||||
@@ -516,7 +523,7 @@ export class StoreService {
|
||||
this.prisma.redeemRecord.findMany({
|
||||
where: {
|
||||
createdAt: { gte: periodStart, lt: periodEnd },
|
||||
store: { partnerId },
|
||||
store: { partnerAccountId: primaryId },
|
||||
},
|
||||
select: { storeId: true },
|
||||
distinct: ['storeId'],
|
||||
@@ -677,22 +684,29 @@ export class StoreService {
|
||||
return ['周一', '周二', '周三', '周四', '周五', '周六', '周日'][index] ?? '';
|
||||
}
|
||||
|
||||
private async getPartnerPrimaryId(actorAccountId: bigint) {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(actorAccountId);
|
||||
return primary.id;
|
||||
}
|
||||
|
||||
private async getPartnerAccount(partnerAccountId: bigint) {
|
||||
return this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
include: { partner: true },
|
||||
});
|
||||
}
|
||||
|
||||
private async resolvePartnerCity(partnerId: bigint, cityId: unknown) {
|
||||
private async resolvePartnerCity(actorAccountId: bigint, cityId: unknown) {
|
||||
const primaryId = await this.getPartnerPrimaryId(actorAccountId);
|
||||
if (cityId) {
|
||||
const city = await this.prisma.commonCity.findFirst({
|
||||
where: { id: parseBigIntParam(cityId, '城市ID'), partnerId },
|
||||
});
|
||||
const id = parseBigIntParam(cityId, '城市ID');
|
||||
await this.partnerCityService.assertPartnerAccountBoundToCity(primaryId, id);
|
||||
const city = await this.prisma.commonCity.findUnique({ where: { id } });
|
||||
if (!city) throw new BadRequestException('所选地区未匹配到开城城市');
|
||||
return city;
|
||||
}
|
||||
const city = await this.prisma.commonCity.findFirst({ where: { partnerId } });
|
||||
const cityIds = await this.partnerCityService.listCityIdsForPartnerAccount(primaryId);
|
||||
if (!cityIds.length) throw new BadRequestException('合伙人未绑定开城');
|
||||
const city = await this.prisma.commonCity.findFirst({ where: { id: { in: cityIds } } });
|
||||
if (!city) throw new BadRequestException('合伙人未绑定开城');
|
||||
return city;
|
||||
}
|
||||
|
||||
@@ -5,11 +5,12 @@ import { IamModule } from '../iam/iam.module';
|
||||
import { BenefitModule } from '../benefit/benefit.module';
|
||||
import { CatalogModule } from '../catalog/catalog.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||
import { TradeController, PartnerOrderController, PartnerReshipmentController } from './trade.controller';
|
||||
import { TradeService } from './trade.service';
|
||||
|
||||
@Module({
|
||||
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, forwardRef(() => BenefitModule), CommonModule],
|
||||
imports: [IntegrationsModule, IamModule, CatalogModule, AnalyticsModule, CityScopeModule, forwardRef(() => BenefitModule), CommonModule],
|
||||
controllers: [TradeController, PartnerOrderController, PartnerReshipmentController],
|
||||
providers: [TradeService],
|
||||
exports: [TradeService],
|
||||
|
||||
@@ -17,6 +17,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
||||
import { AnalyticsService } from '../analytics/analytics.service';
|
||||
import { CatalogService } from '../catalog/catalog.service';
|
||||
import { BenefitService } from '../benefit/benefit.service';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
import { TicketService } from '../common/ticket.service';
|
||||
import { PAY_PROVIDER, DELIVERY_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import { IPayProvider } from '../../integrations/pay/pay.interface';
|
||||
@@ -39,6 +40,7 @@ export class TradeService {
|
||||
@Inject(PAY_PROVIDER) private readonly payProvider: IPayProvider,
|
||||
@Inject(DELIVERY_PROVIDER) private readonly deliveryProvider: IDeliveryProvider,
|
||||
private readonly analyticsService: AnalyticsService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
) {}
|
||||
|
||||
async preview(userId: bigint, body: { productId: string; quantity: number; addressId?: string }) {
|
||||
@@ -220,6 +222,10 @@ export class TradeService {
|
||||
|
||||
const { externalNo } = payResult;
|
||||
const now = new Date();
|
||||
const paySnapshot = await this.partnerCityService.resolveForOrder(
|
||||
order.cityId,
|
||||
order.receiverDistrict,
|
||||
);
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.order.update({
|
||||
@@ -229,6 +235,8 @@ export class TradeService {
|
||||
payStatus: 'PAID',
|
||||
paidAt: now,
|
||||
payExternalNo: externalNo,
|
||||
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
|
||||
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
|
||||
},
|
||||
});
|
||||
await tx.logThirdParty.create({
|
||||
@@ -300,6 +308,10 @@ export class TradeService {
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const paySnapshot = await this.partnerCityService.resolveForOrder(
|
||||
order.cityId,
|
||||
order.receiverDistrict,
|
||||
);
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.order.findUnique({ where: { id: order.id } });
|
||||
if (!current || current.payStatus === 'PAID') return;
|
||||
@@ -311,6 +323,8 @@ export class TradeService {
|
||||
payStatus: 'PAID',
|
||||
paidAt: now,
|
||||
payExternalNo: params.transactionId,
|
||||
partnerAccountIdAtPay: paySnapshot?.partnerAccountId ?? null,
|
||||
orderCommissionRateAtPay: paySnapshot?.orderCommissionRate ?? null,
|
||||
},
|
||||
});
|
||||
await tx.logThirdParty.create({
|
||||
@@ -450,15 +464,10 @@ export class TradeService {
|
||||
}
|
||||
|
||||
async listPartnerReshipments(partnerAccountId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.commonCity.findMany({
|
||||
where: { partnerId: account.partnerId },
|
||||
select: { id: true },
|
||||
});
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const orderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { cityId: { in: cities.map((c) => c.id) } },
|
||||
where: orderWhere,
|
||||
select: { id: true },
|
||||
});
|
||||
const tickets = await this.prisma.commonTicket.findMany({
|
||||
@@ -473,12 +482,8 @@ export class TradeService {
|
||||
}
|
||||
|
||||
async listPartnerOrders(partnerAccountId: bigint, page = 1, pageSize = 20) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.commonCity.findMany({ where: { partnerId: account.partnerId } });
|
||||
const cityIds = cities.map((c) => c.id);
|
||||
const where = { cityId: { in: cityIds } };
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const where = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
@@ -493,12 +498,10 @@ export class TradeService {
|
||||
}
|
||||
|
||||
async getPartnerOrder(partnerAccountId: bigint, orderId: bigint) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const cities = await this.prisma.commonCity.findMany({ where: { partnerId: account.partnerId } });
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: { id: orderId, cityId: { in: cities.map((c) => c.id) } },
|
||||
where: { id: orderId, ...partnerOrderWhere },
|
||||
include: { delivery: true, user: true, imageResource: true },
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
@@ -510,13 +513,12 @@ export class TradeService {
|
||||
}
|
||||
|
||||
async advanceDelivery(partnerAccountId: bigint, orderId: bigint, targetStatus: string) {
|
||||
const account = await this.prisma.partnerAccount.findUniqueOrThrow({
|
||||
where: { id: partnerAccountId },
|
||||
});
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const partnerOrderWhere = await this.partnerCityService.buildPartnerOrderWhere(primary.id);
|
||||
const order = await this.prisma.order.findFirst({
|
||||
where: {
|
||||
id: orderId,
|
||||
city: { partnerId: account.partnerId },
|
||||
...partnerOrderWhere,
|
||||
},
|
||||
include: { delivery: true },
|
||||
});
|
||||
@@ -524,7 +526,7 @@ export class TradeService {
|
||||
await this.applyStatusTransition(order.id, order.status, targetStatus);
|
||||
if (targetStatus === 'SHIPPING') {
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerId: account.partnerId,
|
||||
partnerAccountId: primary.id,
|
||||
eventName: 'partner_order_ship',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
@@ -532,7 +534,7 @@ export class TradeService {
|
||||
});
|
||||
}
|
||||
this.analyticsService.trackPartnerOneSafe(partnerAccountId, 'PARTNER_H5', {
|
||||
partnerId: account.partnerId,
|
||||
partnerAccountId: primary.id,
|
||||
eventName: 'partner_delivery_advance',
|
||||
refType: 'ORDER',
|
||||
refId: orderId,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# 杜康好客 · V3 城市 / 仓库 / 日志架构
|
||||
|
||||
> **版本**:2026-07-12
|
||||
> **状态**:P1 城市多合伙 + P2 仓库 **已实现**(2026-07-12);合伙人端管仓日志仍待 Wave 3
|
||||
> **分工**:刘景尧任务暂由 `jacy-dukang` 代管(见 `AGENTS.md`)
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标数据模型(城市顶层)
|
||||
|
||||
```
|
||||
CommonCity (开城)
|
||||
├── PartnerAccount[] 1:N 主账号(isPrimary=1,含城市绑定 + 购酒/核销佣金)
|
||||
├── CityWarehouse[] 1:N 城市仓库(HQ 管 / 合伙人管)
|
||||
└── CatalogProduct[] 按城市上架
|
||||
|
||||
PartnerAccount (主账号 = 城市合伙人主体)
|
||||
├── cityId / scopeType / districtCodes / bindingStatus
|
||||
├── orderCommissionRate / redeemCommissionRate
|
||||
├── companyName / 银行 / 合同等主体信息
|
||||
├── managedWarehouseId? 可选管仓
|
||||
└── PartnerAccount[] 子账号(parentAccountId,permissions 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
@@ -74,7 +74,7 @@
|
||||
| C10 | 门店主动提现 | 自动 StorePayout | 📋 P2 | 待提现申请流 |
|
||||
| C11 | 工单四类型 | 3 enum | 📋 P3 | 待扩展 `TicketType` |
|
||||
| C12 | 现场提货 | 无 DeliveryType | 📋 P2 | 待 `ON_SITE_PICKUP` |
|
||||
| C13 | 多合伙人 | 单 partnerId | 📋 P2~3 | 待管辖/快照模型 |
|
||||
| C13 | 多合伙人 | 单 partnerId | ✅ 已修复 | 主账号 `partner_account` 合并城市绑定;`partner_partner`/`city_partner` 已删 |
|
||||
| C14 | 文档残留旧口径 | 5min/5 Tab | ✅ 已修复 | `apps/AGENTS.md`、rules、agents、skills |
|
||||
|
||||
### 1.3 P0 已改文件清单
|
||||
@@ -196,8 +196,8 @@
|
||||
| **REQ-H-014a** 提现白名单配置 | 无 |
|
||||
| **REQ-S-017~019** 门店子账号 + 多店选店 | 无店员角色、无选店列表 |
|
||||
| **REQ-P-008** 一号多店确认弹窗 | 当前直接拒绝(见 C9) |
|
||||
| **REQ-H-004a / REQ-H-007** 多合伙人管辖与佣金 | 无全城/区域、区县互斥、佣金快照 |
|
||||
| **ACC-P21~P28** 多合伙人全套 | 数据模型与 UI 均未做 |
|
||||
| **REQ-H-004a / REQ-H-007** 多合伙人管辖与佣金 | 主账号 `partner_account` + HQ 合伙人页;支付写 `partnerAccountIdAtPay` 快照 | 🔶 基础已做;ACC-P21~P28 全套验收待补 |
|
||||
| **ACC-P21~P28** 多合伙人全套 | 主账号模型 + HQ UI + 域规则单测 | 🔶 部分完成 |
|
||||
| **推广码完整归因(W2 门禁)** | 后端有部分 API;**admin-web 缺推广码管理页** |
|
||||
| **合伙人 T+30 独立确认打款** | 有 bill generate;合伙人确认/打款流不完整 |
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
|------------|------|
|
||||
| **REQ-P-021 / REQ-H-012** 代下单 | 无 |
|
||||
| **REQ-S-008a / REQ-S-020 / REQ-H-020a** 弱网 5 次拍照兜底 | 无待处理核销单模型与 UI |
|
||||
| **REQ-H-004b / REQ-P-026** 一城多仓 + 管仓合伙人 | schema 无 warehouse |
|
||||
| **REQ-H-004b / REQ-P-026** 一城多仓 + 管仓合伙人 | `city_warehouse` + admin 仓库 Tab 已做;工单协同待 Wave 3 |
|
||||
| **REQ-U-009 + SC-03** 跨城完整履约 | 后端有 `CROSS_CITY` 检测;总部物流 UX/佣金归总部未闭环 |
|
||||
| **REQ-U-021 OPT-001** 发票 | 无模块 |
|
||||
| **REQ-U-022** 四类型工单用户端 | 仅 refund-request |
|
||||
|
||||
+11
-4
@@ -24,8 +24,8 @@ V3 必须达到可业务验收状态:
|
||||
|
||||
| 负责人 | 主责端 | 主责后端/公共范围 | 说明 |
|
||||
|---|---|---|---|
|
||||
| `jacy-dukang` | `apps/h5-user`、`apps/admin-web` | `packages/*`、`iam`、`catalog`、`trade`、`benefit`、`settlement`、`ops`、`callbacks`、`jobs`、`integrations`、Prisma | Tech lead,负责架构、主交易链路、支付退款、后台运营、交付验收 |
|
||||
| `刘景尧` | `apps/h5-shop`、`apps/h5-partner` | `store`、`redeem`,并配合 `settlement`、配送/核销联调 | 负责门店、合伙人、录店、核销、门店体验与辖区履约 |
|
||||
| `jacy-dukang` | **全部四端**(含 `h5-shop`、`h5-partner`) | **全部模块** + `packages/*`、Prisma | Tech lead;**2026-07 起暂代刘景尧 B+D 职责** |
|
||||
| `刘景尧` | ~~`apps/h5-shop`、`apps/h5-partner`~~ | ~~`store`、`redeem`~~ | **暂停分工**,恢复前由 jacy 代管 |
|
||||
|
||||
### 2.1 四端交付形态(工程口径)
|
||||
|
||||
@@ -47,9 +47,16 @@ V3 必须达到可业务验收状态:
|
||||
- `apps/*` 只走 HTTP API 与 `packages/shared-types`,禁止 import `server/*` 或其他 app。
|
||||
- 后端跨模块只调用 exported Service,禁止为了赶进度直接写他人领域表。
|
||||
- 涉及 API、枚举、DTO、业务规则变更,必须同步 `packages/shared-types`、`packages/domain` 与本手册。
|
||||
- Prisma 迁移由 `jacy-dukang` 主导;涉及 `store` / `redeem` 表或核销流程时 `刘景尧` 必须 Review。
|
||||
- Prisma 迁移由 `jacy-dukang` 主导;涉及 `store` / `redeem` 表或核销流程时 `刘景尧` 必须 Review(**恢复分工前由 jacy 全权**)。
|
||||
|
||||
### 2.2 日志与审计(新业务)
|
||||
|
||||
城市 / 仓库 / 账号 / 子账号 CRUD 须落入对应日志表,规范见 [`杜康好客-v3-城市仓库与日志架构.md`](./杜康好客-v3-城市仓库与日志架构.md):
|
||||
|
||||
- **HQ 写操作** → `common_event(HQ_OPERATION)`,经 `@HqOperation` 装饰器
|
||||
- **合伙人端子账号** → `log_partner_analytics`(`partner_staff_*`)
|
||||
- **城市多合伙、仓库表** → schema 待建;action 常量已预留
|
||||
|
||||
---
|
||||
|
||||
## 3. V3 核销规则(已替代 V2 的 ¥500 上限)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user