forked from wangziqi/gongxue-base
feat: add coupon rule reporting
This commit is contained in:
@@ -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),
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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: '客资归属管理' },
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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