forked from wangziqi/gongxue-base
feat: add referral conversion report
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user