feat: add coupon rule reporting

This commit is contained in:
Codex
2026-06-29 22:58:58 +08:00
parent 9b17f25dd9
commit 62c45a8fed
14 changed files with 692 additions and 35 deletions

View File

@@ -122,6 +122,15 @@ interface CouponRow {
valid_to: string | null;
max_uses: number | null;
used_count: number;
status: string;
campaign_name: string | null;
min_order_amount_cents: number;
max_discount_cents: number | null;
per_user_limit: number;
first_order_only: boolean;
allowed_plan_ids: string[];
allowed_region_ids: string[];
metadata: Record<string, unknown>;
source: string | null;
remark: string | null;
}
@@ -172,11 +181,13 @@ function safeDiscountValue(value: string | number | null) {
function calculateCouponDiscount(coupon: CouponRow, baseAmountCents: number) {
const amount = Math.max(0, Math.trunc(baseAmountCents));
if (!coupon.discount_type || coupon.discount_value === null || coupon.discount_value === undefined) return 0;
if (coupon.discount_type === 'fixed') {
return Math.min(amount, Math.max(0, Math.round(safeDiscountValue(coupon.discount_value))));
}
const percent = Math.max(0, Math.min(100, safeDiscountValue(coupon.discount_value)));
return Math.min(amount, Math.floor((amount * percent) / 100));
const discount = coupon.discount_type === 'fixed'
? Math.max(0, Math.round(safeDiscountValue(coupon.discount_value)))
: Math.floor((amount * Math.max(0, Math.min(100, safeDiscountValue(coupon.discount_value)))) / 100);
const cappedByCoupon = coupon.max_discount_cents === null || coupon.max_discount_cents === undefined
? discount
: Math.min(discount, Math.max(0, coupon.max_discount_cents));
return Math.min(amount, cappedByCoupon);
}
function calculatePlanDays(planDays: number, quantity: number) {
@@ -186,6 +197,9 @@ function calculatePlanDays(planDays: number, quantity: number) {
}
function couponValidity(coupon: CouponRow, now = new Date()) {
if (coupon.status !== 'active') {
return { valid: false, code: 'COUPON_DISABLED', message: 'Coupon is not active' };
}
if (coupon.valid_from && now < new Date(coupon.valid_from)) {
return { valid: false, code: 'COUPON_NOT_STARTED', message: 'Coupon is not active yet' };
}
@@ -198,12 +212,43 @@ function couponValidity(coupon: CouponRow, now = new Date()) {
return { valid: true, code: '', message: '' };
}
function couponArray(value: unknown) {
return Array.isArray(value) ? value.map(item => String(item)).filter(Boolean) : [];
}
function couponMetadata(coupon: CouponRow) {
return {
status: coupon.status,
campaignName: coupon.campaign_name,
minOrderAmountCents: coupon.min_order_amount_cents,
maxDiscountCents: coupon.max_discount_cents,
perUserLimit: coupon.per_user_limit,
firstOrderOnly: coupon.first_order_only,
allowedPlanIds: couponArray(coupon.allowed_plan_ids),
allowedRegionIds: couponArray(coupon.allowed_region_ids),
metadata: coupon.metadata || {},
};
}
function couponRuleSnapshot(coupon: CouponRow) {
return {
campaignName: coupon.campaign_name,
minOrderAmountCents: coupon.min_order_amount_cents,
maxDiscountCents: coupon.max_discount_cents,
perUserLimit: coupon.per_user_limit,
firstOrderOnly: coupon.first_order_only,
allowedPlanIds: couponArray(coupon.allowed_plan_ids),
allowedRegionIds: couponArray(coupon.allowed_region_ids),
};
}
function couponPayload(coupon: CouponRow, plan: PlanRow, redemption: CouponRedemptionRow | null, discountCents: number) {
return {
code: coupon.code,
discountType: coupon.discount_type,
discountValue: coupon.discount_value === null || coupon.discount_value === undefined ? null : Number(coupon.discount_value),
discountCents,
rules: couponMetadata(coupon),
source: coupon.source,
remark: coupon.remark,
redemptionId: redemption?.id || null,
@@ -241,7 +286,10 @@ async function findCouponByCode(client: pg.PoolClient, tenantId: string, code: s
const result = await client.query<CouponRow>(
`
select id, code::text as code, plan_id, discount_type, discount_value,
valid_from, valid_to, max_uses, used_count, source, remark
valid_from, valid_to, max_uses, used_count, status,
campaign_name, min_order_amount_cents, max_discount_cents,
per_user_limit, first_order_only, allowed_plan_ids, allowed_region_ids,
metadata, source, remark
from public.coupons
where tenant_id = $1 and lower(code::text) = lower($2)
limit 1
@@ -256,7 +304,10 @@ async function findCouponById(client: pg.PoolClient, tenantId: string, couponId:
const result = await client.query<CouponRow>(
`
select id, code::text as code, plan_id, discount_type, discount_value,
valid_from, valid_to, max_uses, used_count, source, remark
valid_from, valid_to, max_uses, used_count, status,
campaign_name, min_order_amount_cents, max_discount_cents,
per_user_limit, first_order_only, allowed_plan_ids, allowed_region_ids,
metadata, source, remark
from public.coupons
where tenant_id = $1 and id = $2
limit 1
@@ -274,6 +325,7 @@ async function findCouponRedemption(client: pg.PoolClient, tenantId: string, use
discount_applied_cents, region_id, source, remark, claimed_at, used_at
from public.coupon_redemptions
where tenant_id = $1 and user_id = $2 and coupon_id = $3
and status in ('claimed', 'pending')
order by created_at desc
limit 1
for update
@@ -283,6 +335,67 @@ async function findCouponRedemption(client: pg.PoolClient, tenantId: string, use
return result.rows[0] || null;
}
async function usedCouponCount(client: pg.PoolClient, tenantId: string, userId: string, couponId: string) {
const result = await client.query<{ count: string }>(
`
select count(*)::text as count
from public.coupon_redemptions
where tenant_id = $1
and user_id = $2
and coupon_id = $3
and status = 'used'
`,
[tenantId, userId, couponId],
);
return Number(result.rows[0]?.count || 0);
}
async function paidOrderCount(client: pg.PoolClient, tenantId: string, userId: string) {
const result = await client.query<{ count: string }>(
`
select count(*)::text as count
from public.orders
where tenant_id = $1
and user_id = $2
and status in ('paid', 'partially_refunded', 'refunded')
`,
[tenantId, userId],
);
return Number(result.rows[0]?.count || 0);
}
async function assertCouponBusinessRules(
client: pg.PoolClient,
coupon: CouponRow,
input: {
tenantId: string;
userId: string;
planId: string;
regionId: string | null;
baseAmountCents: number;
},
) {
const allowedPlans = couponArray(coupon.allowed_plan_ids);
if (allowedPlans.length > 0 && !allowedPlans.includes(input.planId)) {
throw new HttpError(409, 'Coupon does not apply to this plan', 'COUPON_PLAN_MISMATCH');
}
const allowedRegions = couponArray(coupon.allowed_region_ids);
if (allowedRegions.length > 0 && (!input.regionId || !allowedRegions.includes(input.regionId))) {
throw new HttpError(409, 'Coupon does not apply to this region', 'COUPON_REGION_MISMATCH');
}
if (input.baseAmountCents < Math.max(0, coupon.min_order_amount_cents || 0)) {
throw new HttpError(409, 'Order amount does not meet coupon minimum', 'COUPON_MIN_ORDER_AMOUNT_NOT_MET');
}
if (coupon.first_order_only && (await paidOrderCount(client, input.tenantId, input.userId)) > 0) {
throw new HttpError(409, 'Coupon is limited to first paid order', 'COUPON_FIRST_ORDER_ONLY');
}
const usedCount = await usedCouponCount(client, input.tenantId, input.userId, coupon.id);
if (usedCount >= Math.max(1, coupon.per_user_limit || 1)) {
const code = Math.max(1, coupon.per_user_limit || 1) === 1 ? 'COUPON_ALREADY_USED' : 'COUPON_USER_LIMIT_REACHED';
throw new HttpError(409, 'Coupon user usage limit reached', code);
}
}
async function claimCoupon(
client: pg.PoolClient,
input: {
@@ -312,9 +425,15 @@ async function claimCoupon(
if (plan.region_id && finalRegionId && plan.region_id !== finalRegionId) {
throw new HttpError(409, 'Coupon does not apply to this region', 'COUPON_REGION_MISMATCH');
}
await assertCouponBusinessRules(client, coupon, {
tenantId: input.tenantId,
userId: input.userId,
planId: plan.id,
regionId: finalRegionId,
baseAmountCents: input.baseAmountCents ?? plan.price_cents,
});
const existing = await findCouponRedemption(client, input.tenantId, input.userId, coupon.id);
if (existing?.status === 'used') throw new HttpError(409, 'Coupon already used', 'COUPON_ALREADY_USED');
if (existing && !['claimed', 'pending'].includes(existing.status)) {
throw new HttpError(409, `Coupon redemption is ${existing.status}`, 'COUPON_REDEMPTION_UNAVAILABLE');
}
@@ -729,6 +848,13 @@ export async function createOrderRoute(ctx: RequestContext) {
if (!coupon) throw new HttpError(404, 'Coupon not found', 'COUPON_NOT_FOUND');
const validity = couponValidity(coupon);
if (!validity.valid) throw new HttpError(409, validity.message, validity.code);
await assertCouponBusinessRules(client, coupon, {
tenantId,
userId,
planId,
regionId: finalRegionId,
baseAmountCents: originalAmountCents,
});
const discountCents = calculateCouponDiscount(coupon, originalAmountCents);
couponResult = { coupon, plan, redemption, discountCents, idempotent: true };
} else if (couponCode) {
@@ -835,6 +961,7 @@ export async function createOrderRoute(ctx: RequestContext) {
redemptionId: couponResult.redemption.id,
discountType: couponResult.coupon.discount_type,
discountValue: couponResult.coupon.discount_value,
couponRules: couponRuleSnapshot(couponResult.coupon),
}),
],
);

View File

@@ -15,7 +15,7 @@ const ROLE_PERMISSION_DEFAULTS: Record<string, string[]> = {
tenant_admin: ['*'],
tenant_operator: ['dashboard:read', 'content:*', 'marketing:*', 'badges:*', 'codes:read', 'coupons:read', 'referral:read', 'commission:read', 'crm:read'],
teacher: ['content:*', 'classes:read', 'students:read', 'students:notes:*', 'students:followups:*'],
sales: ['codes:*', 'coupons:*', 'referral:*', 'commission:self'],
sales: ['codes:*', 'coupons:read', 'coupons:write', 'referral:*', 'commission:self'],
agent: ['codes:read', 'coupons:read', 'referral:self', 'commission:self'],
student: [],
};
@@ -110,6 +110,7 @@ export function tenantPermissionCatalog() {
{ key: 'codes:write', label: '激活码管理' },
{ key: 'coupons:read', label: '优惠券查看' },
{ key: 'coupons:write', label: '优惠券管理' },
{ key: 'coupons:redemptions:read', label: '优惠券核销明细/报表' },
{ key: 'referral:read', label: '客资全局查看' },
{ key: 'referral:self', label: '本人客资查看' },
{ key: 'referral:write', label: '客资归属管理' },

View File

@@ -34,6 +34,8 @@ import {
authProvidersRoute,
bannersAdminRoute,
codeBatchesRoute,
couponRedemptionsRoute,
couponReportRoute,
couponsRoute,
createTenantDomainRoute,
disableTenantRoleTemplateRoute,
@@ -127,6 +129,8 @@ export const tenantAdminRoutes: RouteDefinition[] = [
['POST', '/api/tenant-admin/activation-codes/generate', generateActivationCodesRoute],
['GET', '/api/tenant-admin/coupons', couponsRoute],
['PUT', '/api/tenant-admin/coupons', upsertCouponRoute],
['GET', '/api/tenant-admin/coupons/redemptions', couponRedemptionsRoute],
['GET', '/api/tenant-admin/coupons/report', couponReportRoute],
['GET', '/api/tenant-admin/members', tenantMembersRoute],
['PUT', '/api/tenant-admin/members', upsertTenantMemberRoute],
['POST', '/api/tenant-admin/members/disable', disableTenantMemberRoute],

View File

@@ -18,6 +18,8 @@ const PAYMENT_MODES = ['platform_collect', 'tenant_collect', 'service_provider']
const PAYMENT_STATUSES = ['active', 'disabled', 'pending'];
const AUTH_STATUSES = ['active', 'disabled', 'testing'];
const DISCOUNT_TYPES = ['percent', 'fixed'];
const COUPON_STATUSES = ['active', 'disabled', 'archived'];
const COUPON_REDEMPTION_STATUSES = ['claimed', 'pending', 'used', 'cancelled', 'expired'];
const TENANT_MEMBER_ROLES = ['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher', 'sales', 'agent', 'student'];
const TENANT_MEMBER_STATUSES = ['active', 'invited', 'disabled'];
const ROLE_TEMPLATE_STATUSES = ['active', 'disabled', 'archived'];
@@ -75,6 +77,17 @@ function jsonArrayValue(value: unknown) {
return JSON.stringify(Array.isArray(value) ? value : []);
}
function uuidArrayValue(value: unknown, key: string) {
if (!Array.isArray(value)) return [];
return value.map((item, index) => {
const candidate = optionalUuidString(item, `${key}[${index}]`);
if (!candidate) {
throw new HttpError(400, `${key}[${index}] is required`, 'INVALID_UUID');
}
return candidate;
});
}
function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
@@ -2198,18 +2211,36 @@ export async function couponsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'coupons:read');
const limit = intParam(ctx, 'limit', 100, 500);
const status = stringParam(ctx, 'status');
const campaignName = stringParam(ctx, 'campaignName');
const params: unknown[] = [auth.tenantId, limit];
const filters = ['tenant_id = $1'];
if (status) {
if (!COUPON_STATUSES.includes(status)) throw new HttpError(400, 'status is invalid', 'INVALID_STATUS');
params.push(status);
filters.push(`status = $${params.length}`);
}
if (campaignName) {
params.push(campaignName);
filters.push(`campaign_name = $${params.length}`);
}
const items = await query(
`
select id, legacy_id as "legacyId", code, plan_id as "planId",
discount_type as "discountType", discount_value as "discountValue",
valid_from as "validFrom", valid_to as "validTo", max_uses as "maxUses",
used_count as "usedCount", source, remark, created_at as "createdAt", updated_at as "updatedAt"
used_count as "usedCount", status, campaign_name as "campaignName",
min_order_amount_cents as "minOrderAmountCents",
max_discount_cents as "maxDiscountCents", per_user_limit as "perUserLimit",
first_order_only as "firstOrderOnly", allowed_plan_ids as "allowedPlanIds",
allowed_region_ids as "allowedRegionIds", metadata, source, remark,
created_at as "createdAt", updated_at as "updatedAt"
from public.coupons
where tenant_id = $1
where ${filters.join(' and ')}
order by created_at desc
limit $2
`,
[auth.tenantId, limit],
params,
);
return { items };
}
@@ -2219,17 +2250,32 @@ export async function upsertCouponRoute(ctx: RequestContext) {
requireTenantPermission(auth, 'coupons:write');
const body = await readJsonBody(ctx);
const discountType = body.discountType ? optionalChoice(body.discountType, DISCOUNT_TYPES, 'fixed') : null;
const status = optionalStatus(body.status, COUPON_STATUSES, 'active');
const minOrderAmountCents = Math.max(0, intValue(body.minOrderAmountCents, 0));
const maxDiscountCents = body.maxDiscountCents === undefined || body.maxDiscountCents === null || body.maxDiscountCents === ''
? null
: Math.max(0, intValue(body.maxDiscountCents, 0));
const perUserLimit = Math.max(1, Math.min(100, intValue(body.perUserLimit, 1)));
const allowedPlanIds = uuidArrayValue(body.allowedPlanIds, 'allowedPlanIds');
const allowedRegionIds = uuidArrayValue(body.allowedRegionIds, 'allowedRegionIds');
const planId = optionalUuidString(body.planId, 'planId');
if (planId && allowedPlanIds.length > 0 && !allowedPlanIds.includes(planId)) {
allowedPlanIds.unshift(planId);
}
const item = await transaction(async client => {
const result = await client.query(
`
insert into public.coupons (
id, tenant_id, legacy_id, code, plan_id, discount_type, discount_value,
valid_from, valid_to, max_uses, source, remark
valid_from, valid_to, max_uses, status, campaign_name,
min_order_amount_cents, max_discount_cents, per_user_limit, first_order_only,
allowed_plan_ids, allowed_region_ids, metadata, source, remark
)
values (
coalesce($2::uuid, gen_random_uuid()), $1, $3, $4, $5::uuid, $6, $7,
$8::timestamptz, $9::timestamptz, $10, $11, $12
$8::timestamptz, $9::timestamptz, $10, $11, $12,
$13, $14, $15, $16, $17::uuid[], $18::uuid[], $19::jsonb, $20, $21
)
on conflict (tenant_id, code)
do update set plan_id = excluded.plan_id,
@@ -2238,31 +2284,56 @@ export async function upsertCouponRoute(ctx: RequestContext) {
valid_from = excluded.valid_from,
valid_to = excluded.valid_to,
max_uses = excluded.max_uses,
status = excluded.status,
campaign_name = excluded.campaign_name,
min_order_amount_cents = excluded.min_order_amount_cents,
max_discount_cents = excluded.max_discount_cents,
per_user_limit = excluded.per_user_limit,
first_order_only = excluded.first_order_only,
allowed_plan_ids = excluded.allowed_plan_ids,
allowed_region_ids = excluded.allowed_region_ids,
metadata = excluded.metadata,
source = excluded.source,
remark = excluded.remark,
updated_at = now()
returning id, legacy_id as "legacyId", code, plan_id as "planId",
discount_type as "discountType", discount_value as "discountValue",
valid_from as "validFrom", valid_to as "validTo", max_uses as "maxUses",
used_count as "usedCount", source, remark, updated_at as "updatedAt"
used_count as "usedCount", status, campaign_name as "campaignName",
min_order_amount_cents as "minOrderAmountCents",
max_discount_cents as "maxDiscountCents", per_user_limit as "perUserLimit",
first_order_only as "firstOrderOnly", allowed_plan_ids as "allowedPlanIds",
allowed_region_ids as "allowedRegionIds", metadata, source, remark,
updated_at as "updatedAt"
`,
[
auth.tenantId,
nullableString(body.id),
nullableString(body.legacyId),
codeValue(body),
nullableString(body.planId),
planId,
discountType,
numberValue(body.discountValue, null),
nullableString(body.validFrom),
nullableString(body.validTo),
body.maxUses === undefined ? null : intValue(body.maxUses, 0),
status,
nullableString(body.campaignName),
minOrderAmountCents,
maxDiscountCents,
perUserLimit,
boolValue(body.firstOrderOnly, false),
allowedPlanIds,
allowedRegionIds,
JSON.stringify(objectValue(body.metadata)),
nullableString(body.source),
nullableString(body.remark),
],
);
await recordAudit(client, auth, 'tenant.coupon.upserted', 'coupons', result.rows[0].id, {
code: result.rows[0].code,
status: result.rows[0].status,
campaignName: result.rows[0].campaignName,
});
return result.rows[0];
});
@@ -2270,6 +2341,175 @@ export async function upsertCouponRoute(ctx: RequestContext) {
return { item };
}
function dateParam(ctx: RequestContext, key: string, fallback: string) {
const candidate = stringParam(ctx, key) || fallback;
if (!/^\d{4}-\d{2}-\d{2}$/.test(candidate)) {
throw new HttpError(400, `${key} 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}`;
}
export async function couponRedemptionsRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'coupons:redemptions:read');
const limit = intParam(ctx, 'limit', 100, 500);
const couponId = stringParam(ctx, 'couponId');
const status = stringParam(ctx, 'status');
const params: unknown[] = [auth.tenantId, limit];
const filters = ['cr.tenant_id = $1'];
if (couponId) {
params.push(optionalUuidString(couponId, 'couponId'));
filters.push(`cr.coupon_id = $${params.length}::uuid`);
}
if (status) {
if (!COUPON_REDEMPTION_STATUSES.includes(status)) throw new HttpError(400, 'status is invalid', 'INVALID_STATUS');
params.push(status);
filters.push(`cr.status = $${params.length}`);
}
const items = await query(
`
select cr.id, cr.coupon_id as "couponId", cr.coupon_code as "couponCode",
c.campaign_name as "campaignName", cr.user_id as "userId",
u.name as "userName", u.phone as "userPhone", cr.plan_id as "planId",
p.name as "planName", cr.order_id as "orderId", o.order_no as "orderNo",
cr.status, cr.discount_applied_cents as "discountAppliedCents",
cr.region_id as "regionId", r.name as "regionName", cr.source, cr.remark,
cr.claimed_at as "claimedAt", cr.used_at as "usedAt",
cr.created_at as "createdAt", cr.updated_at as "updatedAt"
from public.coupon_redemptions cr
left join public.coupons c on c.tenant_id = cr.tenant_id and c.id = cr.coupon_id
left join public.platform_users u on u.id = cr.user_id
left join public.svip_plans p on p.tenant_id = cr.tenant_id and p.id = cr.plan_id
left join public.orders o on o.tenant_id = cr.tenant_id and o.id = cr.order_id
left join public.regions r on r.tenant_id = cr.tenant_id and r.id = cr.region_id
where ${filters.join(' and ')}
order by cr.created_at desc
limit $2
`,
params,
);
return { items };
}
export async function couponReportRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'coupons:redemptions:read');
const today = shanghaiDateKey();
const startDate = dateParam(ctx, 'startDate', today.slice(0, 8) + '01');
const endDate = dateParam(ctx, 'endDate', today);
if (startDate > endDate) throw new HttpError(400, 'startDate must be before or equal to endDate', 'INVALID_DATE_RANGE');
const couponId = stringParam(ctx, 'couponId');
const campaignName = stringParam(ctx, 'campaignName');
const params: unknown[] = [auth.tenantId, startDate, endDate];
const couponFilters = ['c.tenant_id = $1'];
const redemptionFilters = ['cr.tenant_id = $1', `cr.created_at >= $2::date`, `cr.created_at < ($3::date + interval '1 day')`];
if (couponId) {
params.push(optionalUuidString(couponId, 'couponId'));
couponFilters.push(`c.id = $${params.length}::uuid`);
redemptionFilters.push(`cr.coupon_id = $${params.length}::uuid`);
}
if (campaignName) {
params.push(campaignName);
couponFilters.push(`c.campaign_name = $${params.length}`);
redemptionFilters.push(`c.campaign_name = $${params.length}`);
}
const [summaryRows, couponRows, dailyRows, campaignRows] = await Promise.all([
query<Record<string, unknown>>(
`
select
count(*)::int as "claimCount",
count(*) filter (where cr.status = 'used')::int as "usedCount",
coalesce(sum(cr.discount_applied_cents) filter (where cr.status = 'used'), 0)::int as "discountCents",
coalesce(sum(o.amount_cents) filter (where cr.status = 'used'), 0)::int as "paidAmountCents"
from public.coupon_redemptions cr
left join public.coupons c on c.tenant_id = cr.tenant_id and c.id = cr.coupon_id
left join public.orders o on o.tenant_id = cr.tenant_id and o.id = cr.order_id
where ${redemptionFilters.join(' and ')}
`,
params,
),
query<Record<string, unknown>>(
`
select c.id, c.code::text as code, c.status, c.campaign_name as "campaignName",
c.used_count as "usedCount", c.max_uses as "maxUses",
count(cr.id)::int as "claimCount",
count(cr.id) filter (where cr.status = 'used')::int as "redeemedCount",
coalesce(sum(cr.discount_applied_cents) filter (where cr.status = 'used'), 0)::int as "discountCents",
coalesce(sum(o.amount_cents) filter (where cr.status = 'used'), 0)::int as "paidAmountCents"
from public.coupons c
left join public.coupon_redemptions cr on cr.tenant_id = c.tenant_id
and cr.coupon_id = c.id
and cr.created_at >= $2::date
and cr.created_at < ($3::date + interval '1 day')
left join public.orders o on o.tenant_id = cr.tenant_id and o.id = cr.order_id
where ${couponFilters.join(' and ')}
group by c.id, c.code, c.status, c.campaign_name, c.used_count, c.max_uses
order by "claimCount" desc, c.created_at desc
limit 200
`,
params,
),
query<Record<string, unknown>>(
`
select cr.created_at::date::text as date, cr.status,
count(*)::int as count,
coalesce(sum(cr.discount_applied_cents) filter (where cr.status = 'used'), 0)::int as "discountCents"
from public.coupon_redemptions cr
left join public.coupons c on c.tenant_id = cr.tenant_id and c.id = cr.coupon_id
where ${redemptionFilters.join(' and ')}
group by cr.created_at::date, cr.status
order by date asc, cr.status
`,
params,
),
query<Record<string, unknown>>(
`
select coalesce(c.campaign_name, '未分组') as "campaignName",
count(cr.id)::int as "claimCount",
count(cr.id) filter (where cr.status = 'used')::int as "usedCount",
coalesce(sum(cr.discount_applied_cents) filter (where cr.status = 'used'), 0)::int as "discountCents"
from public.coupon_redemptions cr
left join public.coupons c on c.tenant_id = cr.tenant_id and c.id = cr.coupon_id
where ${redemptionFilters.join(' and ')}
group by coalesce(c.campaign_name, '未分组')
order by "claimCount" desc
`,
params,
),
]);
const summary = summaryRows[0] || {};
return {
item: {
startDate,
endDate,
claimCount: intValue(summary.claimCount, 0),
usedCount: intValue(summary.usedCount, 0),
discountCents: intValue(summary.discountCents, 0),
paidAmountCents: intValue(summary.paidAmountCents, 0),
conversionRate: intValue(summary.claimCount, 0) > 0
? Number((intValue(summary.usedCount, 0) / intValue(summary.claimCount, 0)).toFixed(4))
: 0,
byCoupon: couponRows,
byCampaign: campaignRows,
daily: dailyRows,
},
};
}
export async function tenantMembersRoute(ctx: RequestContext) {
const auth = await requireTenantAdmin(ctx);
requireTenantPermission(auth, 'members:read');