merge(dev_jacy): sync to dev
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dukang/admin-web",
|
||||
"version": "4.0.19",
|
||||
"version": "4.0.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, Typography, message,
|
||||
Button, Form, Input, Modal, Select, Space, Table, Tag, Tooltip, message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import {
|
||||
@@ -9,25 +9,37 @@ import {
|
||||
PROMO_CODE_STATUS_LABELS,
|
||||
promoConversion,
|
||||
type PromoCodeItem,
|
||||
type PromoCodePartnerBrief,
|
||||
type PromoCodeScene,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { request, type Paginated } from '../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
import { useAdminListColumns } from '../lib/useAdminListColumns';
|
||||
import { AdminListHeader } from '../components/AdminListHeader';
|
||||
import { AdminPrimaryLink } from '../components/AdminPrimaryLink';
|
||||
|
||||
|
||||
type Row = PromoCodeItem;
|
||||
|
||||
type PartnerOption = { id: string; companyName?: string | null; name?: string; phone?: 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() {
|
||||
const navigate = useNavigate();
|
||||
const [filterForm] = Form.useForm();
|
||||
const [createForm] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
const [scenes, setScenes] = useState<SceneOption[]>(
|
||||
Object.entries(PROMO_CODE_SCENE_LABELS).map(([value, label]) => ({
|
||||
value: value as PromoCodeScene,
|
||||
@@ -37,6 +49,11 @@ export default function PromoCodesPage() {
|
||||
const [createOpen, setCreateOpen] = 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>(
|
||||
'/admin/promo-codes',
|
||||
() => {
|
||||
@@ -45,6 +62,8 @@ export default function PromoCodesPage() {
|
||||
if (filters.code) qs.set('code', filters.code);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
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;
|
||||
},
|
||||
[filters],
|
||||
@@ -63,6 +82,12 @@ export default function PromoCodesPage() {
|
||||
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> = [
|
||||
{
|
||||
title: '名称',
|
||||
@@ -116,8 +141,13 @@ export default function PromoCodesPage() {
|
||||
},
|
||||
{
|
||||
title: '渠道负责人',
|
||||
width: 120,
|
||||
render: (_, row) => row.ownerUser?.userNo || row.ownerUser?.phone || '—',
|
||||
width: 160,
|
||||
render: (_, row) => formatPartnerNames(row.channelOwners),
|
||||
},
|
||||
{
|
||||
title: '关联合伙人',
|
||||
width: 140,
|
||||
render: (_, row) => row.assocPartner?.companyName || row.assocPartner?.name || '—',
|
||||
},
|
||||
{ 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);
|
||||
try {
|
||||
const created = await request<PromoCodeItem>('/admin/promo-codes', {
|
||||
@@ -146,7 +184,8 @@ export default function PromoCodesPage() {
|
||||
name: values.name,
|
||||
code: values.code?.trim() || undefined,
|
||||
scene: values.scene,
|
||||
ownerUserId: values.ownerUserId?.trim() || undefined,
|
||||
channelOwnerPartnerIds: values.channelOwnerPartnerIds ?? [],
|
||||
assocPartnerAccountId: values.assocPartnerAccountId || null,
|
||||
remark: values.remark?.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 }))}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -235,8 +292,32 @@ export default function PromoCodesPage() {
|
||||
>
|
||||
<Input placeholder="pages/home/index" />
|
||||
</Form.Item>
|
||||
<Form.Item name="ownerUserId" label="关联用户 ID(选填)">
|
||||
<Input placeholder="渠道负责人,填写用户数据库 ID" />
|
||||
<Form.Item
|
||||
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 name="remark" label="备注">
|
||||
<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 { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
PROMO_CODE_STATUS_LABELS,
|
||||
promoConversion,
|
||||
} from '@dukang/shared-types';
|
||||
import { request } from '../../lib/api';
|
||||
import { fmtTime } from '../../lib/constants';
|
||||
import { request, type Paginated } from '../../lib/api';
|
||||
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../../lib/constants';
|
||||
import type { PromoCodeDetailContext } from './PromoCodeDetailLayout';
|
||||
import PromoCodeMetricsPanel from './PromoCodeMetricsPanel';
|
||||
|
||||
@@ -51,6 +51,19 @@ const ORDER_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 }) {
|
||||
return (
|
||||
<span>
|
||||
@@ -86,10 +99,30 @@ export default function PromoCodeDetailPage() {
|
||||
const [editForm] = Form.useForm();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||
|
||||
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);
|
||||
try {
|
||||
await request(`/admin/promo-codes/${detail.id}`, {
|
||||
@@ -97,7 +130,8 @@ export default function PromoCodeDetailPage() {
|
||||
body: JSON.stringify({
|
||||
name: values.name,
|
||||
scene: values.scene,
|
||||
ownerUserId: values.ownerUserId?.trim() || null,
|
||||
channelOwnerPartnerIds: values.channelOwnerPartnerIds ?? [],
|
||||
assocPartnerAccountId: values.assocPartnerAccountId || null,
|
||||
remark: values.remark?.trim() || null,
|
||||
}),
|
||||
});
|
||||
@@ -128,7 +162,8 @@ export default function PromoCodeDetailPage() {
|
||||
name: detail.name,
|
||||
scene: detail.scene,
|
||||
remark: detail.remark,
|
||||
ownerUserId: detail.ownerUser?.id,
|
||||
channelOwnerPartnerIds: (detail.channelOwners ?? []).map((p) => p.id),
|
||||
assocPartnerAccountId: detail.assocPartner?.id,
|
||||
});
|
||||
setEditOpen(true);
|
||||
}}
|
||||
@@ -194,7 +229,12 @@ export default function PromoCodeDetailPage() {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="渠道负责人">
|
||||
<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>
|
||||
</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 }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="ownerUserId" label="关联用户 ID">
|
||||
<Input placeholder="留空表示解除关联" />
|
||||
<Form.Item
|
||||
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 name="remark" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
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({
|
||||
plugins: [react()],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dukang/h5-partner",
|
||||
"version": "4.0.19",
|
||||
"version": "4.0.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -26,6 +26,7 @@ import UsersManagePage from './pages/UsersManagePage';
|
||||
import AssocOrdersPage from './pages/AssocOrdersPage';
|
||||
import CommissionOrdersPage from './pages/CommissionOrdersPage';
|
||||
import ActivityPostersPage from './pages/ActivityPostersPage';
|
||||
import PromoCodesPage from './pages/PromoCodesPage';
|
||||
import BankAccountPage from './pages/BankAccountPage';
|
||||
|
||||
function PrimaryRoutes() {
|
||||
@@ -45,6 +46,7 @@ function PrimaryRoutes() {
|
||||
<Route path="/center/staff/new" element={<StaffCreatePage />} />
|
||||
<Route path="/center/bank" element={<BankAccountPage />} />
|
||||
<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/users" element={<Navigate to="/users" replace />} />
|
||||
<Route path="/users/orders" element={<AssocOrdersPage />} />
|
||||
|
||||
@@ -258,6 +258,15 @@ export default function CenterPage({ variant = 'primary', roleLabel }: CenterPag
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-muted">chevron_right</span>
|
||||
</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">
|
||||
<div className="partner-menu-item-left">
|
||||
<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,
|
||||
port: 5175,
|
||||
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",
|
||||
"version": "4.0.19",
|
||||
"version": "4.0.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -14,6 +14,6 @@ export default defineConfig({
|
||||
server: {
|
||||
host: true,
|
||||
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",
|
||||
"version": "4.0.19",
|
||||
"version": "4.0.20",
|
||||
"private": true,
|
||||
"description": "杜康好客 · C 端用户微信小程序(Taro)",
|
||||
"scripts": {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { fetchClientConfig } from './pay-wechat';
|
||||
|
||||
/** 与 package.json version 同步(Taro defineConstants 注入),供 minClientVersion 比对 */
|
||||
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, '')}`;
|
||||
|
||||
|
||||
+3
-1
@@ -1,7 +1,7 @@
|
||||
# 杜康好客 · V3 编码手册(交付业务版)
|
||||
|
||||
> **事实源**:[`杜康好客-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)。
|
||||
|
||||
## 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.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 可见待审核、不可发送、合伙人端不可见。历史月账不回刷。
|
||||
|
||||
## 5. 验收用例(必过)
|
||||
|
||||
+15
-5
@@ -1,9 +1,9 @@
|
||||
# 杜康好客 · 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** 子账号继承码、财务全部银行账户目录
|
||||
> 未改规则仍见 [`杜康好客-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**(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 概览、财务银行账户)。
|
||||
> 实现:[`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. 版本
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
| 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.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,佣金归属改写)
|
||||
|
||||
@@ -43,6 +44,15 @@
|
||||
- 主账号可在合伙人中心填写收款账户:收款人、银行账号、开户行名称(写入主账号 `bank_account_*`)。
|
||||
- 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. 订单佣金
|
||||
|
||||
支付快照字段:`user_order.partner_account_id_at_pay`、`order_commission_rate_at_pay`。
|
||||
@@ -125,4 +135,4 @@ HQ「财务 → 全部银行账户」聚合**有效**银行账户,供财务查
|
||||
|
||||
## 10. 不做
|
||||
|
||||
改推广码体系;改核销归属;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
|
||||
不把推广码改成关联码、不回刷历史绑定;改核销归属;回刷已打款账单;区县佣金双轨;AI 出图/出文案;C 端/门店端活动图;预生成每人缓存图。
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
> 基准:[`杜康好客-v4-PRD.md`](./杜康好客-v4-PRD.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);零元不同步合伙人;酒厂含现场提货,零应付仍出账(无需打款) |
|
||||
| 活动图 | HQ 上传底图/码栏/文案;**v4.0.15 上传超限自动压缩并提示尺寸**;合伙人选择写入库;HQ 可指定一张图为勾选主合伙人合成下载;子账号不可看活动图 |
|
||||
@@ -25,6 +25,7 @@
|
||||
| 4.0.14 | [`HQ 概览粒度与环比`](./杜康好客-v4.0.14-开发文档.md) | ✅ 已实现 |
|
||||
| 4.0.15 | [`HQ 概览折线图`](./杜康好客-v4.0.15-开发文档.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 追加:HQ 财务全部银行账户(聚合门店结算资质/酒厂/合伙人/物流有效账户;可新增不挂门店账户;筛选与 Excel/PDF 导出) |
|
||||
| 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. 活动 / 推广码
|
||||
|
||||
HQ 推广码:场景/合伙人绑定/上下线;touch 归因;metrics 四指标+事件日志(v3.4.13)。
|
||||
HQ 推广码:场景/渠道负责人(主合伙人多选)/关联合伙人/上下线;touch 归因 + 关联合伙人 first-lock;metrics 四指标+事件日志(v3.4.13)。
|
||||
|
||||
## 8. 开城
|
||||
|
||||
|
||||
Generated
+7876
-6
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@
|
||||
"@eslint/js": "^9.17.0",
|
||||
"eslint": "^9.17.0",
|
||||
"globals": "^15.14.0",
|
||||
"prisma": "^8.0.0-rc.15",
|
||||
"typescript-eslint": "^8.18.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -27,6 +27,13 @@ export type PromoCodeOwnerUser = {
|
||||
phone?: string | null;
|
||||
};
|
||||
|
||||
export type PromoCodePartnerBrief = {
|
||||
id: string;
|
||||
companyName?: string | null;
|
||||
name?: string | null;
|
||||
phone?: string | null;
|
||||
};
|
||||
|
||||
export type PromoCodeItem = {
|
||||
id: string;
|
||||
code: string;
|
||||
@@ -40,11 +47,27 @@ export type PromoCodeItem = {
|
||||
landingUrl: string;
|
||||
qrcodeUrl?: string | null;
|
||||
remark?: string | null;
|
||||
/** @deprecated HQ 不再编辑;历史 C 端用户渠道负责人 */
|
||||
ownerUser?: PromoCodeOwnerUser | null;
|
||||
/** 渠道负责人:主合伙人,可多选 */
|
||||
channelOwners?: PromoCodePartnerBrief[];
|
||||
/** 关联合伙人:扫码 first-lock 绑定 */
|
||||
assocPartner?: PromoCodePartnerBrief | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
/** 合伙人 H5:仅三项汇总,不含用户/订单明细 */
|
||||
export type PartnerPromoCodeItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
status: PromoCodeStatus;
|
||||
scanCount: number;
|
||||
attributionCount: number;
|
||||
orderCount: number;
|
||||
};
|
||||
|
||||
export type PromoCodeStats = {
|
||||
/** 扫码进入次数 */
|
||||
scanCount: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dukang/shared-ui",
|
||||
"version": "4.0.19",
|
||||
"version": "4.0.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
Generated
+3555
-28
File diff suppressed because it is too large
Load Diff
@@ -17,3 +17,4 @@ allowBuilds:
|
||||
msgpackr-extract: true
|
||||
prisma: true
|
||||
sharp: true
|
||||
workerd: true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@dukang/api",
|
||||
"version": "4.0.19",
|
||||
"version": "4.0.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"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 {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(32)
|
||||
name String @db.VarChar(128)
|
||||
scene PromoCodeScene @default(ONLINE_LINK)
|
||||
qrcodeId String @unique @map("qrcode_id") @db.VarChar(64)
|
||||
status PromoCodeStatus @default(ACTIVE)
|
||||
ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt
|
||||
remark String? @db.VarChar(256)
|
||||
qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
|
||||
scanCount Int @default(0) @map("scan_count")
|
||||
orderCount Int @default(0) @map("order_count")
|
||||
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
|
||||
code String @unique @db.VarChar(32)
|
||||
name String @db.VarChar(128)
|
||||
scene PromoCodeScene @default(ONLINE_LINK)
|
||||
qrcodeId String @unique @map("qrcode_id") @db.VarChar(64)
|
||||
status PromoCodeStatus @default(ACTIVE)
|
||||
ownerUserId BigInt? @map("owner_user_id") @db.UnsignedBigInt
|
||||
assocPartnerAccountId BigInt? @map("assoc_partner_account_id") @db.UnsignedBigInt
|
||||
remark String? @db.VarChar(256)
|
||||
qrcodeResourceId BigInt? @map("qrcode_resource_id") @db.UnsignedBigInt
|
||||
scanCount Int @default(0) @map("scan_count")
|
||||
orderCount Int @default(0) @map("order_count")
|
||||
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)
|
||||
qrcodeResource CommonResource? @relation("PromoQrcode", fields: [qrcodeResourceId], references: [id], onDelete: SetNull)
|
||||
ownerUser User? @relation("PromoOwnerUser", fields: [ownerUserId], 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[]
|
||||
orders Order[]
|
||||
metricEvents LogPromoEvent[]
|
||||
|
||||
@@index([ownerUserId])
|
||||
@@index([assocPartnerAccountId])
|
||||
@@index([scene, status])
|
||||
@@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 {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
code String @unique @db.VarChar(16)
|
||||
@@ -1287,6 +1305,8 @@ model PartnerAccount {
|
||||
assocUsers User[] @relation("UserPartnerAssoc")
|
||||
assocSubUsers User[] @relation("UserSubAccountAssoc")
|
||||
userNotes PartnerUserNote[]
|
||||
promoAssocCodes CommonPromoCode[] @relation("PromoAssocPartner")
|
||||
promoChannelOwnerLinks PromoCodeChannelOwner[] @relation("PromoChannelOwner")
|
||||
assocQrcodeResource CommonResource? @relation("PartnerAssocQrcode", fields: [assocQrcodeResourceId], 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,
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: '4.0.19',
|
||||
placeholder: '4.0.20',
|
||||
description: 'semver 格式(可带或不带 v);客户端低于此版本时提示更新',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ import { OptionalJwtAuthGuard } from '../../common/guards/optional-jwt-auth.guar
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => IamModule), PromoModule],
|
||||
imports: [forwardRef(() => IamModule), forwardRef(() => PromoModule)],
|
||||
controllers: [AnalyticsController, PromoController],
|
||||
providers: [AnalyticsService, OptionalJwtAuthGuard, JwtAuthGuard],
|
||||
exports: [AnalyticsService],
|
||||
|
||||
@@ -89,6 +89,47 @@ export class PartnerCityService {
|
||||
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(
|
||||
cityId: bigint,
|
||||
input: {
|
||||
|
||||
@@ -28,7 +28,7 @@ export class HealthController {
|
||||
return {
|
||||
status,
|
||||
service: 'dukang-api',
|
||||
version: '4.0.19',
|
||||
version: '4.0.20',
|
||||
checks: { db, redis },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsIn, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import { IsArray, IsIn, IsNotEmpty, IsOptional, IsString, MaxLength, ValidateIf } from 'class-validator';
|
||||
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 {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@@ -30,6 +37,14 @@ export class PromoCodeListQueryDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ownerUserId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
channelOwnerPartnerId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assocPartnerAccountId?: string;
|
||||
}
|
||||
|
||||
export class CreatePromoCodeDto {
|
||||
@@ -51,6 +66,17 @@ export class CreatePromoCodeDto {
|
||||
@IsString()
|
||||
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()
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
@@ -77,6 +103,17 @@ export class UpdatePromoCodeDto {
|
||||
@IsString()
|
||||
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()
|
||||
@IsString()
|
||||
@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,
|
||||
} from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
PROMO_CODE_SCENE_LABELS,
|
||||
PromoCodeScene,
|
||||
@@ -17,6 +18,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
||||
import { OSS_PROVIDER, WECHAT_PROVIDER } from '../../integrations/integrations.constants';
|
||||
import type { IOssProvider } from '../../integrations/oss/oss.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 {
|
||||
computePromoMetricPeak,
|
||||
@@ -41,6 +43,13 @@ type PromoTouchMeta = {
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
type PromoPartnerBrief = {
|
||||
id: bigint;
|
||||
companyName: string | null;
|
||||
name: string;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
type PromoRow = {
|
||||
id: bigint;
|
||||
code: string;
|
||||
@@ -59,6 +68,8 @@ type PromoRow = {
|
||||
nickname: string | null;
|
||||
phone: string | null;
|
||||
} | null;
|
||||
assocPartner?: PromoPartnerBrief | null;
|
||||
channelOwners?: Array<{ partnerAccount: PromoPartnerBrief }>;
|
||||
qrcodeResource?: { url: string } | null;
|
||||
};
|
||||
|
||||
@@ -89,6 +100,7 @@ export class PromoCodeService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly promoMetricLog: PromoMetricLogService,
|
||||
private readonly partnerCity: PartnerCityService,
|
||||
@Inject(OSS_PROVIDER) private readonly oss: IOssProvider,
|
||||
@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) {
|
||||
return serializeBigInt({
|
||||
id: row.id,
|
||||
@@ -165,15 +187,30 @@ export class PromoCodeService {
|
||||
landingUrl: buildLandingUrl(row.code, row.qrcodeId),
|
||||
qrcodeUrl: row.qrcodeResource?.url ?? null,
|
||||
ownerUser: this.mapOwnerUser(row.ownerUser),
|
||||
channelOwners: (row.channelOwners ?? []).map((link) => this.mapPartnerBrief(link.partnerAccount)),
|
||||
assocPartner: this.mapPartnerBrief(row.assocPartner),
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
private partnerSelect = {
|
||||
id: true,
|
||||
companyName: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
} as const;
|
||||
|
||||
private includeRelations = {
|
||||
ownerUser: {
|
||||
select: { id: true, userNo: true, nickname: true, phone: true },
|
||||
},
|
||||
assocPartner: {
|
||||
select: this.partnerSelect,
|
||||
},
|
||||
channelOwners: {
|
||||
include: { partnerAccount: { select: this.partnerSelect } },
|
||||
},
|
||||
qrcodeResource: {
|
||||
select: { url: true },
|
||||
},
|
||||
@@ -182,13 +219,7 @@ export class PromoCodeService {
|
||||
async list(query: PromoCodeListQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: {
|
||||
status?: 'ACTIVE' | 'DISABLED';
|
||||
scene?: PromoCodeScene;
|
||||
name?: { contains: string };
|
||||
code?: { contains: string };
|
||||
ownerUserId?: bigint;
|
||||
} = {};
|
||||
const where: Prisma.CommonPromoCodeWhereInput = {};
|
||||
if (query.status) where.status = query.status;
|
||||
if (query.scene) where.scene = query.scene;
|
||||
if (query.name) where.name = { contains: query.name };
|
||||
@@ -196,6 +227,14 @@ export class PromoCodeService {
|
||||
if (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([
|
||||
this.prisma.commonPromoCode.findMany({
|
||||
@@ -237,6 +276,30 @@ export class PromoCodeService {
|
||||
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) {
|
||||
let code = custom?.trim().toUpperCase();
|
||||
if (code) {
|
||||
@@ -309,6 +372,8 @@ export class PromoCodeService {
|
||||
const code = await this.generateUniqueCode(dto.code);
|
||||
const qrcodeId = await this.generateUniqueQrcodeId();
|
||||
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 row = await this.prisma.commonPromoCode.create({
|
||||
@@ -319,7 +384,11 @@ export class PromoCodeService {
|
||||
qrcodeId,
|
||||
status: 'ACTIVE',
|
||||
ownerUserId,
|
||||
assocPartnerAccountId: assocPartnerAccountId ?? undefined,
|
||||
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) {
|
||||
await this.detail(id);
|
||||
const data: {
|
||||
name?: string;
|
||||
scene?: PromoCodeScene;
|
||||
remark?: string | null;
|
||||
ownerUserId?: bigint | null;
|
||||
} = {};
|
||||
const data: Prisma.CommonPromoCodeUpdateInput = {};
|
||||
if (dto.name !== undefined) data.name = dto.name.trim();
|
||||
if (dto.scene !== undefined) data.scene = dto.scene;
|
||||
if (dto.remark !== undefined) data.remark = dto.remark?.trim() || null;
|
||||
if (dto.ownerUserId !== undefined) {
|
||||
if (dto.ownerUserId === null || dto.ownerUserId === '') {
|
||||
data.ownerUserId = null;
|
||||
data.ownerUser = { disconnect: true };
|
||||
} 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({
|
||||
where: { id },
|
||||
@@ -464,6 +540,10 @@ export class PromoCodeService {
|
||||
}
|
||||
|
||||
sourceApplied = await this.applyPromoSourceToUser(userId, promo, meta);
|
||||
|
||||
if (promo.assocPartnerAccountId) {
|
||||
await this.partnerCity.tryBindIfUnbound(userId, promo.assocPartnerAccountId);
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldCountScan) {
|
||||
@@ -783,4 +863,43 @@ export class PromoCodeService {
|
||||
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 { JwtAuthGuard } from '../../common/guards/jwt-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 { PartnerPromoCodeController } from './partner-promo-code.controller';
|
||||
import { PromoCodeService } from './promo-code.service';
|
||||
import { PromoMetricLogService } from './promo-metric-log.service';
|
||||
|
||||
@@ -12,13 +15,14 @@ import { PromoMetricLogService } from './promo-metric-log.service';
|
||||
imports: [
|
||||
GeoModule,
|
||||
IntegrationsModule,
|
||||
CityScopeModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
|
||||
signOptions: { expiresIn: process.env.JWT_EXPIRES_IN || '7d' },
|
||||
}),
|
||||
],
|
||||
controllers: [AdminPromoCodeController],
|
||||
providers: [PromoCodeService, PromoMetricLogService, JwtAuthGuard, HqAuthGuard],
|
||||
controllers: [AdminPromoCodeController, PartnerPromoCodeController],
|
||||
providers: [PromoCodeService, PromoMetricLogService, JwtAuthGuard, HqAuthGuard, PartnerPrimaryGuard],
|
||||
exports: [PromoCodeService],
|
||||
})
|
||||
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 }) {
|
||||
const { primary, sub } = await this.resolveAssocTarget(input);
|
||||
const shouldCountScan = input.countScan !== false;
|
||||
|
||||
Reference in New Issue
Block a user