feat: add referral conversion report

This commit is contained in:
Codex
2026-06-30 23:56:35 +08:00
parent 9891fe9ef3
commit 88640f8262
12 changed files with 899 additions and 25 deletions

View File

@@ -20,6 +20,7 @@ import {
crmQueueLogsRoute,
crmQueueRoute,
referralBindRoute,
referralConversionReportRoute,
referralInviteCodeRoute,
referralManualBindRoute,
referralQrcodeRoute,
@@ -40,6 +41,7 @@ export const referralRoutes: RouteDefinition[] = [
['POST', '/api/referral/bind', referralBindRoute],
['GET', '/api/referral/stats', referralStatsRoute],
['GET', '/api/referral/sales-stats', referralSalesStatsRoute],
['GET', '/api/referral/conversion-report', referralConversionReportRoute],
['GET', '/api/referral/sales-clients', referralSalesClientsRoute],
['POST', '/api/referral/manual-bind', referralManualBindRoute],
['GET', '/api/referral/team', referralTeamRoute],

View File

@@ -154,6 +154,55 @@ function centsToAmount(cents: number) {
return Math.round(cents) / 100;
}
function intValue(value: unknown, fallback = 0) {
const parsed = Number(value ?? fallback);
return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback;
}
function dateValue(value: string, fallback: string) {
const candidate = value || fallback;
if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) {
throw new HttpError(400, 'Date must use YYYY-MM-DD format', 'INVALID_DATE');
}
return candidate;
}
function shanghaiDateKey(date = new Date()) {
const formatter = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
const parts = Object.fromEntries(formatter.formatToParts(date).map(part => [part.type, part.value]));
return `${parts.year}-${parts.month}-${parts.day}`;
}
function daysBetweenInclusive(startDate: string, endDate: string) {
const start = new Date(`${startDate}T00:00:00+08:00`).getTime();
const end = new Date(`${endDate}T00:00:00+08:00`).getTime();
return Math.floor((end - start) / 86400000) + 1;
}
function conversionPeriodFromParams(ctx: RequestContext) {
const today = shanghaiDateKey();
const startDate = dateValue(stringParam(ctx, 'startDate'), today.slice(0, 8) + '01');
const endDate = dateValue(stringParam(ctx, 'endDate'), today);
if (startDate > endDate) {
throw new HttpError(400, 'startDate must be before or equal to endDate', 'INVALID_DATE_RANGE');
}
const days = daysBetweenInclusive(startDate, endDate);
if (days > 180) {
throw new HttpError(400, 'Conversion report range cannot exceed 180 days', 'REPORT_RANGE_TOO_LARGE');
}
return { startDate, endDate, days };
}
function ratio(numerator: number, denominator: number) {
if (!denominator) return 0;
return Number((numerator / denominator).toFixed(4));
}
function canViewAllReferral(auth: TenantAdminAuth) {
return hasTenantPermission(auth, 'referral:read');
}
@@ -162,6 +211,14 @@ function canViewSelfReferral(auth: TenantAdminAuth) {
return hasTenantPermission(auth, 'referral:self') || canViewAllReferral(auth);
}
function restrictReferralScope(auth: TenantAdminAuth, requestedReferrerId: string | null) {
if (canViewAllReferral(auth)) return requestedReferrerId;
if (requestedReferrerId && requestedReferrerId !== auth.userId) {
throw new HttpError(403, 'Only own referral conversion data can be viewed', 'REFERRAL_SCOPE_REQUIRED');
}
return auth.userId;
}
async function userHasTenantMembership(client: pg.PoolClient, tenantId: string, userId: string) {
const result = await client.query(
`
@@ -955,6 +1012,574 @@ export async function referralSalesStatsRoute(ctx: RequestContext) {
};
}
export async function referralConversionReportRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canViewSelfReferral(auth)) {
throw new HttpError(403, 'Referral access is required', 'REFERRAL_ACCESS_REQUIRED');
}
const { startDate, endDate, days } = conversionPeriodFromParams(ctx);
const requestedReferrerId = optionalUuidString(stringParam(ctx, 'referrerUserId'), 'referrerUserId') || null;
const scopedReferrerId = restrictReferralScope(auth, requestedReferrerId);
const limit = intParam(ctx, 'limit', 20, 100);
const params = [auth.tenantId, startDate, endDate, scopedReferrerId, limit];
const byReferrerRows = await query<Record<string, unknown>>(
`
with settings as (
select coalesce(default_rate, 0.2000)::numeric(6,4) as default_rate
from public.tenant_commission_settings
where tenant_id = $1
union all
select 0.2000::numeric(6,4)
limit 1
),
referrers as (
select distinct on (tm.user_id)
tm.user_id, tm.role, u.username, u.name, u.phone, rc.code::text as invite_code
from public.tenant_memberships tm
join public.platform_users u on u.id = tm.user_id
left join public.referral_codes rc on rc.tenant_id = tm.tenant_id and rc.user_id = tm.user_id
where tm.tenant_id = $1
and tm.status = 'active'
and tm.role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent')
and ($4::uuid is null or tm.user_id = $4::uuid)
order by tm.user_id,
case tm.role
when 'sales' then 1
when 'agent' then 2
when 'tenant_operator' then 3
when 'teacher' then 4
else 9
end
),
lead_base as (
select rl.*
from public.referral_leads rl
where rl.tenant_id = $1
and rl.status = 'protected'
and rl.referrer_user_id is not null
and ($4::uuid is null or rl.referrer_user_id = $4::uuid)
),
new_leads as (
select *
from lead_base
where bound_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and bound_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
),
order_sources as (
select 'order'::text as source_type,
o.id as source_id,
coalesce(o.paid_at, o.updated_at, o.created_at) as source_paid_at,
o.user_id as student_user_id,
lb.referrer_user_id,
o.amount_cents::integer as gross_amount_cents,
null::numeric(6,4) as batch_rate
from public.orders o
join lead_base lb on lb.tenant_id = o.tenant_id
and lb.student_user_id = o.user_id
and coalesce(o.paid_at, o.updated_at, o.created_at) >= lb.bound_at
where o.tenant_id = $1
and o.status = 'paid'
and o.user_id is not null
and coalesce(o.paid_at, o.updated_at, o.created_at) >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and coalesce(o.paid_at, o.updated_at, o.created_at) < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
),
code_sources as (
select 'activation_code'::text as source_type,
ac.id as source_id,
ac.used_at as source_paid_at,
ac.used_by as student_user_id,
lb.referrer_user_id,
greatest(coalesce(ac.unit_price_cents, cb.default_unit_price_cents, 0), 0)::integer as gross_amount_cents,
cb.commission_rate as batch_rate
from public.activation_codes ac
left join public.code_batches cb on cb.tenant_id = ac.tenant_id and cb.id = ac.batch_id
join lead_base lb on lb.tenant_id = ac.tenant_id
and lb.student_user_id = ac.used_by
and ac.used_at >= lb.bound_at
where ac.tenant_id = $1
and ac.is_used is true
and ac.used_by is not null
and ac.used_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and ac.used_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
),
sources as (
select * from order_sources
union all
select * from code_sources
),
referrer_members as (
select distinct on (tm.user_id)
tm.user_id, tm.commission_rate
from public.tenant_memberships tm
where tm.tenant_id = $1
and tm.status = 'active'
and tm.role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent')
order by tm.user_id,
case tm.role
when 'sales' then 1
when 'agent' then 2
when 'tenant_operator' then 3
when 'teacher' then 4
else 9
end
),
commission_sources as (
select s.*,
coalesce(s.batch_rate, rm.commission_rate, settings.default_rate)::numeric(6,4) as commission_rate,
round(s.gross_amount_cents * coalesce(s.batch_rate, rm.commission_rate, settings.default_rate))::integer as commission_amount_cents
from sources s
cross join settings
left join referrer_members rm on rm.user_id = s.referrer_user_id
where s.gross_amount_cents > 0
),
lead_stats as (
select referrer_user_id,
count(*)::int as lead_count,
count(*) filter (where assigned_to_user_id is not null)::int as assigned_lead_count
from new_leads
group by referrer_user_id
),
converted_stats as (
select nl.referrer_user_id,
count(distinct nl.student_user_id)::int as converted_lead_count
from new_leads nl
where exists (
select 1
from commission_sources cs
where cs.referrer_user_id = nl.referrer_user_id
and cs.student_user_id = nl.student_user_id
and cs.source_paid_at >= nl.bound_at
)
group by nl.referrer_user_id
),
source_stats as (
select referrer_user_id,
count(*)::int as paid_source_count,
count(distinct student_user_id)::int as paid_lead_count,
coalesce(sum(gross_amount_cents), 0)::bigint as gross_amount_cents,
coalesce(sum(commission_amount_cents), 0)::bigint as commission_amount_cents
from commission_sources
group by referrer_user_id
),
paid_users as (
select lb.referrer_user_id, lb.student_user_id,
min(lb.bound_at) as bound_at,
min(cs.source_paid_at) as first_paid_at
from lead_base lb
join commission_sources cs on cs.referrer_user_id = lb.referrer_user_id
and cs.student_user_id = lb.student_user_id
and cs.source_paid_at >= lb.bound_at
group by lb.referrer_user_id, lb.student_user_id
),
paid_user_stats as (
select referrer_user_id,
count(*)::int as first_pay_user_count,
coalesce(sum(extract(epoch from (first_paid_at - bound_at)) / 3600), 0)::numeric as first_pay_hours_sum,
coalesce(avg(extract(epoch from (first_paid_at - bound_at)) / 3600), 0)::numeric as avg_first_pay_hours
from paid_users
group by referrer_user_id
),
track_stats as (
select ref_user_id as referrer_user_id,
count(*)::int as track_count,
count(*) filter (where event_type = 'share')::int as share_count,
count(*) filter (where event_type in ('scan', 'enter', 'register'))::int as intent_count
from public.referral_tracks
where tenant_id = $1
and ref_user_id is not null
and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and created_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
and ($4::uuid is null or ref_user_id = $4::uuid)
group by ref_user_id
),
crm_stats as (
select lb.referrer_user_id,
count(*)::int as crm_queue_count,
count(*) filter (where q.status = 'sent')::int as crm_sent_count,
count(*) filter (where q.status in ('failed', 'discarded'))::int as crm_failed_count,
count(*) filter (where q.status in ('pending', 'processing', 'retrying'))::int as crm_pending_count
from public.crm_webhook_queue q
join lead_base lb on lb.id::text = q.lead_id
where q.tenant_id = $1
and q.created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and q.created_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
group by lb.referrer_user_id
),
followup_stats as (
select assigned_to_user_id as referrer_user_id,
count(*)::int as followup_count,
count(*) filter (where status in ('open', 'in_progress'))::int as open_followup_count,
count(*) filter (where status = 'done')::int as done_followup_count,
count(*) filter (where due_at < now() and status in ('open', 'in_progress'))::int as overdue_followup_count
from public.tenant_student_followups
where tenant_id = $1
and assigned_to_user_id is not null
and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and created_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
and ($4::uuid is null or assigned_to_user_id = $4::uuid)
group by assigned_to_user_id
)
select r.user_id as "referrerUserId",
r.role,
r.username,
r.name,
r.phone,
r.invite_code as "inviteCode",
coalesce(ts.track_count, 0)::int as "trackCount",
coalesce(ts.share_count, 0)::int as "shareCount",
coalesce(ts.intent_count, 0)::int as "intentCount",
coalesce(ls.lead_count, 0)::int as "leadCount",
coalesce(ls.assigned_lead_count, 0)::int as "assignedLeadCount",
coalesce(cs.converted_lead_count, 0)::int as "convertedLeadCount",
coalesce(ss.paid_lead_count, 0)::int as "paidLeadCount",
coalesce(ss.paid_source_count, 0)::int as "paidSourceCount",
coalesce(ss.gross_amount_cents, 0)::text as "grossAmountCents",
coalesce(ss.commission_amount_cents, 0)::text as "commissionAmountCents",
coalesce(pus.first_pay_user_count, 0)::int as "firstPayUserCount",
coalesce(pus.first_pay_hours_sum, 0)::text as "firstPayHoursSum",
coalesce(pus.avg_first_pay_hours, 0)::text as "avgFirstPayHours",
coalesce(crm.crm_queue_count, 0)::int as "crmQueueCount",
coalesce(crm.crm_sent_count, 0)::int as "crmSentCount",
coalesce(crm.crm_failed_count, 0)::int as "crmFailedCount",
coalesce(crm.crm_pending_count, 0)::int as "crmPendingCount",
coalesce(fs.followup_count, 0)::int as "followupCount",
coalesce(fs.open_followup_count, 0)::int as "openFollowupCount",
coalesce(fs.done_followup_count, 0)::int as "doneFollowupCount",
coalesce(fs.overdue_followup_count, 0)::int as "overdueFollowupCount"
from referrers r
left join lead_stats ls on ls.referrer_user_id = r.user_id
left join converted_stats cs on cs.referrer_user_id = r.user_id
left join source_stats ss on ss.referrer_user_id = r.user_id
left join paid_user_stats pus on pus.referrer_user_id = r.user_id
left join track_stats ts on ts.referrer_user_id = r.user_id
left join crm_stats crm on crm.referrer_user_id = r.user_id
left join followup_stats fs on fs.referrer_user_id = r.user_id
order by coalesce(ss.gross_amount_cents, 0) desc,
coalesce(ls.lead_count, 0) desc,
r.name asc nulls last
limit $5
`,
params,
);
const dailyRows = await query<Record<string, unknown>>(
`
with days as (
select generate_series($2::date, $3::date, interval '1 day')::date as day
),
settings as (
select coalesce(default_rate, 0.2000)::numeric(6,4) as default_rate
from public.tenant_commission_settings
where tenant_id = $1
union all
select 0.2000::numeric(6,4)
limit 1
),
lead_base as (
select rl.*
from public.referral_leads rl
where rl.tenant_id = $1
and rl.status = 'protected'
and rl.referrer_user_id is not null
and ($4::uuid is null or rl.referrer_user_id = $4::uuid)
),
referrer_members as (
select distinct on (tm.user_id)
tm.user_id, tm.commission_rate
from public.tenant_memberships tm
where tm.tenant_id = $1
and tm.status = 'active'
order by tm.user_id,
case tm.role
when 'sales' then 1
when 'agent' then 2
when 'tenant_operator' then 3
when 'teacher' then 4
else 9
end
),
lead_daily as (
select timezone('Asia/Shanghai', bound_at)::date as day,
count(*)::int as lead_count,
count(*) filter (where assigned_to_user_id is not null)::int as assigned_lead_count
from lead_base
where bound_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and bound_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
group by timezone('Asia/Shanghai', bound_at)::date
),
track_daily as (
select timezone('Asia/Shanghai', created_at)::date as day,
count(*)::int as track_count
from public.referral_tracks
where tenant_id = $1
and ref_user_id is not null
and created_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and created_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
and ($4::uuid is null or ref_user_id = $4::uuid)
group by timezone('Asia/Shanghai', created_at)::date
),
order_sources as (
select coalesce(o.paid_at, o.updated_at, o.created_at) as source_paid_at,
o.user_id as student_user_id,
lb.referrer_user_id,
o.amount_cents::integer as gross_amount_cents,
null::numeric(6,4) as batch_rate
from public.orders o
join lead_base lb on lb.tenant_id = o.tenant_id
and lb.student_user_id = o.user_id
and coalesce(o.paid_at, o.updated_at, o.created_at) >= lb.bound_at
where o.tenant_id = $1
and o.status = 'paid'
and o.user_id is not null
and coalesce(o.paid_at, o.updated_at, o.created_at) >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and coalesce(o.paid_at, o.updated_at, o.created_at) < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
),
code_sources as (
select ac.used_at as source_paid_at,
ac.used_by as student_user_id,
lb.referrer_user_id,
greatest(coalesce(ac.unit_price_cents, cb.default_unit_price_cents, 0), 0)::integer as gross_amount_cents,
cb.commission_rate as batch_rate
from public.activation_codes ac
left join public.code_batches cb on cb.tenant_id = ac.tenant_id and cb.id = ac.batch_id
join lead_base lb on lb.tenant_id = ac.tenant_id
and lb.student_user_id = ac.used_by
and ac.used_at >= lb.bound_at
where ac.tenant_id = $1
and ac.is_used is true
and ac.used_by is not null
and ac.used_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and ac.used_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
),
commission_sources as (
select s.source_paid_at,
s.student_user_id,
s.referrer_user_id,
s.gross_amount_cents,
round(s.gross_amount_cents * coalesce(s.batch_rate, rm.commission_rate, settings.default_rate))::integer as commission_amount_cents
from (
select * from order_sources
union all
select * from code_sources
) s
cross join settings
left join referrer_members rm on rm.user_id = s.referrer_user_id
where s.gross_amount_cents > 0
),
source_daily as (
select timezone('Asia/Shanghai', source_paid_at)::date as day,
count(*)::int as paid_source_count,
count(distinct student_user_id)::int as paid_lead_count,
coalesce(sum(gross_amount_cents), 0)::bigint as gross_amount_cents,
coalesce(sum(commission_amount_cents), 0)::bigint as commission_amount_cents
from commission_sources
group by timezone('Asia/Shanghai', source_paid_at)::date
)
select d.day::text as date,
coalesce(td.track_count, 0)::int as "trackCount",
coalesce(ld.lead_count, 0)::int as "leadCount",
coalesce(ld.assigned_lead_count, 0)::int as "assignedLeadCount",
coalesce(sd.paid_lead_count, 0)::int as "paidLeadCount",
coalesce(sd.paid_source_count, 0)::int as "paidSourceCount",
coalesce(sd.gross_amount_cents, 0)::text as "grossAmountCents",
coalesce(sd.commission_amount_cents, 0)::text as "commissionAmountCents"
from days d
left join track_daily td on td.day = d.day
left join lead_daily ld on ld.day = d.day
left join source_daily sd on sd.day = d.day
order by d.day asc
`,
[auth.tenantId, startDate, endDate, scopedReferrerId],
);
const recentUnconvertedRows = await query<Record<string, unknown>>(
`
with scoped_leads as (
select rl.*
from public.referral_leads rl
where rl.tenant_id = $1
and rl.status = 'protected'
and rl.referrer_user_id is not null
and rl.bound_at >= ($2::date::timestamp at time zone 'Asia/Shanghai')
and rl.bound_at < (($3::date + 1)::timestamp at time zone 'Asia/Shanghai')
and ($4::uuid is null or rl.referrer_user_id = $4::uuid)
)
select rl.id,
rl.student_user_id as "studentUserId",
student.name as "studentName",
student.phone as "studentPhone",
rl.referrer_user_id as "referrerUserId",
referrer.name as "referrerName",
referrer.phone as "referrerPhone",
rl.assigned_to_user_id as "assignedToUserId",
assignee.name as "assignedToName",
rl.ref_code::text as "refCode",
rl.source,
rl.bound_at as "boundAt"
from scoped_leads rl
join public.platform_users student on student.id = rl.student_user_id
left join public.platform_users referrer on referrer.id = rl.referrer_user_id
left join public.platform_users assignee on assignee.id = rl.assigned_to_user_id
where not exists (
select 1
from public.orders o
where o.tenant_id = rl.tenant_id
and o.user_id = rl.student_user_id
and o.status = 'paid'
and coalesce(o.paid_at, o.updated_at, o.created_at) >= rl.bound_at
)
and not exists (
select 1
from public.activation_codes ac
where ac.tenant_id = rl.tenant_id
and ac.used_by = rl.student_user_id
and ac.is_used is true
and ac.used_at >= rl.bound_at
)
order by rl.bound_at desc
limit $5
`,
params,
);
const byReferrer = byReferrerRows.map(row => {
const leadCount = intValue(row.leadCount);
const convertedLeadCount = intValue(row.convertedLeadCount);
const paidLeadCount = intValue(row.paidLeadCount);
const grossAmountCents = intValue(row.grossAmountCents);
const commissionAmountCents = intValue(row.commissionAmountCents);
return {
referrerUserId: String(row.referrerUserId),
role: String(row.role || ''),
username: row.username ? String(row.username) : null,
name: row.name ? String(row.name) : null,
phone: row.phone ? String(row.phone) : null,
inviteCode: row.inviteCode ? String(row.inviteCode) : null,
trackCount: intValue(row.trackCount),
shareCount: intValue(row.shareCount),
intentCount: intValue(row.intentCount),
leadCount,
assignedLeadCount: intValue(row.assignedLeadCount),
convertedLeadCount,
paidLeadCount,
paidSourceCount: intValue(row.paidSourceCount),
grossAmountCents,
grossAmount: centsToAmount(grossAmountCents),
commissionAmountCents,
commissionAmount: centsToAmount(commissionAmountCents),
firstPayUserCount: intValue(row.firstPayUserCount),
firstPayHoursSum: Number(row.firstPayHoursSum || 0),
avgFirstPayHours: Number(Number(row.avgFirstPayHours || 0).toFixed(2)),
crmQueueCount: intValue(row.crmQueueCount),
crmSentCount: intValue(row.crmSentCount),
crmFailedCount: intValue(row.crmFailedCount),
crmPendingCount: intValue(row.crmPendingCount),
followupCount: intValue(row.followupCount),
openFollowupCount: intValue(row.openFollowupCount),
doneFollowupCount: intValue(row.doneFollowupCount),
overdueFollowupCount: intValue(row.overdueFollowupCount),
conversionRate: ratio(convertedLeadCount, leadCount),
paidLeadRate: ratio(paidLeadCount, leadCount),
};
});
const summaryBase = byReferrer.reduce((acc, item) => {
acc.trackCount += item.trackCount;
acc.shareCount += item.shareCount;
acc.intentCount += item.intentCount;
acc.leadCount += item.leadCount;
acc.assignedLeadCount += item.assignedLeadCount;
acc.convertedLeadCount += item.convertedLeadCount;
acc.paidLeadCount += item.paidLeadCount;
acc.paidSourceCount += item.paidSourceCount;
acc.grossAmountCents += item.grossAmountCents;
acc.commissionAmountCents += item.commissionAmountCents;
acc.firstPayUserCount += item.firstPayUserCount;
acc.firstPayHoursSum += item.firstPayHoursSum;
acc.crmQueueCount += item.crmQueueCount;
acc.crmSentCount += item.crmSentCount;
acc.crmFailedCount += item.crmFailedCount;
acc.crmPendingCount += item.crmPendingCount;
acc.followupCount += item.followupCount;
acc.openFollowupCount += item.openFollowupCount;
acc.doneFollowupCount += item.doneFollowupCount;
acc.overdueFollowupCount += item.overdueFollowupCount;
return acc;
}, {
trackCount: 0,
shareCount: 0,
intentCount: 0,
leadCount: 0,
assignedLeadCount: 0,
convertedLeadCount: 0,
paidLeadCount: 0,
paidSourceCount: 0,
grossAmountCents: 0,
commissionAmountCents: 0,
firstPayUserCount: 0,
firstPayHoursSum: 0,
crmQueueCount: 0,
crmSentCount: 0,
crmFailedCount: 0,
crmPendingCount: 0,
followupCount: 0,
openFollowupCount: 0,
doneFollowupCount: 0,
overdueFollowupCount: 0,
});
return {
item: {
tenantId: auth.tenantId,
range: { startDate, endDate, days, referrerUserId: scopedReferrerId },
summary: {
...summaryBase,
grossAmount: centsToAmount(summaryBase.grossAmountCents),
commissionAmount: centsToAmount(summaryBase.commissionAmountCents),
conversionRate: ratio(summaryBase.convertedLeadCount, summaryBase.leadCount),
paidLeadRate: ratio(summaryBase.paidLeadCount, summaryBase.leadCount),
avgFirstPayHours: summaryBase.firstPayUserCount
? Number((summaryBase.firstPayHoursSum / summaryBase.firstPayUserCount).toFixed(2))
: 0,
},
funnel: [
{ key: 'tracks', label: '触达/扫码', count: summaryBase.trackCount },
{ key: 'leads', label: '新增客资', count: summaryBase.leadCount },
{ key: 'assigned', label: '已分配跟进', count: summaryBase.assignedLeadCount },
{ key: 'converted', label: '有效成交客资', count: summaryBase.convertedLeadCount },
{ key: 'paid_sources', label: '成交来源', count: summaryBase.paidSourceCount },
],
byReferrer,
dailyTrend: dailyRows.map(row => ({
date: String(row.date),
trackCount: intValue(row.trackCount),
leadCount: intValue(row.leadCount),
assignedLeadCount: intValue(row.assignedLeadCount),
paidLeadCount: intValue(row.paidLeadCount),
paidSourceCount: intValue(row.paidSourceCount),
grossAmountCents: intValue(row.grossAmountCents),
commissionAmountCents: intValue(row.commissionAmountCents),
})),
recentUnconvertedLeads: recentUnconvertedRows.map(row => ({
id: String(row.id),
studentUserId: String(row.studentUserId),
studentName: row.studentName ? String(row.studentName) : null,
studentPhone: row.studentPhone ? String(row.studentPhone) : null,
referrerUserId: String(row.referrerUserId),
referrerName: row.referrerName ? String(row.referrerName) : null,
referrerPhone: row.referrerPhone ? String(row.referrerPhone) : null,
assignedToUserId: row.assignedToUserId ? String(row.assignedToUserId) : null,
assignedToName: row.assignedToName ? String(row.assignedToName) : null,
refCode: row.refCode ? String(row.refCode) : null,
source: row.source ? String(row.source) : null,
boundAt: row.boundAt,
})),
},
};
}
export async function referralSalesClientsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
if (!canViewSelfReferral(auth)) {

View File

@@ -25,6 +25,7 @@ import {
loadPointExchangeItems,
loadPointExchangeOrders,
loadPointsRiskReport,
loadReferralConversionReport,
loadTenantMembers,
loadUserNotifications,
updateCommissionSettings,
@@ -53,6 +54,7 @@ import {
type PointActivityTaskItem,
type PointExchangeItem,
type PointsRiskReport,
type ReferralConversionReport,
type TenantMemberItem,
type UserNotificationAdminItem,
} from '@/services/tenantAdmin';
@@ -305,6 +307,7 @@ export default function TenantMarketingPage() {
const [crmLogs, setCrmLogs] = useState<Record<string, CrmQueueLogItem[]>>({});
const [commissionSettings, setCommissionSettings] = useState<CommissionSettingsItem | null>(null);
const [commission, setCommission] = useState<CommissionSummaryItem | null>(null);
const [conversionReport, setConversionReport] = useState<ReferralConversionReport | null>(null);
const [commissionOrders, setCommissionOrders] = useState<CommissionOrderItem[]>([]);
const [settlements, setSettlements] = useState<CommissionSettlementItem[]>([]);
const [settlementProofs, setSettlementProofs] = useState<Record<string, CommissionSettlementProofItem[]>>({});
@@ -380,6 +383,7 @@ export default function TenantMarketingPage() {
crmDeadLettersPayload,
commissionSettingsPayload,
commissionPayload,
conversionReportPayload,
orderPayload,
settlementPayload,
memberPayload,
@@ -410,6 +414,7 @@ export default function TenantMarketingPage() {
loadCrmDeadLetters({ limit: 20 }).catch(() => ({ items: [], summary: {} })),
loadCommissionSettings().catch(() => ({ item: null })),
loadCommissionSummary(period).catch(() => ({ item: null })),
loadReferralConversionReport({ ...period, limit: 20 }).catch(() => ({ item: null })),
loadCommissionOrders({ ...period, limit: 20 }).catch(() => ({ items: [] })),
loadCommissionSettlements({ limit: 20 }).catch(() => ({ items: [] })),
loadTenantMembers({ limit: 100 }).catch(() => ({ items: [] })),
@@ -438,6 +443,7 @@ export default function TenantMarketingPage() {
setCrmDeadLetters(crmDeadLettersPayload || {});
setCommissionSettings(nextSettings);
setCommission(commissionPayload.item || null);
setConversionReport(conversionReportPayload.item || null);
setCommissionOrders(orderPayload.items || []);
setSettlements(settlementPayload.items || []);
setMembers(memberPayload.items || []);
@@ -882,12 +888,14 @@ export default function TenantMarketingPage() {
setBusy('commission');
setError('');
try {
const [summaryPayload, orderPayload, settlementPayload] = await Promise.all([
const [summaryPayload, conversionPayload, orderPayload, settlementPayload] = await Promise.all([
loadCommissionSummary(period),
loadReferralConversionReport({ ...period, limit: 30 }),
loadCommissionOrders({ ...period, limit: 30 }),
loadCommissionSettlements({ limit: 30 }),
]);
setCommission(summaryPayload.item || null);
setConversionReport(conversionPayload.item || null);
setCommissionOrders(orderPayload.items || []);
setSettlements(settlementPayload.items || []);
} catch (nextError) {
@@ -1544,6 +1552,67 @@ export default function TenantMarketingPage() {
</View>
</View>
<View className='admin-section'>
<Text className='admin-section-title'>/</Text>
<View className='admin-grid'>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(conversionReport?.summary?.leadCount || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(conversionReport?.summary?.convertedLeadCount || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{percentLabel(conversionReport?.summary?.conversionRate)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{money(conversionReport?.summary?.grossAmountCents)}</Text></View>
</View>
<View className='admin-grid'>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{money(conversionReport?.summary?.commissionAmountCents)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'>CRM </Text><Text className='admin-metric-value'>{String(conversionReport?.summary?.crmFailedCount || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(conversionReport?.summary?.openFollowupCount || 0)}</Text></View>
<View className='admin-metric'><Text className='admin-metric-label'></Text><Text className='admin-metric-value'>{String(conversionReport?.summary?.avgFirstPayHours || 0)}h</Text></View>
</View>
<View className='admin-list compact'>
{(conversionReport?.funnel || []).map(item => (
<View className='admin-row' key={String(item.key || item.label)}>
<Text className='admin-row-main'>{String(item.label || item.key || '漏斗')}</Text>
<Text className='admin-row-meta'>{String(item.count || 0)}</Text>
</View>
))}
</View>
<View className='admin-list'>
{(conversionReport?.byReferrer || []).slice(0, 8).map(item => (
<View className='admin-row' key={item.referrerUserId}>
<Text className='admin-row-main'>{item.name || item.phone || item.username || item.referrerUserId} · {money(item.grossAmountCents)}</Text>
<Text className='admin-row-meta'>
{item.role || 'member'} · {String(item.leadCount || 0)} · {String(item.convertedLeadCount || 0)} · {percentLabel(item.conversionRate)} · {money(item.commissionAmountCents)}
</Text>
<Text className='admin-row-meta'>
CRM {String(item.crmFailedCount || 0)} · {String(item.openFollowupCount || 0)} · {String(item.overdueFollowupCount || 0)} · {String(item.avgFirstPayHours || 0)}h
</Text>
</View>
))}
</View>
{(conversionReport?.dailyTrend || []).length ? (
<View className='admin-list compact'>
{(conversionReport?.dailyTrend || []).slice(-7).map(item => (
<View className='admin-row' key={String(item.date)}>
<Text className='admin-row-main'>{String(item.date || '-')}</Text>
<Text className='admin-row-meta'> {String(item.leadCount || 0)} · {String(item.paidSourceCount || 0)} · {money(item.grossAmountCents)}</Text>
</View>
))}
</View>
) : null}
{(conversionReport?.recentUnconvertedLeads || []).length ? (
<View className='admin-list'>
<Text className='admin-section-title'></Text>
{(conversionReport?.recentUnconvertedLeads || []).slice(0, 6).map(item => (
<View className='admin-row' key={String(item.id)}>
<Text className='admin-row-main'>{item.studentName || item.studentPhone || item.studentUserId || '学生'} · {item.referrerName || item.referrerPhone || item.referrerUserId || '归属人'}</Text>
<Text className='admin-row-meta'> {item.assignedToName || item.assignedToUserId || '-'} · {item.source || '-'} · {shortDate(item.boundAt)}</Text>
</View>
))}
</View>
) : null}
{!(conversionReport?.byReferrer?.length || conversionReport?.recentUnconvertedLeads?.length) ? (
<View className='admin-empty'></View>
) : null}
</View>
<View className='admin-section'>
<Text className='admin-section-title'></Text>
<View className='admin-list'>

View File

@@ -840,6 +840,86 @@ export interface CrmQueueLogItem {
createdAt?: string;
}
export interface ReferralConversionReferrerItem {
referrerUserId: string;
role?: string;
username?: string | null;
name?: string | null;
phone?: string | null;
inviteCode?: string | null;
trackCount?: number;
shareCount?: number;
intentCount?: number;
leadCount?: number;
assignedLeadCount?: number;
convertedLeadCount?: number;
paidLeadCount?: number;
paidSourceCount?: number;
grossAmountCents?: number;
grossAmount?: number;
commissionAmountCents?: number;
commissionAmount?: number;
firstPayUserCount?: number;
avgFirstPayHours?: number;
crmQueueCount?: number;
crmSentCount?: number;
crmFailedCount?: number;
crmPendingCount?: number;
followupCount?: number;
openFollowupCount?: number;
doneFollowupCount?: number;
overdueFollowupCount?: number;
conversionRate?: number;
paidLeadRate?: number;
}
export interface ReferralConversionReport {
tenantId?: string;
range?: {
startDate?: string;
endDate?: string;
days?: number;
referrerUserId?: string | null;
};
summary?: Partial<ReferralConversionReferrerItem> & {
grossAmountCents?: number;
commissionAmountCents?: number;
grossAmount?: number;
commissionAmount?: number;
firstPayHoursSum?: number;
};
funnel?: Array<{
key?: string;
label?: string;
count?: number;
}>;
byReferrer?: ReferralConversionReferrerItem[];
dailyTrend?: Array<{
date?: string;
trackCount?: number;
leadCount?: number;
assignedLeadCount?: number;
paidLeadCount?: number;
paidSourceCount?: number;
grossAmountCents?: number;
commissionAmountCents?: number;
}>;
recentUnconvertedLeads?: Array<{
id?: string;
studentUserId?: string;
studentName?: string | null;
studentPhone?: string | null;
referrerUserId?: string;
referrerName?: string | null;
referrerPhone?: string | null;
assignedToUserId?: string | null;
assignedToName?: string | null;
refCode?: string | null;
source?: string | null;
boundAt?: string;
}>;
}
export interface CommissionSettingsItem {
defaultRate?: number | string;
minSettlementCents?: number;
@@ -1699,6 +1779,17 @@ export async function loadCommissionSummary(query: { startDate?: string; endDate
return apiRequest<{ item?: CommissionSummaryItem }>('/api/commission/summary', { query });
}
export async function loadReferralConversionReport(query: {
startDate?: string;
endDate?: string;
referrerUserId?: string;
limit?: number;
} = {}) {
return apiRequest<{ item?: ReferralConversionReport }>('/api/referral/conversion-report', {
query: { ...query, limit: query.limit || 20 },
});
}
export async function loadCommissionOrders(query: { startDate?: string; endDate?: string; referrerUserId?: string; limit?: number } = {}) {
return apiRequest<{ items?: CommissionOrderItem[]; scope?: Record<string, unknown> }>('/api/commission/orders', {
query: { ...query, limit: query.limit || 50 },