forked from wangziqi/gongxue-base
feat: add coupon rule reporting
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user