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

@@ -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');