From 7f031cc4c2ca1019bf4b57f5f9302fa633ea4316 Mon Sep 17 00:00:00 2001 From: jacy-dukang Date: Sun, 12 Jul 2026 10:19:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=9F=8E=E5=B8=82=E5=90=88=E4=BC=99=E4=BA=BA?= =?UTF-8?q?=E7=AB=AF=E7=9A=84=E4=BF=AE=E6=94=B9=EF=BC=88=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/dukang-core.mdc | 6 +- AGENTS.md | 14 +- README.md | 4 +- apps/admin-web/src/App.tsx | 10 +- .../components/ChinaProvinceCityCascader.tsx | 34 + .../src/components/CityPartnersPanel.tsx | 414 +++++++++++ .../src/components/PartnerSubAccountList.tsx | 92 +++ apps/admin-web/src/layouts/AdminLayout.tsx | 6 +- apps/admin-web/src/lib/china-region.ts | 71 +- apps/admin-web/src/lib/hq-log.ts | 7 + apps/admin-web/src/lib/storeCreate.ts | 9 +- apps/admin-web/src/pages/CitiesPage.tsx | 388 +++++++++-- apps/admin-web/src/pages/CityPartnersPage.tsx | 645 ++++++++++++++++++ .../src/pages/CityWarehousesPage.tsx | 406 +++++++++++ .../src/pages/PartnerAccountsPage.tsx | 120 ++-- apps/admin-web/src/pages/PartnersPage.tsx | 338 +++++++-- apps/admin-web/src/pages/StoresPage.tsx | 55 +- .../src/contexts/PartnerSessionContext.tsx | 2 + apps/h5-partner/src/lib/api.ts | 4 +- apps/h5-partner/src/lib/partnerAccess.ts | 11 +- deploy/docker-compose.yml | 13 +- packages/domain/src/city-partner.test.ts | 96 +++ packages/domain/src/city-partner.ts | 145 ++++ packages/domain/src/index.ts | 2 + packages/shared-types/src/catalog.ts | 2 + packages/shared-types/src/city-partner.ts | 91 +++ packages/shared-types/src/city-warehouse.ts | 36 + packages/shared-types/src/enums.ts | 40 ++ packages/shared-types/src/index.ts | 2 + packages/shared-types/src/partner-log.ts | 15 +- packages/shared-types/src/partner.ts | 5 + scripts/sms-test-helper.mjs | 2 +- server/dukang-api/.env.example | 4 +- server/dukang-api/package.json | 1 + server/dukang-api/prisma/clear-cities.ts | 29 + server/dukang-api/prisma/init_v3.sql | 155 +++-- .../dukang-api/prisma/migrate-city-partner.ts | 64 ++ .../prisma/migrate-partner-account-unify.ts | 32 + server/dukang-api/prisma/schema.prisma | 189 ++--- server/dukang-api/prisma/seed-v31.ts | 552 +++++++++++++-- server/dukang-api/src/app.module.ts | 2 + .../hq-operation/hq-operation.constants.ts | 14 + .../modules/analytics/analytics.service.ts | 19 +- .../src/modules/catalog/catalog.service.ts | 22 +- .../modules/city-scope/city-scope.module.ts | 9 + .../city-scope/city-warehouse.service.ts | 207 ++++++ .../city-scope/partner-city.service.ts | 164 +++++ .../src/modules/iam/auth.service.ts | 122 ++-- .../src/modules/iam/dto/partner-staff.dto.ts | 12 +- .../modules/iam/partner-staff.controller.ts | 6 +- .../src/modules/iam/partner-staff.service.ts | 296 ++++---- .../src/modules/ops/admin-cities.service.ts | 81 ++- .../ops/admin-city-warehouses.controller.ts | 83 +++ .../modules/ops/admin-dashboard.service.ts | 2 +- .../modules/ops/admin-partner-logs.service.ts | 82 +-- .../src/modules/ops/admin-partners.service.ts | 424 ++++++++---- .../src/modules/ops/admin-redeem.service.ts | 2 +- .../src/modules/ops/admin-stores.service.ts | 31 +- .../src/modules/ops/admin-tickets.service.ts | 12 +- .../ops/admin-wechat-bindings.service.ts | 18 +- .../src/modules/ops/dto/admin-mutate.dto.ts | 259 ++++++- .../src/modules/ops/dto/admin-query.dto.ts | 30 + .../dukang-api/src/modules/ops/ops.module.ts | 7 +- .../src/modules/redeem/redeem.service.ts | 5 +- .../settlement/settlement.controller.ts | 11 +- .../modules/settlement/settlement.module.ts | 3 +- .../modules/settlement/settlement.service.ts | 70 +- .../src/modules/store/store.module.ts | 3 +- .../src/modules/store/store.service.ts | 96 +-- .../src/modules/trade/trade.module.ts | 3 +- .../src/modules/trade/trade.service.ts | 52 +- 杜康好客-v3-城市仓库与日志架构.md | 124 ++++ 杜康好客-v3-现状对照.md | 8 +- 杜康好客-v3编码手册.md | 15 +- 74 files changed, 5430 insertions(+), 975 deletions(-) create mode 100644 apps/admin-web/src/components/ChinaProvinceCityCascader.tsx create mode 100644 apps/admin-web/src/components/CityPartnersPanel.tsx create mode 100644 apps/admin-web/src/components/PartnerSubAccountList.tsx create mode 100644 apps/admin-web/src/pages/CityPartnersPage.tsx create mode 100644 apps/admin-web/src/pages/CityWarehousesPage.tsx create mode 100644 packages/domain/src/city-partner.test.ts create mode 100644 packages/domain/src/city-partner.ts create mode 100644 packages/shared-types/src/city-partner.ts create mode 100644 packages/shared-types/src/city-warehouse.ts create mode 100644 server/dukang-api/prisma/clear-cities.ts create mode 100644 server/dukang-api/prisma/migrate-city-partner.ts create mode 100644 server/dukang-api/prisma/migrate-partner-account-unify.ts create mode 100644 server/dukang-api/src/modules/city-scope/city-scope.module.ts create mode 100644 server/dukang-api/src/modules/city-scope/city-warehouse.service.ts create mode 100644 server/dukang-api/src/modules/city-scope/partner-city.service.ts create mode 100644 server/dukang-api/src/modules/ops/admin-city-warehouses.controller.ts create mode 100644 杜康好客-v3-城市仓库与日志架构.md diff --git a/.cursor/rules/dukang-core.mdc b/.cursor/rules/dukang-core.mdc index db2eedd..cd16226 100644 --- a/.cursor/rules/dukang-core.mdc +++ b/.cursor/rules/dukang-core.mdc @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 05e6ded..d5ac38f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 摘要) diff --git a/README.md b/README.md index c5dbbf6..916ce46 100644 --- a/README.md +++ b/README.md @@ -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`。 diff --git a/apps/admin-web/src/App.tsx b/apps/admin-web/src/App.tsx index b99046d..44a0851 100644 --- a/apps/admin-web/src/App.tsx +++ b/apps/admin-web/src/App.tsx @@ -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() { } /> } /> } /> - } /> + } /> } /> - } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/apps/admin-web/src/components/ChinaProvinceCityCascader.tsx b/apps/admin-web/src/components/ChinaProvinceCityCascader.tsx new file mode 100644 index 0000000..0259a41 --- /dev/null +++ b/apps/admin-web/src/components/ChinaProvinceCityCascader.tsx @@ -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 ( + onChange?.((codes ?? []) as string[])} + disabled={disabled} + placeholder={placeholder} + showSearch={{ + filter: (input, path) => + path.some((option) => + String(option.label ?? '').toLowerCase().includes(input.toLowerCase()), + ), + }} + changeOnSelect={false} + /> + ); +} diff --git a/apps/admin-web/src/components/CityPartnersPanel.tsx b/apps/admin-web/src/components/CityPartnersPanel.tsx new file mode 100644 index 0000000..d00cde4 --- /dev/null +++ b/apps/admin-web/src/components/CityPartnersPanel.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [detail, setDetail] = useState(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(null); + const [createScopeType, setCreateScopeType] = useState(CityPartnerScopeType.CITY_WIDE); + const [editScopeType, setEditScopeType] = useState(CityPartnerScopeType.CITY_WIDE); + + const loadPartners = useCallback(async () => { + setLoading(true); + try { + const res = await request>(`/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(`/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 = [ + { 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) => ( + + ), + }, + ]; + + return ( + <> + + + + setDrawerOpen(false)} + extra={} + > + {detail && ( + + + + + + + + setEditScopeType(v)} /> + + {editScopeType === CityPartnerScopeType.DISTRICT && ( + + + + )} + + + + + + 订单 + 核销合计不得超过 {(maxPartnerCommissionRate * 100).toFixed(2)}%(可在城市基本信息中调整) + + + + + + + + + ), + }, + { + key: 'staff', + label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`, + children: ( + { + 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)} + /> + ), + }, + ]} + /> + )} + + + 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?.(); + }}> +
+ + + + + + + + + + + + + + + + +
+ + ); +} diff --git a/apps/admin-web/src/components/PartnerSubAccountList.tsx b/apps/admin-web/src/components/PartnerSubAccountList.tsx new file mode 100644 index 0000000..90b2a90 --- /dev/null +++ b/apps/admin-web/src/components/PartnerSubAccountList.tsx @@ -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 = [ + { 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) => ( + {s === 'ACTIVE' ? '启用' : '停用'} + ), + }, + { + title: '权限', + dataIndex: 'permissions', + ellipsis: true, + render: (p: string[] | undefined) => formatPermissions(p), + }, + { + title: '操作', + width: 120, + render: (_, row) => ( + + + onDelete(row.id)}> + + + + ), + }, + ]; + + return ( + <> +
+ + 子账号({subs.length})· 仅主账号可添加,不可多级 + + +
+
+ + ); +} diff --git a/apps/admin-web/src/layouts/AdminLayout.tsx b/apps/admin-web/src/layouts/AdminLayout.tsx index 7216fbf..390e6a3 100644 --- a/apps/admin-web/src/layouts/AdminLayout.tsx +++ b/apps/admin-web/src/layouts/AdminLayout.tsx @@ -51,9 +51,9 @@ const MENU_ITEMS: MenuProps['items'] = [ icon: , 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: '合伙人结算' }, ], }, diff --git a/apps/admin-web/src/lib/china-region.ts b/apps/admin-web/src/lib/china-region.ts index cd9019f..e6b3312 100644 --- a/apps/admin-web/src/lib/china-region.ts +++ b/apps/admin-web/src/lib/china-region.ts @@ -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, diff --git a/apps/admin-web/src/lib/hq-log.ts b/apps/admin-web/src/lib/hq-log.ts index 9122d68..ff535d2 100644 --- a/apps/admin-web/src/lib/hq-log.ts +++ b/apps/admin-web/src/lib/hq-log.ts @@ -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: '新增合伙人账户' }, diff --git a/apps/admin-web/src/lib/storeCreate.ts b/apps/admin-web/src/lib/storeCreate.ts index 9a4aee7..e21fd57 100644 --- a/apps/admin-web/src/lib/storeCreate.ts +++ b/apps/admin-web/src/lib/storeCreate.ts @@ -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, + form: Pick, ): 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位手机号'; diff --git a/apps/admin-web/src/pages/CitiesPage.tsx b/apps/admin-web/src/pages/CitiesPage.tsx index fd9324f..15721e7 100644 --- a/apps/admin-web/src/pages/CitiesPage.tsx +++ b/apps/admin-web/src/pages/CitiesPage.tsx @@ -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>({}); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/cities', @@ -32,37 +71,132 @@ export default function CitiesPage() { [filters], ); const [detail, setDetail] = useState | null>(null); + const [warehouses, setWarehouses] = useState([]); const [drawerOpen, setDrawerOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false); + const [warehouseOpen, setWarehouseOpen] = useState(false); + const [warehouseEditOpen, setWarehouseEditOpen] = useState(false); + const [warehouseEditId, setWarehouseEditId] = useState(null); const [partners, setPartners] = useState([]); + const [createRegionPreview, setCreateRegionPreview] = useState(null); + const createRegionCodes = Form.useWatch('regionCodes', createForm); + const [warehouseManagerType, setWarehouseManagerType] = useState(WarehouseManagerType.HQ); + const [editWarehouseManagerType, setEditWarehouseManagerType] = useState(WarehouseManagerType.HQ); - async function loadPartners() { - const res = await request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); + const loadPartners = useCallback(async (cityId: string) => { + const res = await request>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}&cityId=${cityId}`); setPartners(res.items); + }, []); + + const loadWarehouses = useCallback(async (cityId: string) => { + const wh = await request(`/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>(`/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 = [ { title: '编码', dataIndex: 'code', width: 90 }, { title: '城市', dataIndex: 'name', width: 100 }, { title: '省份', dataIndex: 'province', width: 90 }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => {CITY_STATUS_LABELS[s] || s} }, - { 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) => ( - + + ), + }, + ]; + + const warehouseColumns: ColumnsType = [ + { 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) => {s} }, + { + title: '操作', + width: 120, + render: (_, row) => ( + + + + ), }, ]; @@ -70,8 +204,8 @@ export default function CitiesPage() { return (
- 开城城市 - + 城市 +
{ setFilters(v); setPage(1); }}> @@ -81,59 +215,197 @@ export default function CitiesPage() { -
{ setPage(p); setPageSize(ps); } }} /> - setDrawerOpen(false)} - extra={detail && ( - - )}> + + setDrawerOpen(false)}> {detail && ( - <> - - {String(detail.code)} - {String(detail.storeCount ?? (detail as { _count?: { stores?: number } })._count?.stores ?? '—')} - -
- - - - ({ value, label }))} /> - - - - - + + + {String(detail.code)} + {String(detail.partnerBindingCount ?? '—')} + +
+ + + +
+ + ), + }, + ]} + /> )} + 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(); }}> - - - - - + + + + + + + ({ value: p.id, label: p.companyName }))} /> + + )} + + + + + + + ({ value: p.id, label: p.companyName }))} /> + + )} + + + + + + + +
( + 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); + }, + }} + /> + + setDrawerOpen(false)} + extra={ + + } + > + {detail && ( + <> + + {detail.cityName ?? '—'} + {detail.storeCount ?? 0} + + {detail.managedWarehouseName ?? ( + + 未分配(请前往 仓库管理 设置) + + )} + + + + + + + + + + + + + + + + + + + + setEditScopeType(v)} /> + + {editScopeType === CityPartnerScopeType.DISTRICT && ( + + + + )} + + + + + + + + + + 订单 + 核销合计不得超过 {(maxCommissionRate * 100).toFixed(2)}% + + + + + + + + + + + + ), + }, + { + key: 'staff', + label: `子账号 (${Math.max(0, (detail.accountCount ?? 1) - 1)})`, + children: ( + { + subForm.resetFields(); + subForm.setFieldsValue({ staffRole: 'INTERNAL', permissions: ['store:create'] }); + setSubOpen(true); + }} + onEdit={(sub) => openSubEdit(sub, detail.id)} + onDelete={(subId) => void deleteSubAccount(subId, detail.id)} + /> + ), + }, + ]} + /> + + )} + + + 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(); + }} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ); +} diff --git a/apps/admin-web/src/pages/CityWarehousesPage.tsx b/apps/admin-web/src/pages/CityWarehousesPage.tsx new file mode 100644 index 0000000..c063654 --- /dev/null +++ b/apps/admin-web/src/pages/CityWarehousesPage.tsx @@ -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>({}); + const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( + '/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([]); + const [partners, setPartners] = useState([]); + const [createOpen, setCreateOpen] = useState(false); + const [editOpen, setEditOpen] = useState(false); + const [editRow, setEditRow] = useState(null); + const [createManagerType, setCreateManagerType] = useState(WarehouseManagerType.HQ); + const [editManagerType, setEditManagerType] = useState(WarehouseManagerType.HQ); + const [createCityId, setCreateCityId] = useState(); + + const loadCities = useCallback(async () => { + const res = await request>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`); + setCities(res.items); + }, []); + + const loadPartners = useCallback(async (cityId: string) => { + const res = await request>( + `/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 = [ + { title: '仓库名', dataIndex: 'name', ellipsis: true, width: 140 }, + { + title: '城市', + width: 100, + render: (_, row) => ( + + {row.cityName} + + ), + }, + { 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 ? ( + {v} + ) : ( + '—' + ), + }, + { + title: '状态', + dataIndex: 'status', + width: 80, + render: (s) => ( + + {WAREHOUSE_STATUS_LABELS[s as WarehouseStatus] || s} + + ), + }, + { title: '创建', dataIndex: 'createdAt', width: 150, render: fmtTime }, + { + title: '操作', + width: 120, + fixed: 'right', + render: (_, row) => ( + + + { + await request(`/admin/city-warehouses/${row.id}`, { method: 'DELETE' }); + message.success('已删除'); + void reload(); + }} + > + + + + ), + }, + ]; + + return ( +
+ + + 仓库管理 + + + + + + +
{ + setFilters(v); + setPage(1); + }} + > + + + + + + + +
`共 ${t} 条`, + onChange: (p, ps) => { + setPage(p); + setPageSize(ps); + }, + }} + /> + + 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(); + }} + > + + + + + + + + + + + + + + + ({ value: p.id, label: p.companyName }))} + /> + + )} + + + + + + + + + + + + + + ({ value: p.id, label: p.companyName }))} + /> + + )} + + + - {detail.staffRole ? ( - - - - ) : null} {detail.parentAccountId && detail.parent ? ( ) : null} - + - + - ({ value, label }))} /> - - 合伙人 H5 登录使用「登录手机」,与开城合伙人主体的「联系电话」可不同。 - + {detail.parentAccountId ? ( + + + + ) : null} + {!detail.parentAccountId ? ( + + 主账号请在「开城合伙人」页面编辑。 + + ) : ( + + 合伙人 H5 登录使用「登录手机」,与主账号联系电话可不同。 + + )} ), }, @@ -403,31 +413,6 @@ export default function PartnerAccountsPage() { ]} /> )} - 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(); - }}> -
- - - - - - -
-
{ setPage(p); setPageSize(ps); } }} /> +
{ setPage(p); setPageSize(ps); }, + }} + /> + setDrawerOpen(false)} extra={} > {detail && ( - <> - {Array.isArray(detail.cities) && detail.cities.length > 0 && ( - - {detail.cities.map((c) => ( - {c.name} ({c.status}) - ))} - - )} -
- - - - - - - - + + + + + + + + ({ value: w.id, label: w.name }))} /> + + + + + + ), + }, + { + key: 'staff', + label: '子账号', + children: ( + <> + +
{s} }, + { + title: '权限', + dataIndex: 'permissions', + render: (p: string[] | undefined) => p?.map((k) => PARTNER_PERMISSION_LABELS[k as keyof typeof PARTNER_PERMISSION_LABELS] || k).join('、') || '—', + }, + ]} + /> + + ), + }, + ]} + /> )} - 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(); - }}> -
+ + 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(); + }} + > + + + - + + + + ({ value: w.id, label: w.name }))} /> + + )} + + + + 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); + }} + > +
+ + + + + + + )} @@ -339,16 +359,16 @@ export default function StoresPage() { )}
- + + + + & { +export type PartnerSessionProfile = Pick & { 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, }; } diff --git a/apps/h5-partner/src/lib/partnerAccess.ts b/apps/h5-partner/src/lib/partnerAccess.ts index c2093ea..5bc2d78 100644 --- a/apps/h5-partner/src/lib/partnerAccess.ts +++ b/apps/h5-partner/src/lib/partnerAccess.ts @@ -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' : '/'; } diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 9da510b..5e31d9a 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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 diff --git a/packages/domain/src/city-partner.test.ts b/packages/domain/src/city-partner.test.ts new file mode 100644 index 0000000..b59ba13 --- /dev/null +++ b/packages/domain/src/city-partner.test.ts @@ -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); + }); +}); diff --git a/packages/domain/src/city-partner.ts b/packages/domain/src/city-partner.ts new file mode 100644 index 0000000..3eafdb8 --- /dev/null +++ b/packages/domain/src/city-partner.ts @@ -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(); + 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 }; +} diff --git a/packages/domain/src/index.ts b/packages/domain/src/index.ts index 0339013..2ed0d6a 100644 --- a/packages/domain/src/index.ts +++ b/packages/domain/src/index.ts @@ -125,3 +125,5 @@ export function orderTabToStatuses(tab: string): string[] | undefined { return undefined; } } + +export * from './city-partner'; diff --git a/packages/shared-types/src/catalog.ts b/packages/shared-types/src/catalog.ts index 079515c..67c4125 100644 --- a/packages/shared-types/src/catalog.ts +++ b/packages/shared-types/src/catalog.ts @@ -4,6 +4,8 @@ export interface CityDto { name: string; province: string; status: string; + /** 订单+核销佣金合计上限(小数,默认 0.05) */ + maxPartnerCommissionRate?: number; } export interface ProductDto { diff --git a/packages/shared-types/src/city-partner.ts b/packages/shared-types/src/city-partner.ts new file mode 100644 index 0000000..6a1520b --- /dev/null +++ b/packages/shared-types/src/city-partner.ts @@ -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 = { + 'warehouse:manage': '仓库管理', + 'store:manage': '门店管理', + 'store:create': '开店管理', + 'order:view': '订单查看', +}; diff --git a/packages/shared-types/src/city-warehouse.ts b/packages/shared-types/src/city-warehouse.ts new file mode 100644 index 0000000..72d2b0b --- /dev/null +++ b/packages/shared-types/src/city-warehouse.ts @@ -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; +} diff --git a/packages/shared-types/src/enums.ts b/packages/shared-types/src/enums.ts index 618453c..a4ac4b9 100644 --- a/packages/shared-types/src/enums.ts +++ b/packages/shared-types/src/enums.ts @@ -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.CITY_WIDE]: '全城合伙人', + [CityPartnerScopeType.DISTRICT]: '区域合伙人', +}; + +export const WAREHOUSE_MANAGER_LABELS: Record = { + [WarehouseManagerType.HQ]: '总部直管', + [WarehouseManagerType.PARTNER]: '合伙人管仓', +}; + +export const CITY_PARTNER_STATUS_LABELS: Record = { + [CityPartnerStatus.ACTIVE]: '启用', + [CityPartnerStatus.PAUSED]: '暂停', +}; + +export const WAREHOUSE_STATUS_LABELS: Record = { + [WarehouseStatus.ACTIVE]: '启用', + [WarehouseStatus.PAUSED]: '暂停', +}; + export enum AccountStatus { ACTIVE = 'ACTIVE', DISABLED = 'DISABLED', diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 3dcfb7c..0c5aaef 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -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'; diff --git a/packages/shared-types/src/partner-log.ts b/packages/shared-types/src/partner-log.ts index 9d9b1fb..c2ae326 100644 --- a/packages/shared-types/src/partner-log.ts +++ b/packages/shared-types/src/partner-log.ts @@ -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 = { 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 = { '': '全部', login: '登录', wechat_auth: '微信授权', + account_ops: '子账号管理', store_ops: '门店操作', shipping: '发货/配送', settlement: '结算', + warehouse_ops: '仓库操作', }; export function resolvePartnerLogCategory(eventName: string): PartnerLogCategory | null { diff --git a/packages/shared-types/src/partner.ts b/packages/shared-types/src/partner.ts index 7751bc7..b6f08ba 100644 --- a/packages/shared-types/src/partner.ts +++ b/packages/shared-types/src/partner.ts @@ -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; } diff --git a/scripts/sms-test-helper.mjs b/scripts/sms-test-helper.mjs index e8e0bc3..e8e854b 100644 --- a/scripts/sms-test-helper.mjs +++ b/scripts/sms-test-helper.mjs @@ -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)); diff --git a/server/dukang-api/.env.example b/server/dukang-api/.env.example index 12592ac..b4c47b9 100644 --- a/server/dukang-api/.env.example +++ b/server/dukang-api/.env.example @@ -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 diff --git a/server/dukang-api/package.json b/server/dukang-api/package.json index 1eaa770..917438a 100644 --- a/server/dukang-api/package.json +++ b/server/dukang-api/package.json @@ -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" diff --git a/server/dukang-api/prisma/clear-cities.ts b/server/dukang-api/prisma/clear-cities.ts new file mode 100644 index 0000000..e0c528e --- /dev/null +++ b/server/dukang-api/prisma/clear-cities.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(); + }); diff --git a/server/dukang-api/prisma/init_v3.sql b/server/dukang-api/prisma/init_v3.sql index 24af529..9606e7c 100644 --- a/server/dukang-api/prisma/init_v3.sql +++ b/server/dukang-api/prisma/init_v3.sql @@ -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), diff --git a/server/dukang-api/prisma/migrate-city-partner.ts b/server/dukang-api/prisma/migrate-city-partner.ts new file mode 100644 index 0000000..30dd82f --- /dev/null +++ b/server/dukang-api/prisma/migrate-city-partner.ts @@ -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 { + const rows = await prisma.$queryRawUnsafe>( + `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()); diff --git a/server/dukang-api/prisma/migrate-partner-account-unify.ts b/server/dukang-api/prisma/migrate-partner-account-unify.ts new file mode 100644 index 0000000..08184dc --- /dev/null +++ b/server/dukang-api/prisma/migrate-partner-account-unify.ts @@ -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); +}); diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index 18d8d69..63558d1 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -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") diff --git a/server/dukang-api/prisma/seed-v31.ts b/server/dukang-api/prisma/seed-v31.ts index 3a73669..0a8f100 100644 --- a/server/dukang-api/prisma/seed-v31.ts +++ b/server/dukang-api/prisma/seed-v31.ts @@ -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(); + }); + diff --git a/server/dukang-api/src/app.module.ts b/server/dukang-api/src/app.module.ts index 43ac719..f27eac3 100644 --- a/server/dukang-api/src/app.module.ts +++ b/server/dukang-api/src/app.module.ts @@ -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, diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index 985a6ce..dcb3a0a 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -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 = { [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]: '新增合伙人账户', diff --git a/server/dukang-api/src/modules/analytics/analytics.service.ts b/server/dukang-api/src/modules/analytics/analytics.service.ts index a47d8af..412de69 100644 --- a/server/dukang-api/src/modules/analytics/analytics.service.ts +++ b/server/dukang-api/src/modules/analytics/analytics.service.ts @@ -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, }; } diff --git a/server/dukang-api/src/modules/catalog/catalog.service.ts b/server/dukang-api/src/modules/catalog/catalog.service.ts index b0bda7f..a092f03 100644 --- a/server/dukang-api/src/modules/catalog/catalog.service.ts +++ b/server/dukang-api/src/modules/catalog/catalog.service.ts @@ -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) { diff --git a/server/dukang-api/src/modules/city-scope/city-scope.module.ts b/server/dukang-api/src/modules/city-scope/city-scope.module.ts new file mode 100644 index 0000000..d816951 --- /dev/null +++ b/server/dukang-api/src/modules/city-scope/city-scope.module.ts @@ -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 {} diff --git a/server/dukang-api/src/modules/city-scope/city-warehouse.service.ts b/server/dukang-api/src/modules/city-scope/city-warehouse.service.ts new file mode 100644 index 0000000..11160aa --- /dev/null +++ b/server/dukang-api/src/modules/city-scope/city-warehouse.service.ts @@ -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; + +@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(), + }); + } +} diff --git a/server/dukang-api/src/modules/city-scope/partner-city.service.ts b/server/dukang-api/src/modules/city-scope/partner-city.service.ts new file mode 100644 index 0000000..5095396 --- /dev/null +++ b/server/dukang-api/src/modules/city-scope/partner-city.service.ts @@ -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 { + 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 { + const primary = await this.resolvePrimaryAccount(partnerAccountId); + if (!primary.cityId) return { id: -1n }; + return { cityId: primary.cityId }; + } + + async buildPartnerCityWhere(partnerAccountId: bigint): Promise { + 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(), + }); + } +} diff --git a/server/dukang-api/src/modules/iam/auth.service.ts b/server/dukang-api/src/modules/iam/auth.service.ts index 038343f..97eaffd 100644 --- a/server/dukang-api/src/modules/iam/auth.service.ts +++ b/server/dukang-api/src/modules/iam/auth.service.ts @@ -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, 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 { diff --git a/server/dukang-api/src/modules/iam/dto/partner-staff.dto.ts b/server/dukang-api/src/modules/iam/dto/partner-staff.dto.ts index a6c78fc..ca0eee3 100644 --- a/server/dukang-api/src/modules/iam/dto/partner-staff.dto.ts +++ b/server/dukang-api/src/modules/iam/dto/partner-staff.dto.ts @@ -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() diff --git a/server/dukang-api/src/modules/iam/partner-staff.controller.ts b/server/dukang-api/src/modules/iam/partner-staff.controller.ts index b2181f9..32967d3 100644 --- a/server/dukang-api/src/modules/iam/partner-staff.controller.ts +++ b/server/dukang-api/src/modules/iam/partner-staff.controller.ts @@ -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)); } } diff --git a/server/dukang-api/src/modules/iam/partner-staff.service.ts b/server/dukang-api/src/modules/iam/partner-staff.service.ts index 942e494..e40fdb6 100644 --- a/server/dukang-api/src/modules/iam/partner-staff.service.ts +++ b/server/dukang-api/src/modules/iam/partner-staff.service.ts @@ -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 = {}; - 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 = {}; + 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; + } + if (dto.status !== undefined) { + data.status = dto.status; + } + const updated = await this.prisma.partnerAccount.update({ + where: { id: staff.id }, + data, + }); + + const onlyRoleChange = + (dto.staffRole !== undefined || dto.permissions !== undefined) && + dto.name === undefined && + dto.status === undefined; + const eventName = onlyRoleChange ? 'partner_staff_permission_update' : 'partner_staff_update'; + + const primaryId = parentAccountId; + this.trackStaffEvent(actor, primaryId, eventName, staff.id, { + before, + after: { + name: updated.name, + staffRole: updated.staffRole, + status: updated.status, + }, + }); + + return this.toStaffItem(updated); + } + + async deleteStaff(actor: AuthUser, staffId: bigint) { + const parentAccountId = actor.actorId; + const staff = await this.assertStaffOwned(parentAccountId, staffId); + + this.trackStaffEvent(actor, parentAccountId, 'partner_staff_delete', staff.id, { + name: staff.name, + phone: this.maskPhone(staff.phone), + staffRole: staff.staffRole, + status: staff.status, + }); + + await this.prisma.partnerAccount.delete({ where: { id: staff.id } }); + return { ok: true }; + } + + private trackStaffEvent( + actor: AuthUser, + primaryAccountId: bigint, + eventName: string, + refId: bigint, + extraJson?: Record, + ) { + this.analytics.trackPartnerOneSafe(actor.actorId, actor.clientApp, { + partnerAccountId: primaryAccountId, + eventName, + refType: 'PARTNER_ACCOUNT', + refId, + extraJson, + }); + } + + 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; + permissions?: unknown; + status: string; + lastLoginAt: Date | null; + }) { + return serializeBigInt({ + id: row.id.toString(), + name: row.name, + phone: this.maskPhone(row.phone), + staffRole: row.staffRole ?? PartnerStaffRole.INTERNAL, + permissions: Array.isArray(row.permissions) ? row.permissions : undefined, + 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)}`; + } +} diff --git a/server/dukang-api/src/modules/ops/admin-cities.service.ts b/server/dukang-api/src/modules/ops/admin-cities.service.ts index adf11df..5457898 100644 --- a/server/dukang-api/src/modules/ops/admin-cities.service.ts +++ b/server/dukang-api/src/modules/ops/admin-cities.service.ts @@ -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); diff --git a/server/dukang-api/src/modules/ops/admin-city-warehouses.controller.ts b/server/dukang-api/src/modules/ops/admin-city-warehouses.controller.ts new file mode 100644 index 0000000..8df8da0 --- /dev/null +++ b/server/dukang-api/src/modules/ops/admin-city-warehouses.controller.ts @@ -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)); + } +} diff --git a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts index 7481fe7..7c3c97b 100644 --- a/server/dukang-api/src/modules/ops/admin-dashboard.service.ts +++ b/server/dukang-api/src/modules/ops/admin-dashboard.service.ts @@ -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' } }), diff --git a/server/dukang-api/src/modules/ops/admin-partner-logs.service.ts b/server/dukang-api/src/modules/ops/admin-partner-logs.service.ts index 70e0fbc..eaa243b 100644 --- a/server/dukang-api/src/modules/ops/admin-partner-logs.service.ts +++ b/server/dukang-api/src/modules/ops/admin-partner-logs.service.ts @@ -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 { + private async resolvePartnerAccountIds( + query: AdminPartnerLogsQueryDto, + ): Promise { + 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, diff --git a/server/dukang-api/src/modules/ops/admin-partners.service.ts b/server/dukang-api/src/modules/ops/admin-partners.service.ts index 4415794..8915d80 100644 --- a/server/dukang-api/src/modules/ops/admin-partners.service.ts +++ b/server/dukang-api/src/modules/ops/admin-partners.service.ts @@ -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('仅可删除子账号'); } diff --git a/server/dukang-api/src/modules/ops/admin-redeem.service.ts b/server/dukang-api/src/modules/ops/admin-redeem.service.ts index 57280b8..9bfb880 100644 --- a/server/dukang-api/src/modules/ops/admin-redeem.service.ts +++ b/server/dukang-api/src/modules/ops/admin-redeem.service.ts @@ -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, }, diff --git a/server/dukang-api/src/modules/ops/admin-stores.service.ts b/server/dukang-api/src/modules/ops/admin-stores.service.ts index 52575b5..e923e51 100644 --- a/server/dukang-api/src/modules/ops/admin-stores.service.ts +++ b/server/dukang-api/src/modules/ops/admin-stores.service.ts @@ -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); diff --git a/server/dukang-api/src/modules/ops/admin-tickets.service.ts b/server/dukang-api/src/modules/ops/admin-tickets.service.ts index f3bd330..0b9bdf7 100644 --- a/server/dukang-api/src/modules/ops/admin-tickets.service.ts +++ b/server/dukang-api/src/modules/ops/admin-tickets.service.ts @@ -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); diff --git a/server/dukang-api/src/modules/ops/admin-wechat-bindings.service.ts b/server/dukang-api/src/modules/ops/admin-wechat-bindings.service.ts index 8b4ec7f..f1fe060 100644 --- a/server/dukang-api/src/modules/ops/admin-wechat-bindings.service.ts +++ b/server/dukang-api/src/modules/ops/admin-wechat-bindings.service.ts @@ -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, }); diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 4e2c4cd..49a4ca4 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -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 { diff --git a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts index 6bc9193..aa3b78d 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-query.dto.ts @@ -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 { diff --git a/server/dukang-api/src/modules/ops/ops.module.ts b/server/dukang-api/src/modules/ops/ops.module.ts index 84a2de0..ea07f7b 100644 --- a/server/dukang-api/src/modules/ops/ops.module.ts +++ b/server/dukang-api/src/modules/ops/ops.module.ts @@ -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 {} diff --git a/server/dukang-api/src/modules/redeem/redeem.service.ts b/server/dukang-api/src/modules/redeem/redeem.service.ts index 5e7d117..1bd5678 100644 --- a/server/dukang-api/src/modules/redeem/redeem.service.ts +++ b/server/dukang-api/src/modules/redeem/redeem.service.ts @@ -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; diff --git a/server/dukang-api/src/modules/settlement/settlement.controller.ts b/server/dukang-api/src/modules/settlement/settlement.controller.ts index 225e248..6cdf951 100644 --- a/server/dukang-api/src/modules/settlement/settlement.controller.ts +++ b/server/dukang-api/src/modules/settlement/settlement.controller.ts @@ -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, }; } diff --git a/server/dukang-api/src/modules/settlement/settlement.module.ts b/server/dukang-api/src/modules/settlement/settlement.module.ts index ec7c869..e1dc1cd 100644 --- a/server/dukang-api/src/modules/settlement/settlement.module.ts +++ b/server/dukang-api/src/modules/settlement/settlement.module.ts @@ -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, diff --git a/server/dukang-api/src/modules/settlement/settlement.service.ts b/server/dukang-api/src/modules/settlement/settlement.service.ts index 90837ec..03e34e7 100644 --- a/server/dukang-api/src/modules/settlement/settlement.service.ts +++ b/server/dukang-api/src/modules/settlement/settlement.service.ts @@ -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), diff --git a/server/dukang-api/src/modules/store/store.module.ts b/server/dukang-api/src/modules/store/store.module.ts index a0792b5..30db0ec 100644 --- a/server/dukang-api/src/modules/store/store.module.ts +++ b/server/dukang-api/src/modules/store/store.module.ts @@ -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, diff --git a/server/dukang-api/src/modules/store/store.service.ts b/server/dukang-api/src/modules/store/store.service.ts index cdd47b6..8367726 100644 --- a/server/dukang-api/src/modules/store/store.service.ts +++ b/server/dukang-api/src/modules/store/store.service.ts @@ -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) { - 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, ) { - 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; } diff --git a/server/dukang-api/src/modules/trade/trade.module.ts b/server/dukang-api/src/modules/trade/trade.module.ts index f703fda..e6065a7 100644 --- a/server/dukang-api/src/modules/trade/trade.module.ts +++ b/server/dukang-api/src/modules/trade/trade.module.ts @@ -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], diff --git a/server/dukang-api/src/modules/trade/trade.service.ts b/server/dukang-api/src/modules/trade/trade.service.ts index 086f121..37d2b02 100644 --- a/server/dukang-api/src/modules/trade/trade.service.ts +++ b/server/dukang-api/src/modules/trade/trade.service.ts @@ -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, diff --git a/杜康好客-v3-城市仓库与日志架构.md b/杜康好客-v3-城市仓库与日志架构.md new file mode 100644 index 0000000..21e26d4 --- /dev/null +++ b/杜康好客-v3-城市仓库与日志架构.md @@ -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` | 合伙人日志 | diff --git a/杜康好客-v3-现状对照.md b/杜康好客-v3-现状对照.md index 091f617..d0864c7 100644 --- a/杜康好客-v3-现状对照.md +++ b/杜康好客-v3-现状对照.md @@ -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 | diff --git a/杜康好客-v3编码手册.md b/杜康好客-v3编码手册.md index e5ca061..27b9898 100644 --- a/杜康好客-v3编码手册.md +++ b/杜康好客-v3编码手册.md @@ -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 上限)