@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/admin-web",
|
"name": "@dukang/admin-web",
|
||||||
"version": "4.0.19",
|
"version": "4.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message,
|
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
@@ -9,25 +9,37 @@ import {
|
|||||||
PROMO_CODE_STATUS_LABELS,
|
PROMO_CODE_STATUS_LABELS,
|
||||||
promoConversion,
|
promoConversion,
|
||||||
type PromoCodeItem,
|
type PromoCodeItem,
|
||||||
|
type PromoCodePartnerBrief,
|
||||||
type PromoCodeScene,
|
type PromoCodeScene,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request, type Paginated } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||||
import { AdminListHeader } from '../components/AdminListHeader';
|
import { AdminListHeader } from '../components/AdminListHeader';
|
||||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||||
|
|
||||||
|
|
||||||
type Row = PromoCodeItem;
|
type Row = PromoCodeItem;
|
||||||
|
type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: string };
|
||||||
type SceneOption = { value: PromoCodeScene; label: string };
|
type SceneOption = { value: PromoCodeScene; label: string };
|
||||||
|
|
||||||
|
function formatPartnerLabel(p?: PromoCodePartnerBrief | PartnerOption | null) {
|
||||||
|
if (!p) return '—';
|
||||||
|
const title = p.companyName || p.name || p.id;
|
||||||
|
return p.phone ? `${title} · ${p.phone}` : title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPartnerNames(partners?: PromoCodePartnerBrief[] | null) {
|
||||||
|
if (!partners?.length) return '—';
|
||||||
|
return partners.map((p) => p.companyName || p.name || p.id).join('、');
|
||||||
|
}
|
||||||
|
|
||||||
export default function PromoCodesPage() {
|
export default function PromoCodesPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [filterForm] = Form.useForm();
|
const [filterForm] = Form.useForm();
|
||||||
const [createForm] = Form.useForm();
|
const [createForm] = Form.useForm();
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
const [scenes, setScenes] = useState<SceneOption[]>(
|
const [scenes, setScenes] = useState<SceneOption[]>(
|
||||||
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
|
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
|
||||||
value: value as PromoCodeScene,
|
value: value as PromoCodeScene,
|
||||||
@@ -37,6 +49,11 @@ export default function PromoCodesPage() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
|
||||||
|
const partnerSelectOptions = useMemo(
|
||||||
|
() => partners.map((p) => ({ value: p.id, label: formatPartnerLabel(p) })),
|
||||||
|
[partners],
|
||||||
|
);
|
||||||
|
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
'/admin/promo-codes',
|
'/admin/promo-codes',
|
||||||
() => {
|
() => {
|
||||||
@@ -45,6 +62,8 @@ export default function PromoCodesPage() {
|
|||||||
if (filters.code) qs.set('code', filters.code);
|
if (filters.code) qs.set('code', filters.code);
|
||||||
if (filters.status) qs.set('status', filters.status);
|
if (filters.status) qs.set('status', filters.status);
|
||||||
if (filters.scene) qs.set('scene', filters.scene);
|
if (filters.scene) qs.set('scene', filters.scene);
|
||||||
|
if (filters.channelOwnerPartnerId) qs.set('channelOwnerPartnerId', filters.channelOwnerPartnerId);
|
||||||
|
if (filters.assocPartnerAccountId) qs.set('assocPartnerAccountId', filters.assocPartnerAccountId);
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -63,6 +82,12 @@ export default function PromoCodesPage() {
|
|||||||
void loadScenes();
|
void loadScenes();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setPartners(res.items ?? []))
|
||||||
|
.catch(() => setPartners([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const baseColumns: ColumnsType<Row> = [
|
const baseColumns: ColumnsType<Row> = [
|
||||||
{
|
{
|
||||||
title: '名称',
|
title: '名称',
|
||||||
@@ -116,8 +141,13 @@ export default function PromoCodesPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '渠道负责人',
|
title: '渠道负责人',
|
||||||
width: 120,
|
width: 160,
|
||||||
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
|
render: (_, row) => formatPartnerNames(row.channelOwners),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '关联合伙人',
|
||||||
|
width: 140,
|
||||||
|
render: (_, row) => row.assocPartner?.companyName || row.assocPartner?.name || '—',
|
||||||
},
|
},
|
||||||
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
@@ -137,7 +167,15 @@ export default function PromoCodesPage() {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function handleCreate(values: Record<string, string>) {
|
async function handleCreate(values: {
|
||||||
|
name: string;
|
||||||
|
code?: string;
|
||||||
|
scene: PromoCodeScene;
|
||||||
|
page?: string;
|
||||||
|
remark?: string;
|
||||||
|
channelOwnerPartnerIds?: string[];
|
||||||
|
assocPartnerAccountId?: string;
|
||||||
|
}) {
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const created = await request<PromoCodeItem>('/admin/promo-codes', {
|
const created = await request<PromoCodeItem>('/admin/promo-codes', {
|
||||||
@@ -146,7 +184,8 @@ export default function PromoCodesPage() {
|
|||||||
name: values.name,
|
name: values.name,
|
||||||
code: values.code?.trim() || undefined,
|
code: values.code?.trim() || undefined,
|
||||||
scene: values.scene,
|
scene: values.scene,
|
||||||
ownerUserId: values.ownerUserId?.trim() || undefined,
|
channelOwnerPartnerIds: values.channelOwnerPartnerIds ?? [],
|
||||||
|
assocPartnerAccountId: values.assocPartnerAccountId || null,
|
||||||
remark: values.remark?.trim() || undefined,
|
remark: values.remark?.trim() || undefined,
|
||||||
page: values.page?.trim() || undefined,
|
page: values.page?.trim() || undefined,
|
||||||
}),
|
}),
|
||||||
@@ -192,6 +231,24 @@ export default function PromoCodesPage() {
|
|||||||
options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(PROMO_CODE_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="channelOwnerPartnerId" label="渠道负责人">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ minWidth: 200 }}
|
||||||
|
options={partnerSelectOptions}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="assocPartnerAccountId" label="关联合伙人">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
style={{ minWidth: 200 }}
|
||||||
|
options={partnerSelectOptions}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
<Form.Item><Button type="primary" htmlType="submit">查询</Button></Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
@@ -235,8 +292,32 @@ export default function PromoCodesPage() {
|
|||||||
>
|
>
|
||||||
<Input placeholder="pages/home/index" />
|
<Input placeholder="pages/home/index" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
|
<Form.Item
|
||||||
<Input placeholder="渠道负责人,填写用户数据库 ID" />
|
name="channelOwnerPartnerIds"
|
||||||
|
label="渠道负责人"
|
||||||
|
extra="可空、可多选。被指定的主合伙人可在 H5 查看扫码/归因/订单数量,看不到用户或订单明细。"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="选择主合伙人"
|
||||||
|
options={partnerSelectOptions}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="assocPartnerAccountId"
|
||||||
|
label="关联合伙人"
|
||||||
|
extra="可空。登录用户扫该码且尚未关联任何人时,将绑定到该主合伙人;已关联他人不换绑。"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="选择主合伙人"
|
||||||
|
options={partnerSelectOptions}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="remark" label="备注">
|
<Form.Item name="remark" label="备注">
|
||||||
<Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
|
<Input.TextArea rows={2} placeholder="渠道说明、活动备注等" />
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, type CSSProperties } from 'react';
|
import { useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||||
import { useNavigate, useOutletContext } from 'react-router-dom';
|
import { useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||||
import {
|
import {
|
||||||
@@ -23,8 +23,8 @@ import {
|
|||||||
PROMO_CODE_STATUS_LABELS,
|
PROMO_CODE_STATUS_LABELS,
|
||||||
promoConversion,
|
promoConversion,
|
||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import { request } from '../../lib/api';
|
import { request, type Paginated } from '../../lib/api';
|
||||||
import { fmtTime } from '../../lib/constants';
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../../lib/constants';
|
||||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||||
import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
|
import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
|
||||||
|
|
||||||
@@ -51,6 +51,19 @@ const ORDER_HINT = '下单时绑定当时的归因推广码,仅统计已完成
|
|||||||
const CONVERSION_HINT =
|
const CONVERSION_HINT =
|
||||||
'已完成订单数 ÷ 扫码进入次数(人次)。同一人多次扫码会放大分母,未登录扫码也计入。';
|
'已完成订单数 ÷ 扫码进入次数(人次)。同一人多次扫码会放大分母,未登录扫码也计入。';
|
||||||
|
|
||||||
|
type PartnerOption = { id: string; companyName?: string | null; name?: string | null; phone?: string | null };
|
||||||
|
|
||||||
|
function formatPartnerLabel(p?: { companyName?: string | null; name?: string | null; phone?: string | null; id: string } | null) {
|
||||||
|
if (!p) return '—';
|
||||||
|
const title = p.companyName || p.name || p.id;
|
||||||
|
return p.phone ? `${title} · ${p.phone}` : title;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPartnerNames(partners?: Array<{ companyName?: string | null; name?: string | null; id: string }> | null) {
|
||||||
|
if (!partners?.length) return '—';
|
||||||
|
return partners.map((p) => p.companyName || p.name || p.id).join('、');
|
||||||
|
}
|
||||||
|
|
||||||
function StatHint({ label, hint }: { label: string; hint: string }) {
|
function StatHint({ label, hint }: { label: string; hint: string }) {
|
||||||
return (
|
return (
|
||||||
<span>
|
<span>
|
||||||
@@ -86,10 +99,30 @@ export default function PromoCodeDetailPage() {
|
|||||||
const [editForm] = Form.useForm();
|
const [editForm] = Form.useForm();
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
|
|
||||||
const stats = detail.stats;
|
const stats = detail.stats;
|
||||||
|
|
||||||
async function handleEdit(values: Record<string, string>) {
|
const partnerSelectOptions = useMemo(() => {
|
||||||
|
const map = new Map(partners.map((p) => [p.id, p]));
|
||||||
|
for (const p of detail.channelOwners ?? []) map.set(p.id, p);
|
||||||
|
if (detail.assocPartner) map.set(detail.assocPartner.id, detail.assocPartner);
|
||||||
|
return [...map.values()].map((p) => ({ value: p.id, label: formatPartnerLabel(p) }));
|
||||||
|
}, [partners, detail.channelOwners, detail.assocPartner]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setPartners(res.items ?? []))
|
||||||
|
.catch(() => setPartners([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleEdit(values: {
|
||||||
|
name: string;
|
||||||
|
scene: string;
|
||||||
|
remark?: string;
|
||||||
|
channelOwnerPartnerIds?: string[];
|
||||||
|
assocPartnerAccountId?: string;
|
||||||
|
}) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await request(`/admin/promo-codes/${detail.id}`, {
|
await request(`/admin/promo-codes/${detail.id}`, {
|
||||||
@@ -97,7 +130,8 @@ export default function PromoCodeDetailPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: values.name,
|
name: values.name,
|
||||||
scene: values.scene,
|
scene: values.scene,
|
||||||
ownerUserId: values.ownerUserId?.trim() || null,
|
channelOwnerPartnerIds: values.channelOwnerPartnerIds ?? [],
|
||||||
|
assocPartnerAccountId: values.assocPartnerAccountId || null,
|
||||||
remark: values.remark?.trim() || null,
|
remark: values.remark?.trim() || null,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -128,7 +162,8 @@ export default function PromoCodeDetailPage() {
|
|||||||
name: detail.name,
|
name: detail.name,
|
||||||
scene: detail.scene,
|
scene: detail.scene,
|
||||||
remark: detail.remark,
|
remark: detail.remark,
|
||||||
ownerUserId: detail.ownerUser?.id,
|
channelOwnerPartnerIds: (detail.channelOwners ?? []).map((p) => p.id),
|
||||||
|
assocPartnerAccountId: detail.assocPartner?.id,
|
||||||
});
|
});
|
||||||
setEditOpen(true);
|
setEditOpen(true);
|
||||||
}}
|
}}
|
||||||
@@ -194,7 +229,12 @@ export default function PromoCodeDetailPage() {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="渠道负责人">
|
<Descriptions.Item label="渠道负责人">
|
||||||
<span style={{ whiteSpace: 'nowrap' }}>
|
<span style={{ whiteSpace: 'nowrap' }}>
|
||||||
{detail.ownerUser?.userNo || detail.ownerUser?.phone || '—'}
|
{formatPartnerNames(detail.channelOwners)}
|
||||||
|
</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="关联合伙人">
|
||||||
|
<span style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{formatPartnerLabel(detail.assocPartner)}
|
||||||
</span>
|
</span>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
<Descriptions.Item label="备注">{detail.remark || '—'}</Descriptions.Item>
|
||||||
@@ -309,8 +349,32 @@ export default function PromoCodeDetailPage() {
|
|||||||
options={Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ value, label }))}
|
options={Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="ownerUserId" label="关联用户 ID">
|
<Form.Item
|
||||||
<Input placeholder="留空表示解除关联" />
|
name="channelOwnerPartnerIds"
|
||||||
|
label="渠道负责人"
|
||||||
|
extra="可空、可多选。被指定的主合伙人可在 H5 查看扫码/归因/订单数量。"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="选择主合伙人"
|
||||||
|
options={partnerSelectOptions}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="assocPartnerAccountId"
|
||||||
|
label="关联合伙人"
|
||||||
|
extra="可空。扫该码且尚未关联的用户将绑定该合伙人;已关联他人不换绑,不回刷历史。"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
placeholder="选择主合伙人"
|
||||||
|
options={partnerSelectOptions}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="remark" label="备注">
|
<Form.Item name="remark" label="备注">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite';
|
|||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const apiTarget = process.env.VITE_API_TARGET ?? 'http://localhost:3010';
|
const apiTarget = process.env.VITE_API_TARGET ?? 'http://127.0.0.1:3010';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/h5-partner",
|
"name": "@dukang/h5-partner",
|
||||||
"version": "4.0.19",
|
"version": "4.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import UsersManagePage from './pages/UsersManagePage';
|
|||||||
import AssocOrdersPage from './pages/AssocOrdersPage';
|
import AssocOrdersPage from './pages/AssocOrdersPage';
|
||||||
import CommissionOrdersPage from './pages/CommissionOrdersPage';
|
import CommissionOrdersPage from './pages/CommissionOrdersPage';
|
||||||
import ActivityPostersPage from './pages/ActivityPostersPage';
|
import ActivityPostersPage from './pages/ActivityPostersPage';
|
||||||
|
import PromoCodesPage from './pages/PromoCodesPage';
|
||||||
import BankAccountPage from './pages/BankAccountPage';
|
import BankAccountPage from './pages/BankAccountPage';
|
||||||
|
|
||||||
function PrimaryRoutes() {
|
function PrimaryRoutes() {
|
||||||
@@ -45,6 +46,7 @@ function PrimaryRoutes() {
|
|||||||
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
||||||
<Route path="/center/bank" element={<BankAccountPage />} />
|
<Route path="/center/bank" element={<BankAccountPage />} />
|
||||||
<Route path="/center/activity-posters" element={<ActivityPostersPage />} />
|
<Route path="/center/activity-posters" element={<ActivityPostersPage />} />
|
||||||
|
<Route path="/center/promo-codes" element={<PromoCodesPage />} />
|
||||||
<Route path="/center/assoc" element={<Navigate to="/users" replace />} />
|
<Route path="/center/assoc" element={<Navigate to="/users" replace />} />
|
||||||
<Route path="/center/assoc/users" element={<Navigate to="/users" replace />} />
|
<Route path="/center/assoc/users" element={<Navigate to="/users" replace />} />
|
||||||
<Route path="/users/orders" element={<AssocOrdersPage />} />
|
<Route path="/users/orders" element={<AssocOrdersPage />} />
|
||||||
|
|||||||
@@ -258,6 +258,15 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
|||||||
</div>
|
</div>
|
||||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link to="/center/promo-codes" className="partner-menu-item">
|
||||||
|
<div className="partner-menu-item-left">
|
||||||
|
<div className="partner-menu-icon">
|
||||||
|
<span className="material-symbols-outlined">qr_code_2</span>
|
||||||
|
</div>
|
||||||
|
<span className="body-md" style={{ fontSize: 16 }}>推广码数据</span>
|
||||||
|
</div>
|
||||||
|
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||||
|
</Link>
|
||||||
<Link to="/users" className="partner-menu-item">
|
<Link to="/users" className="partner-menu-item">
|
||||||
<div className="partner-menu-item-left">
|
<div className="partner-menu-item-left">
|
||||||
<div className="partner-menu-icon">
|
<div className="partner-menu-icon">
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||||
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import { PROMO_CODE_STATUS_LABELS, type PartnerPromoCodeItem } from '@dukang/shared-types';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { toastError } from '../lib/toast';
|
||||||
|
import { usePartnerPageView } from '../lib/usePageView';
|
||||||
|
|
||||||
|
type ListRes = { items: PartnerPromoCodeItem[] };
|
||||||
|
|
||||||
|
export default function PromoCodesPage() {
|
||||||
|
usePartnerPageView('partner_promo_codes_view');
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [items, setItems] = useState<PartnerPromoCodeItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await request<ListRes>('PARTNER_H5', '/partner/promo-codes');
|
||||||
|
setItems(res.items ?? []);
|
||||||
|
} catch (e) {
|
||||||
|
toastError(e instanceof Error ? e.message : '加载失败');
|
||||||
|
setItems([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = '推广码数据';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PullToRefresh onRefresh={load}>
|
||||||
|
<PageHeader title="推广码数据" onBack={() => navigate(-1)} />
|
||||||
|
<div style={{ padding: '0 20px 24px' }}>
|
||||||
|
<p className="label-md text-muted" style={{ margin: '12px 0' }}>
|
||||||
|
{loading ? '加载中…' : `仅展示扫码人数、归因人数、订单数量`}
|
||||||
|
</p>
|
||||||
|
{!loading && items.length === 0 && <div className="empty">暂无负责的推广码</div>}
|
||||||
|
{items.map((row) => (
|
||||||
|
<div key={row.id} className="partner-store-card" style={{ marginBottom: 12 }}>
|
||||||
|
<div className="partner-store-card-header">
|
||||||
|
<div>
|
||||||
|
<p className="body-md">{row.name}</p>
|
||||||
|
<p className="label-md text-muted">
|
||||||
|
{row.code}
|
||||||
|
{row.status !== 'ACTIVE' ? ` · ${PROMO_CODE_STATUS_LABELS[row.status] || row.status}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 16, marginTop: 8 }}>
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted">扫码人数</p>
|
||||||
|
<p className="body-md">{row.scanCount}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted">归因人数</p>
|
||||||
|
<p className="body-md">{row.attributionCount}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="label-md text-muted">订单数量</p>
|
||||||
|
<p className="body-md">{row.orderCount}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PullToRefresh>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ export default defineConfig({
|
|||||||
host: true,
|
host: true,
|
||||||
port: 5175,
|
port: 5175,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010',
|
'/api': process.env.VITE_API_TARGET ?? 'http://127.0.0.1:3010',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/h5-shop",
|
"name": "@dukang/h5-shop",
|
||||||
"version": "4.0.19",
|
"version": "4.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
port: 5174,
|
port: 5174,
|
||||||
proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://localhost:3010' },
|
proxy: { '/api': process.env.VITE_API_TARGET ?? 'http://127.0.0.1:3010' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/mini-user",
|
"name": "@dukang/mini-user",
|
||||||
"version": "4.0.19",
|
"version": "4.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { fetchClientConfig } from './pay-wechat';
|
|||||||
|
|
||||||
/** 与 package.json version 同步(Taro defineConstants 注入),供 minClientVersion 比对 */
|
/** 与 package.json version 同步(Taro defineConstants 注入),供 minClientVersion 比对 */
|
||||||
export const APP_VERSION =
|
export const APP_VERSION =
|
||||||
(typeof TARO_APP_VERSION !== 'undefined' && String(TARO_APP_VERSION).trim()) || '4.0.19';
|
(typeof TARO_APP_VERSION !== 'undefined' && String(TARO_APP_VERSION).trim()) || '4.0.20';
|
||||||
|
|
||||||
export const APP_VERSION_LABEL = `v${APP_VERSION.replace(/^v/i, '')}`;
|
export const APP_VERSION_LABEL = `v${APP_VERSION.replace(/^v/i, '')}`;
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -1,7 +1,7 @@
|
|||||||
# 杜康好客 · V3 编码手册(交付业务版)
|
# 杜康好客 · V3 编码手册(交付业务版)
|
||||||
|
|
||||||
> **事实源**:[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · **审计**:[`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
|
> **事实源**:[`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md) · **审计**:[`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md)
|
||||||
> **佣金归属 / 关联码 / 合伙人账单明细(v4.0.1)· 活动图(v4.0.2)· 周结算与预付款(v4.0.9)· HQ 概览(v4.0.14 / v4.0.15)**:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。
|
> **佣金归属 / 关联码 / 合伙人账单明细(v4.0.1)· 活动图(v4.0.2)· 周结算与预付款(v4.0.9)· HQ 概览(v4.0.14 / v4.0.15)· 推广码渠道负责人/关联合伙人(v4.0.20)**:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md),冲突时 **V4 > V3**。
|
||||||
> V2/preV1 **非需求依据**。总部交付 = **`apps/admin-web`**(非 H5)。
|
> V2/preV1 **非需求依据**。总部交付 = **`apps/admin-web`**(非 H5)。
|
||||||
|
|
||||||
## 1. 交付目标(六条)
|
## 1. 交付目标(六条)
|
||||||
@@ -68,6 +68,8 @@ C 端购酒核销 · 门店扫码核销+打款 · 合伙人拓店履约 · WebAd
|
|||||||
|
|
||||||
**合伙人关联与订单佣金(v4.0.1 / v4.0.9)**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `POST /user/partner-assoc/touch`(未登录可计已扫码)· `GET /partner/assoc`(`scanCount` + `userCount`;子账号无 `activityPosterId`)· `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`;主账号与子账号均可)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。子账号创建默认 `ACTIVE`。主账号 `PUT /partner/me/bank` 填收款账户。HQ `GET /admin/partners/:id/assoc/qrcode` 下载裸关联码 PNG(与「下载活动图」合成海报分开)。
|
**合伙人关联与订单佣金(v4.0.1 / v4.0.9)**:规则见 v4-PRD。`user_user.assoc_partner_account_id` 首次扫码锁定;`user_order.partner_account_id_at_pay` 仅关联或代下单显式选择写入(禁止区县解析)。`partner_bill_item` 分酒单 / 核销两段。合伙人备注独立表 `partner_user_note`(勿写 `hq_remark`)。`POST /user/partner-assoc/bind` · `POST /user/partner-assoc/touch`(未登录可计已扫码)· `GET /partner/assoc`(`scanCount` + `userCount`;子账号无 `activityPosterId`)· `GET /partner/assoc/stats`(关联用户 / 当前关联用户已付购酒单,本日/本月)· `GET /partner/assoc/users?keyword&sort`(合伙人侧返回 `partnerRemark`,不返回 `hqRemark`;主账号与子账号均可)· `GET /partner/assoc/users/:userId/orders` · `GET /partner/assoc/orders` · `PUT /partner/assoc/users/:userId/remark` · HQ `GET /admin/users` 支持 `keyword`、`assocPartnerAccountId`(`none` / `any` / 主账号 ID)· `GET /admin/orders` 支持 `assocPartnerAccountId`(筛本单快照,`none`=无快照)· `PUT /admin/users/:id/assoc`(权限 `users_partner_assoc`)改绑/解绑 · 开城合伙人关联用户快链 `/users?assocPartnerAccountId=` · `PUT /admin/partners/:id` 改费率用 `Decimal(toFixed(4))`。子账号创建默认 `ACTIVE`。主账号 `PUT /partner/me/bank` 填收款账户。HQ `GET /admin/partners/:id/assoc/qrcode` 下载裸关联码 PNG(与「下载活动图」合成海报分开)。
|
||||||
|
|
||||||
|
**推广码渠道负责人 / 关联合伙人(v4.0.20)**:规则见 v4-PRD §2.1 与 [`v4.0.20 开发文档`](./杜康好客-v4.0.20-开发文档.md)。表 `promo_code_channel_owner`(多对多主合伙人)+ `common_promo_code.assoc_partner_account_id`。HQ `POST/PUT /admin/promo-codes` 字段 `channelOwnerPartnerIds`、`assocPartnerAccountId`(须主账号 ACTIVE)。`POST /promo/touch` 登录后对关联合伙人 `PartnerCityService.tryBindIfUnbound`(已绑他人静默跳过,不回刷历史;Promo 不 import StoreModule)。合伙人主账号 `GET /partner/promo-codes` 仅返回 `scanCount` / `attributionCount` / `orderCount`(已完成订单),禁止用户/事件/订单明细。H5 入口:合伙人中心 → 运营管理 → 推广码数据。验收:只配渠道负责人不绑用户;只配关联合伙人会进「关联用户」且能看三项汇总;已关联他人扫码流程不中断。
|
||||||
|
|
||||||
**合伙人周结算(v4.0.9)**:每周一 08:00 生成上一自然周账单。`GET /partner/settlement/cycle` 账期与出账日;`GET /partner/settlement/preview` 本周一至今预付款预估。零元账单 HQ 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
|
**合伙人周结算(v4.0.9)**:每周一 08:00 生成上一自然周账单。`GET /partner/settlement/cycle` 账期与出账日;`GET /partner/settlement/preview` 本周一至今预付款预估。零元账单 HQ 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
|
||||||
|
|
||||||
## 5. 验收用例(必过)
|
## 5. 验收用例(必过)
|
||||||
|
|||||||
+15
-5
@@ -1,9 +1,9 @@
|
|||||||
# 杜康好客 · V4 PRD
|
# 杜康好客 · V4 PRD
|
||||||
|
|
||||||
> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理;**v4.0.14** HQ 概览粒度;**v4.0.15** HQ 概览折线图;**v4.0.18** 子账号继承码、财务全部银行账户目录
|
> **v4.0**(2026-08-29)· 关联码与分佣事实源;**v4.0.6** 酒厂对账;**v4.0.7** HQ 活动图快链与勾选导出;**v4.0.9** 合伙人 H5 周结算与用户管理;**v4.0.14** HQ 概览粒度;**v4.0.15** HQ 概览折线图;**v4.0.18** 子账号继承码、财务全部银行账户目录;**v4.0.20** 推广码渠道负责人/关联合伙人
|
||||||
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户)。
|
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、推广码渠道负责人/关联合伙人、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户)。
|
||||||
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户)。
|
> 未改规则仍见 [`杜康好客-v3-PRD.md`](./杜康好客-v3-PRD.md)。**冲突时 V4 > V3**(本主题:订单佣金归属、关联码、推广码渠道负责人/关联合伙人、合伙人账单明细、活动图、酒厂对账、合伙人周结算、HQ 概览、财务银行账户)。
|
||||||
> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · [`v4.0.7 开发文档`](./杜康好客-v4.0.7-开发文档.md) · [`v4.0.9 开发文档`](./杜康好客-v4.0.9-开发文档.md) · [`v4.0.14 开发文档`](./杜康好客-v4.0.14-开发文档.md) · [`v4.0.15 开发文档`](./杜康好客-v4.0.15-开发文档.md) · [`v4.0.18 开发文档`](./杜康好客-v4.0.18-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
|
> 实现:[`v4.0.1 开发文档`](./杜康好客-v4.0.1-开发文档.md) · [`v4.0.2 开发文档`](./杜康好客-v4.0.2-开发文档.md) · [`v4.0.6 开发文档`](./杜康好客-v4.0.6-开发文档.md) · [`v4.0.7 开发文档`](./杜康好客-v4.0.7-开发文档.md) · [`v4.0.9 开发文档`](./杜康好客-v4.0.9-开发文档.md) · [`v4.0.14 开发文档`](./杜康好客-v4.0.14-开发文档.md) · [`v4.0.15 开发文档`](./杜康好客-v4.0.15-开发文档.md) · [`v4.0.18 开发文档`](./杜康好客-v4.0.18-开发文档.md) · [`v4.0.20 开发文档`](./杜康好客-v4.0.20-开发文档.md) · 审计:[`v4-现状对照`](./杜康好客-v4-现状对照.md)
|
||||||
|
|
||||||
## 0. 版本
|
## 0. 版本
|
||||||
|
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
| 4.0.14 | 09-02 | HQ 概览:日/周/月/季/年、环比、全局城市/时间 + 五板块筛;订单/核销笔数与金额 | [`v4.0.14`](./杜康好客-v4.0.14-开发文档.md) |
|
| 4.0.14 | 09-02 | HQ 概览:日/周/月/季/年、环比、全局城市/时间 + 五板块筛;订单/核销笔数与金额 | [`v4.0.14`](./杜康好客-v4.0.14-开发文档.md) |
|
||||||
| 4.0.15 | 09-02 | HQ 概览改为全宽折线图:粒度分桶、总量/增量、维度线条;查看快链只带全局筛选 | [`v4.0.15`](./杜康好客-v4.0.15-开发文档.md) |
|
| 4.0.15 | 09-02 | HQ 概览改为全宽折线图:粒度分桶、总量/增量、维度线条;查看快链只带全局筛选 | [`v4.0.15`](./杜康好客-v4.0.15-开发文档.md) |
|
||||||
| 4.0.18 | 09-07 / 09-08 | C 端门店列表省+市+区+详细地址(原样拼接、不去重);子账号独立继承二维码 + 子账号维度统计;HQ 财务全部银行账户(门店行读结算资质);撤销门店多收款账户 | [`v4.0.18`](./杜康好客-v4.0.18-开发文档.md) |
|
| 4.0.18 | 09-07 / 09-08 | C 端门店列表省+市+区+详细地址(原样拼接、不去重);子账号独立继承二维码 + 子账号维度统计;HQ 财务全部银行账户(门店行读结算资质);撤销门店多收款账户 | [`v4.0.18`](./杜康好客-v4.0.18-开发文档.md) |
|
||||||
|
| 4.0.20 | 09-16 / 09-17 | 推广码渠道负责人多选主合伙人(H5 只看三项汇总);关联合伙人扫码 first-lock,不回刷、已关联他人静默跳过 | [`v4.0.20`](./杜康好客-v4.0.20-开发文档.md) |
|
||||||
|
|
||||||
## 1. 锚点(沿用 V3,佣金归属改写)
|
## 1. 锚点(沿用 V3,佣金归属改写)
|
||||||
|
|
||||||
@@ -43,6 +44,15 @@
|
|||||||
- 主账号可在合伙人中心填写收款账户:收款人、银行账号、开户行名称(写入主账号 `bank_account_*`)。
|
- 主账号可在合伙人中心填写收款账户:收款人、银行账号、开户行名称(写入主账号 `bank_account_*`)。
|
||||||
- HQ:用户列表综合搜索 + 关联合伙人筛选(`none` 未关联 / `any` 已关联全部 / 指定主合伙人),筛选默认展开;订单列表按本单佣金快照筛关联合伙人;开城城市合伙人提供「关联用户」快链与「全部关联用户」快链,进入用户列表并带上筛选。
|
- HQ:用户列表综合搜索 + 关联合伙人筛选(`none` 未关联 / `any` 已关联全部 / 指定主合伙人),筛选默认展开;订单列表按本单佣金快照筛关联合伙人;开城城市合伙人提供「关联用户」快链与「全部关联用户」快链,进入用户列表并带上筛选。
|
||||||
|
|
||||||
|
### 2.1 推广码 × 合伙人(v4.0.20)
|
||||||
|
|
||||||
|
推广码仍是独立小程序码(数字 scene),**不复用**关联码 `pa_` / `sa_`。
|
||||||
|
|
||||||
|
- **渠道负责人**(可空、多选主合伙人):HQ 创建/编辑推广码可指定。被指定的**主账号**可在合伙人 H5「推广码数据」查看该码的**扫码人数、归因人数、订单数量**;不得查看用户明细、订单列表、核销或手机号等。
|
||||||
|
- **关联合伙人**(可空、单选主合伙人):登录用户扫该码且尚未关联任何人时,**first-lock** 到该主合伙人(写入 `assoc_partner_account_id` + `assocBoundAt`,不写子账号)。已关联他人不换绑、不报错。只绑以后扫进来的用户,**不回刷**历史归因用户。不增加关联码 `assoc_scan_count`。用户来源仍按推广码规则(ORGANIC 才标 `PROMO_CODE`)。
|
||||||
|
- 两字段独立:只配渠道负责人不会绑用户;只配关联合伙人也会把用户写入该合伙人「关联用户」,并允许该合伙人看上述三项汇总。
|
||||||
|
- 子账号不展示推广码数据入口。HQ 原 `ownerUserId`(C 端用户)不再编辑。
|
||||||
|
|
||||||
## 3. 订单佣金
|
## 3. 订单佣金
|
||||||
|
|
||||||
支付快照字段:`user_order.partner_account_id_at_pay`、`order_commission_rate_at_pay`。
|
支付快照字段:`user_order.partner_account_id_at_pay`、`order_commission_rate_at_pay`。
|
||||||
@@ -125,4 +135,4 @@ HQ「财务 → 全部银行账户」聚合**有效**银行账户,供财务查
|
|||||||
|
|
||||||
## 10. 不做
|
## 10. 不做
|
||||||
|
|
||||||
改推广码体系;改核销归属;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
|
不把推广码改成关联码、不回刷历史绑定;改核销归属;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
> 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md)
|
> 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md)
|
||||||
> V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。
|
> V3 进度仍见 [`杜康好客-v3-现状对照.md`](./杜康好客-v3-现状对照.md),不混表。
|
||||||
|
|
||||||
## 0. 总览(2026-09-07)
|
## 0. 总览(2026-09-17)
|
||||||
|
|
||||||
| 维度 | 结论 |
|
| 维度 | 结论 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 版本线 | **v4.0.18** 子账号继承二维码 + HQ 财务全部银行账户(门店行读结算资质;含 v4.0.15 HQ 概览折线图) |
|
| 版本线 | **v4.0.20** 推广码渠道负责人 + 关联合伙人(含 v4.0.18 子账号继承码 / 财务全部银行账户) |
|
||||||
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
| 订单佣金 | 区县归属已删除;只认关联 / 代下单选择 |
|
||||||
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
| 账单 | 酒订单 / 核销订单分列;合伙人改为周账(周一 08:00);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
||||||
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
| 4.0.14 | [`HQ 概览粒度与环比`](./杜康好客-v4.0.14-开发文档.md) | ✅ 已实现 |
|
| 4.0.14 | [`HQ 概览粒度与环比`](./杜康好客-v4.0.14-开发文档.md) | ✅ 已实现 |
|
||||||
| 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 |
|
| 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.md) | ✅ 已实现 |
|
||||||
| 4.0.18 | [`子账号继承二维码 + 财务全部银行账户(结算资质)`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 |
|
| 4.0.18 | [`子账号继承二维码 + 财务全部银行账户(结算资质)`](./杜康好客-v4.0.18-开发文档.md) | ✅ 已实现 |
|
||||||
|
| 4.0.20 | [`推广码渠道负责人 + 关联合伙人`](./杜康好客-v4.0.20-开发文档.md) | ✅ 已实现 |
|
||||||
|
|
||||||
| 日期 | 说明 |
|
| 日期 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
@@ -42,4 +43,5 @@
|
|||||||
| 2026-09-08 | v4.0.18 修订:C 端门店地址「省+市+区+详细地址」原样拼接,详细地址已含省市区时不去重 |
|
| 2026-09-08 | v4.0.18 修订:C 端门店地址「省+市+区+详细地址」原样拼接,详细地址已含省市区时不去重 |
|
||||||
| 2026-09-08 | v4.0.18 追加:HQ 财务全部银行账户(聚合门店结算资质/酒厂/合伙人/物流有效账户;可新增不挂门店账户;筛选与 Excel/PDF 导出) |
|
| 2026-09-08 | v4.0.18 追加:HQ 财务全部银行账户(聚合门店结算资质/酒厂/合伙人/物流有效账户;可新增不挂门店账户;筛选与 Excel/PDF 导出) |
|
||||||
| 2026-09-08 | v4.0.18 再修订:撤销门店多收款账户;打款与财务门店行改读结算资质(结算户名/银行账号/开户银行) |
|
| 2026-09-08 | v4.0.18 再修订:撤销门店多收款账户;打款与财务门店行改读结算资质(结算户名/银行账号/开户银行) |
|
||||||
| 2026-09-08 | HQ 合伙人详情关联码可下载裸二维码(与「下载活动图」分开);C 端门店核销次数由系统设置开关控制 |
|
| 2026-09-16 | v4.0.20:推广码渠道负责人改为可多选主合伙人(H5 只看扫码/归因/订单数);关联合伙人扫码 first-lock,不回刷、已关联他人静默跳过 |
|
||||||
|
| 2026-09-17 | v4.0.20:PromoModule 改走 CityScope 避免 Nest 循环依赖;本地 Vite 代理默认 `127.0.0.1:3010` |
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# 杜康好客 · v4.0.20 开发文档
|
||||||
|
|
||||||
|
> **2026-09-16 / 09-17** · promo / city-scope / store / admin-web / h5-partner / shared-types
|
||||||
|
> **主题**:推广码渠道负责人(多选主合伙人)+ 关联合伙人(扫码 first-lock)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 版本目标
|
||||||
|
|
||||||
|
| # | 任务 | 类型 | 交付 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | 渠道负责人 | 需求 | HQ 创建/编辑推广码可多选主合伙人;被指定主账号在合伙人 H5 只看扫码人数、归因人数、订单数量 |
|
||||||
|
| 2 | 关联合伙人 | 需求 | HQ 可单选绑定主合伙人;登录用户扫该码且尚未关联任何人时 first-lock;已关联他人静默跳过;不回刷历史 |
|
||||||
|
|
||||||
|
**不做**:把推广码改成关联码(仍用数字 scene,不复用 `pa_` / `sa_`);回刷历史归因用户;已关联他人换绑;合伙人 H5 开放用户/订单/核销/手机号明细;历史 `ownerUserId`(C 端用户)迁成合伙人;子账号推广码数据入口。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 规则
|
||||||
|
|
||||||
|
规则事实源:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.md) §2.1。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
scan[C端扫推广码] --> touch["POST /promo/touch"]
|
||||||
|
touch --> scanInc[scanCount++ 未登录也计]
|
||||||
|
touch --> login{已登录?}
|
||||||
|
login -->|否| done[结束]
|
||||||
|
login -->|是| attr[首次归因 UserPromoAttribution]
|
||||||
|
attr --> src[ORGANIC 才标 PROMO_CODE 来源]
|
||||||
|
src --> hasAssoc{码上有关联合伙人?}
|
||||||
|
hasAssoc -->|否| done
|
||||||
|
hasAssoc -->|是| bound{用户已有 assoc?}
|
||||||
|
bound -->|无| lock["PartnerCityService.tryBindIfUnbound"]
|
||||||
|
bound -->|已是同一人| skipSame[noop]
|
||||||
|
bound -->|已是他人| skipOther[静默跳过]
|
||||||
|
lock --> users[合伙人H5 关联用户可见]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **渠道负责人**:可空、多选 **ACTIVE 主合伙人**(`isPrimary=1`)。只授权看三项汇总,不绑用户。
|
||||||
|
- **关联合伙人**:可空、单选 ACTIVE 主合伙人。登录 touch 后 `tryBindIfUnbound`:无关联则写 `assoc_partner_account_id` + `assoc_bound_at`(不写 `assoc_sub_account_id`、不改 `sourceType`、不增加关联码 `assoc_scan_count`);已是同一人 noop;已是他人不抛错。只绑以后扫进来的用户。
|
||||||
|
- 两字段独立:只配渠道负责人 ≠ 绑用户;只配关联合伙人也会把用户写入该合伙人「关联用户」,并允许看三项汇总。
|
||||||
|
- HQ 原 `ownerUserId` 列保留、创建/编辑不再暴露。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. API
|
||||||
|
|
||||||
|
### 3.1 HQ(promo 模块)
|
||||||
|
|
||||||
|
`POST /admin/promo-codes`、`PUT /admin/promo-codes/:id` 增加:
|
||||||
|
|
||||||
|
- `channelOwnerPartnerIds: string[]`(可空)
|
||||||
|
- `assocPartnerAccountId: string | null`(可空;空串/null 解绑)
|
||||||
|
|
||||||
|
列表/详情返回 `channelOwners[]`、`assocPartner`(`id` / `companyName` / `name` / `phone`)。列表筛:`channelOwnerPartnerId`、`assocPartnerAccountId`。
|
||||||
|
|
||||||
|
### 3.2 C 端扫码
|
||||||
|
|
||||||
|
`POST /promo/touch`:登录后、归因与来源标记之后,若码上有 `assocPartnerAccountId`,调用 `PartnerCityService.tryBindIfUnbound`(**不** import `StoreModule`,避免 Nest 循环依赖)。
|
||||||
|
|
||||||
|
### 3.3 合伙人 H5(仅主账号,`PartnerPrimaryGuard`)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 返回 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/partner/promo-codes` | `{ items: [{ id, name, code, status, scanCount, attributionCount, orderCount }] }` |
|
||||||
|
|
||||||
|
可见范围:当前主账号是渠道负责人 **或** 关联合伙人。订单数 = `status=COMPLETED`。禁止 HQ 的 users / metrics / timeline / 订单快链。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 变更面
|
||||||
|
|
||||||
|
| 层 | 路径 |
|
||||||
|
|----|------|
|
||||||
|
| Prisma | `CommonPromoCode.assocPartnerAccountId`;`PromoCodeChannelOwner`(`promo_code_channel_owner`) |
|
||||||
|
| 迁移 | `server/dukang-api/prisma/migrate-promo-partner-fields.sql`(**Review 后生产执行**) |
|
||||||
|
| shared-types | `promo.ts`:`PromoCodePartnerBrief`、`PromoCodeItem.channelOwners/assocPartner`、`PartnerPromoCodeItem` |
|
||||||
|
| API promo | `promo-code.service.ts`、`dto/promo-code.dto.ts`、`partner-promo-code.controller.ts`;`PromoModule` import `CityScopeModule` |
|
||||||
|
| API city-scope | `PartnerCityService.tryBindIfUnbound` |
|
||||||
|
| API store | `PartnerAssocService.tryBindIfUnbound` 转调 city-scope |
|
||||||
|
| admin-web | `PromoCodesPage.tsx`、`promo/PromoCodeDetailPage.tsx`:渠道负责人多选 / 关联合伙人单选;去掉关联用户 ID |
|
||||||
|
| h5-partner | `CenterPage.tsx` 运营管理「推广码数据」;`/center/promo-codes`;`PromoCodesPage.tsx` |
|
||||||
|
| 本地代理 | admin/partner/shop Vite 默认 `VITE_API_TARGET` → `http://127.0.0.1:3010`(Windows 上 `localhost` 可能打到占用 `::3010` 的其他进程) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验收
|
||||||
|
|
||||||
|
- [ ] HQ 创建/编辑推广码可多选渠道负责人、可清空单选关联合伙人;列表/详情展示公司名;可按两字段筛选
|
||||||
|
- [ ] 只配渠道负责人:扫码不写用户关联;该主账号 H5「推广码数据」能看到三项数字,点不开用户/订单/核销
|
||||||
|
- [ ] 只配关联合伙人:未关联的登录用户扫码后出现在该合伙人「关联用户」;该主账号也能看三项汇总
|
||||||
|
- [ ] 用户已关联他人:扫带关联合伙人的码不换绑、流程不中断、不报「无法更换」
|
||||||
|
- [ ] 为已有码补关联合伙人:**不**回刷历史归因用户
|
||||||
|
- [ ] 子账号中心无「推广码数据」入口
|
||||||
|
- [ ] 关联码 `pa_` / `sa_` 行为不变;已付佣金快照不回刷
|
||||||
|
- [ ] 执行迁移后 API 可启动(`PromoModule` 不得再 import `StoreModule`)
|
||||||
|
- [ ] shared-types 构建通过;相关 lint 过
|
||||||
+1
-1
@@ -103,7 +103,7 @@ HQ 创建商品:名称/价格/权益额/箱规/香型/城市上架;详情模
|
|||||||
|
|
||||||
## 7. 活动 / 推广码
|
## 7. 活动 / 推广码
|
||||||
|
|
||||||
HQ 推广码:场景/合伙人绑定/上下线;touch 归因;metrics 四指标+事件日志(v3.4.13)。
|
HQ 推广码:场景/渠道负责人(主合伙人多选)/关联合伙人/上下线;touch 归因 + 关联合伙人 first-lock;metrics 四指标+事件日志(v3.4.13)。
|
||||||
|
|
||||||
## 8. 开城
|
## 8. 开城
|
||||||
|
|
||||||
|
|||||||
Generated
+7876
-6
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
|||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.17.0",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"globals": "^15.14.0",
|
"globals": "^15.14.0",
|
||||||
|
"prisma": "^8.0.0-rc.15",
|
||||||
"typescript-eslint": "^8.18.0"
|
"typescript-eslint": "^8.18.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ export type PromoCodeOwnerUser = {
|
|||||||
phone?: string | null;
|
phone?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PromoCodePartnerBrief = {
|
||||||
|
id: string;
|
||||||
|
companyName?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type PromoCodeItem = {
|
export type PromoCodeItem = {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -40,11 +47,27 @@ export type PromoCodeItem = {
|
|||||||
landingUrl: string;
|
landingUrl: string;
|
||||||
qrcodeUrl?: string | null;
|
qrcodeUrl?: string | null;
|
||||||
remark?: string | null;
|
remark?: string | null;
|
||||||
|
/** @deprecated HQ 不再编辑;历史 C 端用户渠道负责人 */
|
||||||
ownerUser?: PromoCodeOwnerUser | null;
|
ownerUser?: PromoCodeOwnerUser | null;
|
||||||
|
/** 渠道负责人:主合伙人,可多选 */
|
||||||
|
channelOwners?: PromoCodePartnerBrief[];
|
||||||
|
/** 关联合伙人:扫码 first-lock 绑定 */
|
||||||
|
assocPartner?: PromoCodePartnerBrief | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 合伙人 H5:仅三项汇总,不含用户/订单明细 */
|
||||||
|
export type PartnerPromoCodeItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
status: PromoCodeStatus;
|
||||||
|
scanCount: number;
|
||||||
|
attributionCount: number;
|
||||||
|
orderCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type PromoCodeStats = {
|
export type PromoCodeStats = {
|
||||||
/** 扫码进入次数 */
|
/** 扫码进入次数 */
|
||||||
scanCount: number;
|
scanCount: number;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/shared-ui",
|
"name": "@dukang/shared-ui",
|
||||||
"version": "4.0.19",
|
"version": "4.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
Generated
+3555
-28
File diff suppressed because it is too large
Load Diff
@@ -17,3 +17,4 @@ allowBuilds:
|
|||||||
msgpackr-extract: true
|
msgpackr-extract: true
|
||||||
prisma: true
|
prisma: true
|
||||||
sharp: true
|
sharp: true
|
||||||
|
workerd: true
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dukang/api",
|
"name": "@dukang/api",
|
||||||
"version": "4.0.19",
|
"version": "4.0.20",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"predev": "pnpm --dir ../../packages/domain build",
|
"predev": "pnpm --dir ../../packages/domain build",
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- v4.0.20:推广码关联合伙人 + 渠道负责人(主合伙人多选)
|
||||||
|
-- Review 后再在生产执行。
|
||||||
|
|
||||||
|
ALTER TABLE `common_promo_code`
|
||||||
|
ADD COLUMN `assoc_partner_account_id` BIGINT UNSIGNED NULL AFTER `owner_user_id`,
|
||||||
|
ADD KEY `idx_common_promo_code_assoc_partner` (`assoc_partner_account_id`),
|
||||||
|
ADD CONSTRAINT `fk_common_promo_code_assoc_partner`
|
||||||
|
FOREIGN KEY (`assoc_partner_account_id`) REFERENCES `partner_account`(`id`) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `promo_code_channel_owner` (
|
||||||
|
`promo_code_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`partner_account_id` BIGINT UNSIGNED NOT NULL,
|
||||||
|
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
PRIMARY KEY (`promo_code_id`, `partner_account_id`),
|
||||||
|
KEY `idx_promo_channel_owner_partner` (`partner_account_id`),
|
||||||
|
CONSTRAINT `fk_promo_channel_owner_promo` FOREIGN KEY (`promo_code_id`) REFERENCES `common_promo_code`(`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `fk_promo_channel_owner_partner` FOREIGN KEY (`partner_account_id`) REFERENCES `partner_account`(`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='推广码渠道负责人(主合伙人)';
|
||||||
@@ -1069,31 +1069,49 @@ model StoreCategoryLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model CommonPromoCode {
|
model CommonPromoCode {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
code String @unique @db.VarChar(32)
|
code String @unique @db.VarChar(32)
|
||||||
name String @db.VarChar(128)
|
name String @db.VarChar(128)
|
||||||
scene PromoCodeScene @default(ONLINE_LINK)
|
scene PromoCodeScene @default(ONLINE_LINK)
|
||||||
qrcodeId String @unique @map("qrcode_id") @db.VarChar(64)
|
qrcodeId String @unique @map("qrcode_id") @db.VarChar(64)
|
||||||
status PromoCodeStatus @default(ACTIVE)
|
status PromoCodeStatus @default(ACTIVE)
|
||||||
ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt
|
ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt
|
||||||
remark String? @db.VarChar(256)
|
assocPartnerAccountId BigInt? @map("assoc_partner_account_id") @db.UnsignedBigInt
|
||||||
qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
|
remark String? @db.VarChar(256)
|
||||||
scanCount Int @default(0) @map("scan_count")
|
qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
|
||||||
orderCount Int @default(0) @map("order_count")
|
scanCount Int @default(0) @map("scan_count")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
orderCount Int @default(0) @map("order_count")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||||
|
|
||||||
ownerUser User? @relation("PromoOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull)
|
ownerUser User? @relation("PromoOwnerUser", fields: [ownerUserId], references: [id], onDelete: SetNull)
|
||||||
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
|
assocPartner PartnerAccount? @relation("PromoAssocPartner", fields: [assocPartnerAccountId], references: [id], onDelete: SetNull)
|
||||||
|
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
|
||||||
|
channelOwners PromoCodeChannelOwner[]
|
||||||
attributions UserPromoAttribution[]
|
attributions UserPromoAttribution[]
|
||||||
orders Order[]
|
orders Order[]
|
||||||
metricEvents LogPromoEvent[]
|
metricEvents LogPromoEvent[]
|
||||||
|
|
||||||
@@index([ownerUserId])
|
@@index([ownerUserId])
|
||||||
|
@@index([assocPartnerAccountId])
|
||||||
@@index([scene, status])
|
@@index([scene, status])
|
||||||
@@map("common_promo_code")
|
@@map("common_promo_code")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 推广码渠道负责人(主合伙人,可多选)
|
||||||
|
model PromoCodeChannelOwner {
|
||||||
|
promoCodeId BigInt @map("promo_code_id") @db.UnsignedBigInt
|
||||||
|
partnerAccountId BigInt @map("partner_account_id") @db.UnsignedBigInt
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
|
||||||
|
promoCode CommonPromoCode @relation(fields: [promoCodeId], references: [id], onDelete: Cascade)
|
||||||
|
partnerAccount PartnerAccount @relation("PromoChannelOwner", fields: [partnerAccountId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@id([promoCodeId, partnerAccountId])
|
||||||
|
@@index([partnerAccountId])
|
||||||
|
@@map("promo_code_channel_owner")
|
||||||
|
}
|
||||||
|
|
||||||
model CommonCity {
|
model CommonCity {
|
||||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||||
code String @unique @db.VarChar(16)
|
code String @unique @db.VarChar(16)
|
||||||
@@ -1287,6 +1305,8 @@ model PartnerAccount {
|
|||||||
assocUsers User[] @relation("UserPartnerAssoc")
|
assocUsers User[] @relation("UserPartnerAssoc")
|
||||||
assocSubUsers User[] @relation("UserSubAccountAssoc")
|
assocSubUsers User[] @relation("UserSubAccountAssoc")
|
||||||
userNotes PartnerUserNote[]
|
userNotes PartnerUserNote[]
|
||||||
|
promoAssocCodes CommonPromoCode[] @relation("PromoAssocPartner")
|
||||||
|
promoChannelOwnerLinks PromoCodeChannelOwner[] @relation("PromoChannelOwner")
|
||||||
assocQrcodeResource CommonResource? @relation("PartnerAssocQrcode", fields: [assocQrcodeResourceId], references: [id], onDelete: SetNull)
|
assocQrcodeResource CommonResource? @relation("PartnerAssocQrcode", fields: [assocQrcodeResourceId], references: [id], onDelete: SetNull)
|
||||||
activityPoster ActivityPoster? @relation(fields: [activityPosterId], references: [id], onDelete: SetNull)
|
activityPoster ActivityPoster? @relation(fields: [activityPosterId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
|||||||
group: G.wechat_mini,
|
group: G.wechat_mini,
|
||||||
type: 'string',
|
type: 'string',
|
||||||
requiresRestart: false,
|
requiresRestart: false,
|
||||||
placeholder: '4.0.19',
|
placeholder: '4.0.20',
|
||||||
description: 'semver 格式(可带或不带 v);客户端低于此版本时提示更新',
|
description: 'semver 格式(可带或不带 v);客户端低于此版本时提示更新',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guar
|
|||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [forwardRef(() => IamModule), PromoModule],
|
imports: [forwardRef(() => IamModule), forwardRef(() => PromoModule)],
|
||||||
controllers: [AnalyticsController, PromoController],
|
controllers: [AnalyticsController, PromoController],
|
||||||
providers: [AnalyticsService, OptionalJwtAuthGuard, JwtAuthGuard],
|
providers: [AnalyticsService, OptionalJwtAuthGuard, JwtAuthGuard],
|
||||||
exports: [AnalyticsService],
|
exports: [AnalyticsService],
|
||||||
|
|||||||
@@ -89,6 +89,47 @@ export class PartnerCityService {
|
|||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推广码关联合伙人:未绑定则 first-lock;已绑同一人 noop;已绑他人静默跳过。
|
||||||
|
* 不改 sourceType、不写 assocSubAccountId、不增加关联码扫码计数。
|
||||||
|
*/
|
||||||
|
async tryBindIfUnbound(userId: bigint, partnerAccountId: bigint): Promise<{
|
||||||
|
bound: boolean;
|
||||||
|
alreadyBound: boolean;
|
||||||
|
skipped: boolean;
|
||||||
|
}> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, assocPartnerAccountId: true },
|
||||||
|
});
|
||||||
|
if (!user) {
|
||||||
|
return { bound: false, alreadyBound: false, skipped: true };
|
||||||
|
}
|
||||||
|
if (user.assocPartnerAccountId) {
|
||||||
|
if (user.assocPartnerAccountId === partnerAccountId) {
|
||||||
|
return { bound: true, alreadyBound: true, skipped: false };
|
||||||
|
}
|
||||||
|
return { bound: false, alreadyBound: false, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const primary = await this.prisma.partnerAccount.findUnique({
|
||||||
|
where: { id: partnerAccountId },
|
||||||
|
select: { id: true, isPrimary: true, status: true },
|
||||||
|
});
|
||||||
|
if (!primary || primary.isPrimary !== 1 || primary.status !== 'ACTIVE') {
|
||||||
|
return { bound: false, alreadyBound: false, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
assocPartnerAccountId: primary.id,
|
||||||
|
assocBoundAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { bound: true, alreadyBound: false, skipped: false };
|
||||||
|
}
|
||||||
|
|
||||||
async validatePrimaryBinding(
|
async validatePrimaryBinding(
|
||||||
cityId: bigint,
|
cityId: bigint,
|
||||||
input: {
|
input: {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class HealthController {
|
|||||||
return {
|
return {
|
||||||
status,
|
status,
|
||||||
service: 'dukang-api',
|
service: 'dukang-api',
|
||||||
version: '4.0.19',
|
version: '4.0.20',
|
||||||
checks: { db, redis },
|
checks: { db, redis },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Transform, Type } from 'class-transformer';
|
||||||
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString, MaxLength, ValidateIf } from 'class-validator';
|
||||||
import { PromoCodeScene, PromoCodeStatus } from '@dukang/shared-types';
|
import { PromoCodeScene, PromoCodeStatus } from '@dukang/shared-types';
|
||||||
|
|
||||||
|
function toOptionalNullableString(value: unknown): string | null | undefined {
|
||||||
|
if (value === undefined) return undefined;
|
||||||
|
if (value === null) return null;
|
||||||
|
const t = String(value).trim();
|
||||||
|
return t.length ? t : null;
|
||||||
|
}
|
||||||
|
|
||||||
export class PromoCodeListQueryDto {
|
export class PromoCodeListQueryDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@@ -30,6 +37,14 @@ export class PromoCodeListQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
ownerUserId?: string;
|
ownerUserId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
channelOwnerPartnerId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
assocPartnerAccountId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreatePromoCodeDto {
|
export class CreatePromoCodeDto {
|
||||||
@@ -51,6 +66,17 @@ export class CreatePromoCodeDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
ownerUserId?: string;
|
ownerUserId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
channelOwnerPartnerIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => toOptionalNullableString(value))
|
||||||
|
@ValidateIf((_, v) => v !== null && v !== undefined)
|
||||||
|
@IsString()
|
||||||
|
assocPartnerAccountId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(256)
|
@MaxLength(256)
|
||||||
@@ -77,6 +103,17 @@ export class UpdatePromoCodeDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
ownerUserId?: string | null;
|
ownerUserId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
channelOwnerPartnerIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(({ value }) => toOptionalNullableString(value))
|
||||||
|
@ValidateIf((_, v) => v !== null && v !== undefined)
|
||||||
|
@IsString()
|
||||||
|
assocPartnerAccountId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(256)
|
@MaxLength(256)
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Controller, Get, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||||
|
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||||
|
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||||
|
import { PromoCodeService } from './promo-code.service';
|
||||||
|
|
||||||
|
@Controller('partner/promo-codes')
|
||||||
|
@UseGuards(JwtAuthGuard, PartnerPrimaryGuard)
|
||||||
|
export class PartnerPromoCodeController {
|
||||||
|
constructor(private readonly service: PromoCodeService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.service.listForPartner(user.actorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
PROMO_CODE_SCENE_LABELS,
|
PROMO_CODE_SCENE_LABELS,
|
||||||
PromoCodeScene,
|
PromoCodeScene,
|
||||||
@@ -17,6 +18,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
|||||||
import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||||
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
import type { IOssProvider } from '../../integrations/oss/oss.interface';
|
||||||
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
import type { IWechatProvider } from '../../integrations/wechat/wechat.interface';
|
||||||
|
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||||
import { PromoMetricLogService } from './promo-metric-log.service';
|
import { PromoMetricLogService } from './promo-metric-log.service';
|
||||||
import {
|
import {
|
||||||
computePromoMetricPeak,
|
computePromoMetricPeak,
|
||||||
@@ -41,6 +43,13 @@ type PromoTouchMeta = {
|
|||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type PromoPartnerBrief = {
|
||||||
|
id: bigint;
|
||||||
|
companyName: string | null;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
};
|
||||||
|
|
||||||
type PromoRow = {
|
type PromoRow = {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -59,6 +68,8 @@ type PromoRow = {
|
|||||||
nickname: string | null;
|
nickname: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
|
assocPartner?: PromoPartnerBrief | null;
|
||||||
|
channelOwners?: Array<{ partnerAccount: PromoPartnerBrief }>;
|
||||||
qrcodeResource?: { url: string } | null;
|
qrcodeResource?: { url: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -89,6 +100,7 @@ export class PromoCodeService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly promoMetricLog: PromoMetricLogService,
|
private readonly promoMetricLog: PromoMetricLogService,
|
||||||
|
private readonly partnerCity: PartnerCityService,
|
||||||
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
||||||
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
@Inject(WECHAT_PROVIDER) private readonly wechat: IWechatProvider,
|
||||||
) {}
|
) {}
|
||||||
@@ -151,6 +163,16 @@ export class PromoCodeService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mapPartnerBrief(partner?: PromoPartnerBrief | null) {
|
||||||
|
if (!partner) return null;
|
||||||
|
return serializeBigInt({
|
||||||
|
id: partner.id,
|
||||||
|
companyName: partner.companyName,
|
||||||
|
name: partner.name,
|
||||||
|
phone: partner.phone,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private mapRow(row: PromoRow, orderCount?: number) {
|
private mapRow(row: PromoRow, orderCount?: number) {
|
||||||
return serializeBigInt({
|
return serializeBigInt({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -165,15 +187,30 @@ export class PromoCodeService {
|
|||||||
landingUrl: buildLandingUrl(row.code, row.qrcodeId),
|
landingUrl: buildLandingUrl(row.code, row.qrcodeId),
|
||||||
qrcodeUrl: row.qrcodeResource?.url ?? null,
|
qrcodeUrl: row.qrcodeResource?.url ?? null,
|
||||||
ownerUser: this.mapOwnerUser(row.ownerUser),
|
ownerUser: this.mapOwnerUser(row.ownerUser),
|
||||||
|
channelOwners: (row.channelOwners ?? []).map((link) => this.mapPartnerBrief(link.partnerAccount)),
|
||||||
|
assocPartner: this.mapPartnerBrief(row.assocPartner),
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private partnerSelect = {
|
||||||
|
id: true,
|
||||||
|
companyName: true,
|
||||||
|
name: true,
|
||||||
|
phone: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
private includeRelations = {
|
private includeRelations = {
|
||||||
ownerUser: {
|
ownerUser: {
|
||||||
select: { id: true, userNo: true, nickname: true, phone: true },
|
select: { id: true, userNo: true, nickname: true, phone: true },
|
||||||
},
|
},
|
||||||
|
assocPartner: {
|
||||||
|
select: this.partnerSelect,
|
||||||
|
},
|
||||||
|
channelOwners: {
|
||||||
|
include: { partnerAccount: { select: this.partnerSelect } },
|
||||||
|
},
|
||||||
qrcodeResource: {
|
qrcodeResource: {
|
||||||
select: { url: true },
|
select: { url: true },
|
||||||
},
|
},
|
||||||
@@ -182,13 +219,7 @@ export class PromoCodeService {
|
|||||||
async list(query: PromoCodeListQueryDto) {
|
async list(query: PromoCodeListQueryDto) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
const pageSize = query.pageSize ?? 20;
|
const pageSize = query.pageSize ?? 20;
|
||||||
const where: {
|
const where: Prisma.CommonPromoCodeWhereInput = {};
|
||||||
status?: 'ACTIVE' | 'DISABLED';
|
|
||||||
scene?: PromoCodeScene;
|
|
||||||
name?: { contains: string };
|
|
||||||
code?: { contains: string };
|
|
||||||
ownerUserId?: bigint;
|
|
||||||
} = {};
|
|
||||||
if (query.status) where.status = query.status;
|
if (query.status) where.status = query.status;
|
||||||
if (query.scene) where.scene = query.scene;
|
if (query.scene) where.scene = query.scene;
|
||||||
if (query.name) where.name = { contains: query.name };
|
if (query.name) where.name = { contains: query.name };
|
||||||
@@ -196,6 +227,14 @@ export class PromoCodeService {
|
|||||||
if (query.ownerUserId?.trim()) {
|
if (query.ownerUserId?.trim()) {
|
||||||
where.ownerUserId = BigInt(query.ownerUserId.trim());
|
where.ownerUserId = BigInt(query.ownerUserId.trim());
|
||||||
}
|
}
|
||||||
|
if (query.channelOwnerPartnerId?.trim()) {
|
||||||
|
where.channelOwners = {
|
||||||
|
some: { partnerAccountId: BigInt(query.channelOwnerPartnerId.trim()) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (query.assocPartnerAccountId?.trim()) {
|
||||||
|
where.assocPartnerAccountId = BigInt(query.assocPartnerAccountId.trim());
|
||||||
|
}
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.commonPromoCode.findMany({
|
this.prisma.commonPromoCode.findMany({
|
||||||
@@ -237,6 +276,30 @@ export class PromoCodeService {
|
|||||||
return user.id;
|
return user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async resolvePrimaryPartnerId(partnerId?: string | null) {
|
||||||
|
if (partnerId === undefined) return undefined;
|
||||||
|
if (partnerId === null || !partnerId.trim()) return null;
|
||||||
|
const partner = await this.prisma.partnerAccount.findFirst({
|
||||||
|
where: { id: BigInt(partnerId.trim()), isPrimary: 1, status: 'ACTIVE' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!partner) throw new BadRequestException('关联合伙人必须是有效的主合伙人');
|
||||||
|
return partner.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveChannelOwnerIds(ids?: string[]) {
|
||||||
|
const unique = [...new Set((ids ?? []).map((s) => s.trim()).filter(Boolean))];
|
||||||
|
if (!unique.length) return [] as bigint[];
|
||||||
|
const rows = await this.prisma.partnerAccount.findMany({
|
||||||
|
where: { id: { in: unique.map((id) => BigInt(id)) }, isPrimary: 1, status: 'ACTIVE' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (rows.length !== unique.length) {
|
||||||
|
throw new BadRequestException('渠道负责人必须是有效的主合伙人');
|
||||||
|
}
|
||||||
|
return unique.map((id) => BigInt(id));
|
||||||
|
}
|
||||||
|
|
||||||
private async generateUniqueCode(custom?: string) {
|
private async generateUniqueCode(custom?: string) {
|
||||||
let code = custom?.trim().toUpperCase();
|
let code = custom?.trim().toUpperCase();
|
||||||
if (code) {
|
if (code) {
|
||||||
@@ -309,6 +372,8 @@ export class PromoCodeService {
|
|||||||
const code = await this.generateUniqueCode(dto.code);
|
const code = await this.generateUniqueCode(dto.code);
|
||||||
const qrcodeId = await this.generateUniqueQrcodeId();
|
const qrcodeId = await this.generateUniqueQrcodeId();
|
||||||
const ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
|
const ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
|
||||||
|
const assocPartnerAccountId = await this.resolvePrimaryPartnerId(dto.assocPartnerAccountId);
|
||||||
|
const channelOwnerIds = await this.resolveChannelOwnerIds(dto.channelOwnerPartnerIds);
|
||||||
const scene = (dto.scene ?? 'ONLINE_LINK') as PromoCodeScene;
|
const scene = (dto.scene ?? 'ONLINE_LINK') as PromoCodeScene;
|
||||||
|
|
||||||
const row = await this.prisma.commonPromoCode.create({
|
const row = await this.prisma.commonPromoCode.create({
|
||||||
@@ -319,7 +384,11 @@ export class PromoCodeService {
|
|||||||
qrcodeId,
|
qrcodeId,
|
||||||
status: 'ACTIVE',
|
status: 'ACTIVE',
|
||||||
ownerUserId,
|
ownerUserId,
|
||||||
|
assocPartnerAccountId: assocPartnerAccountId ?? undefined,
|
||||||
remark: dto.remark?.trim() || null,
|
remark: dto.remark?.trim() || null,
|
||||||
|
channelOwners: channelOwnerIds.length
|
||||||
|
? { create: channelOwnerIds.map((partnerAccountId) => ({ partnerAccountId })) }
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -339,22 +408,29 @@ export class PromoCodeService {
|
|||||||
|
|
||||||
async update(id: bigint, dto: UpdatePromoCodeDto) {
|
async update(id: bigint, dto: UpdatePromoCodeDto) {
|
||||||
await this.detail(id);
|
await this.detail(id);
|
||||||
const data: {
|
const data: Prisma.CommonPromoCodeUpdateInput = {};
|
||||||
name?: string;
|
|
||||||
scene?: PromoCodeScene;
|
|
||||||
remark?: string | null;
|
|
||||||
ownerUserId?: bigint | null;
|
|
||||||
} = {};
|
|
||||||
if (dto.name !== undefined) data.name = dto.name.trim();
|
if (dto.name !== undefined) data.name = dto.name.trim();
|
||||||
if (dto.scene !== undefined) data.scene = dto.scene;
|
if (dto.scene !== undefined) data.scene = dto.scene;
|
||||||
if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null;
|
if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null;
|
||||||
if (dto.ownerUserId !== undefined) {
|
if (dto.ownerUserId !== undefined) {
|
||||||
if (dto.ownerUserId === null || dto.ownerUserId === '') {
|
if (dto.ownerUserId === null || dto.ownerUserId === '') {
|
||||||
data.ownerUserId = null;
|
data.ownerUser = { disconnect: true };
|
||||||
} else {
|
} else {
|
||||||
data.ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
|
const ownerUserId = await this.resolveOwnerUserId(dto.ownerUserId);
|
||||||
|
data.ownerUser = { connect: { id: ownerUserId } };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (dto.assocPartnerAccountId !== undefined) {
|
||||||
|
const assocId = await this.resolvePrimaryPartnerId(dto.assocPartnerAccountId);
|
||||||
|
data.assocPartner = assocId ? { connect: { id: assocId } } : { disconnect: true };
|
||||||
|
}
|
||||||
|
if (dto.channelOwnerPartnerIds !== undefined) {
|
||||||
|
const channelOwnerIds = await this.resolveChannelOwnerIds(dto.channelOwnerPartnerIds);
|
||||||
|
data.channelOwners = {
|
||||||
|
deleteMany: {},
|
||||||
|
create: channelOwnerIds.map((partnerAccountId) => ({ partnerAccountId })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const row = await this.prisma.commonPromoCode.update({
|
const row = await this.prisma.commonPromoCode.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -464,6 +540,10 @@ export class PromoCodeService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sourceApplied = await this.applyPromoSourceToUser(userId, promo, meta);
|
sourceApplied = await this.applyPromoSourceToUser(userId, promo, meta);
|
||||||
|
|
||||||
|
if (promo.assocPartnerAccountId) {
|
||||||
|
await this.partnerCity.tryBindIfUnbound(userId, promo.assocPartnerAccountId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (shouldCountScan) {
|
if (shouldCountScan) {
|
||||||
@@ -783,4 +863,43 @@ export class PromoCodeService {
|
|||||||
pageSize,
|
pageSize,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 合伙人 H5:仅扫码/归因/订单三项汇总,不含用户或订单明细 */
|
||||||
|
async listForPartner(partnerAccountId: bigint) {
|
||||||
|
const where: Prisma.CommonPromoCodeWhereInput = {
|
||||||
|
OR: [
|
||||||
|
{ assocPartnerAccountId: partnerAccountId },
|
||||||
|
{ channelOwners: { some: { partnerAccountId } } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const rows = await this.prisma.commonPromoCode.findMany({
|
||||||
|
where,
|
||||||
|
select: { id: true, name: true, code: true, status: true, scanCount: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 100,
|
||||||
|
});
|
||||||
|
const ids = rows.map((r) => r.id);
|
||||||
|
const [attributionRows, orderMap] = await Promise.all([
|
||||||
|
ids.length
|
||||||
|
? this.prisma.userPromoAttribution.groupBy({
|
||||||
|
by: ['promoCodeId'],
|
||||||
|
where: { promoCodeId: { in: ids } },
|
||||||
|
_count: { _all: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve([] as Array<{ promoCodeId: bigint; _count: { _all: number } }>),
|
||||||
|
this.completedOrderCounts(ids),
|
||||||
|
]);
|
||||||
|
const attributionMap = new Map(attributionRows.map((r) => [r.promoCodeId.toString(), r._count._all]));
|
||||||
|
return serializeBigInt({
|
||||||
|
items: rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
code: row.code,
|
||||||
|
status: row.status,
|
||||||
|
scanCount: row.scanCount,
|
||||||
|
attributionCount: attributionMap.get(row.id.toString()) ?? 0,
|
||||||
|
orderCount: orderMap.get(row.id.toString()) ?? 0,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { GeoModule } from '../../common/geo/geo.module';
|
|||||||
import { IntegrationsModule } from '../../integrations/integrations.module';
|
import { IntegrationsModule } from '../../integrations/integrations.module';
|
||||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||||
|
import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
||||||
|
import { CityScopeModule } from '../city-scope/city-scope.module';
|
||||||
import { AdminPromoCodeController } from './admin-promo-code.controller';
|
import { AdminPromoCodeController } from './admin-promo-code.controller';
|
||||||
|
import { PartnerPromoCodeController } from './partner-promo-code.controller';
|
||||||
import { PromoCodeService } from './promo-code.service';
|
import { PromoCodeService } from './promo-code.service';
|
||||||
import { PromoMetricLogService } from './promo-metric-log.service';
|
import { PromoMetricLogService } from './promo-metric-log.service';
|
||||||
|
|
||||||
@@ -12,13 +15,14 @@ import { PromoMetricLogService } from './promo-metric-log.service';
|
|||||||
imports: [
|
imports: [
|
||||||
GeoModule,
|
GeoModule,
|
||||||
IntegrationsModule,
|
IntegrationsModule,
|
||||||
|
CityScopeModule,
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
|
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
|
||||||
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
|
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AdminPromoCodeController],
|
controllers: [AdminPromoCodeController, PartnerPromoCodeController],
|
||||||
providers: [PromoCodeService, PromoMetricLogService, JwtAuthGuard, HqAuthGuard],
|
providers: [PromoCodeService, PromoMetricLogService, JwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
|
||||||
exports: [PromoCodeService],
|
exports: [PromoCodeService],
|
||||||
})
|
})
|
||||||
export class PromoModule {}
|
export class PromoModule {}
|
||||||
|
|||||||
@@ -131,6 +131,10 @@ export class PartnerAssocService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async tryBindIfUnbound(userId: bigint, partnerAccountId: bigint) {
|
||||||
|
return this.partnerCityService.tryBindIfUnbound(userId, partnerAccountId);
|
||||||
|
}
|
||||||
|
|
||||||
async touchScan(input: { scene?: string; partnerId?: string; countScan?: boolean }) {
|
async touchScan(input: { scene?: string; partnerId?: string; countScan?: boolean }) {
|
||||||
const { primary, sub } = await this.resolveAssocTarget(input);
|
const { primary, sub } = await this.resolveAssocTarget(input);
|
||||||
const shouldCountScan = input.countScan !== false;
|
const shouldCountScan = input.countScan !== false;
|
||||||
|
|||||||
Reference in New Issue
Block a user