feat(M07-C): 建立优惠券套餐权益核销
This commit is contained in:
@@ -42,7 +42,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062219_m06b_device_topology.up.sql',
|
||||
'database/migrations/2026062220_m06c_iot_messages.up.sql',
|
||||
'database/migrations/2026062421_m07a_wallet_ledger.up.sql',
|
||||
'database/migrations/2026062422_m07b_recharge_plans.up.sql'
|
||||
'database/migrations/2026062422_m07b_recharge_plans.up.sql',
|
||||
'database/migrations/2026062423_m07c_benefits.up.sql'
|
||||
],
|
||||
verify: [
|
||||
'database/migrations/2026061601_m01b_core_schema.verify.sql',
|
||||
@@ -66,9 +67,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
|
||||
'database/migrations/2026062219_m06b_device_topology.verify.sql',
|
||||
'database/migrations/2026062220_m06c_iot_messages.verify.sql',
|
||||
'database/migrations/2026062421_m07a_wallet_ledger.verify.sql',
|
||||
'database/migrations/2026062422_m07b_recharge_plans.verify.sql'
|
||||
'database/migrations/2026062422_m07b_recharge_plans.verify.sql',
|
||||
'database/migrations/2026062423_m07c_benefits.verify.sql'
|
||||
],
|
||||
down: [
|
||||
'database/migrations/2026062423_m07c_benefits.down.sql',
|
||||
'database/migrations/2026062422_m07b_recharge_plans.down.sql',
|
||||
'database/migrations/2026062421_m07a_wallet_ledger.down.sql',
|
||||
'database/migrations/2026062220_m06c_iot_messages.down.sql',
|
||||
@@ -228,7 +231,8 @@ export async function executeMigrationPlan(
|
||||
5, 8, 5, 2, 1,
|
||||
3, 3, 9, 1,
|
||||
1, 1, 1, 4, 7, 1,
|
||||
1, 1, 7, 7, 1
|
||||
1, 1, 7, 7, 1,
|
||||
5, 10, 7, 3, 1
|
||||
][index] ?? 1;
|
||||
if (!Array.isArray(result) || result.length < minimumRows) {
|
||||
throw new Error(
|
||||
|
||||
@@ -0,0 +1,461 @@
|
||||
import type { PoolConnection, ResultSetHeader, RowDataPacket } from 'mysql2/promise';
|
||||
import type { MySqlPool } from '../db/mysql.js';
|
||||
|
||||
interface CouponRow extends RowDataPacket {
|
||||
id: string;
|
||||
status: string;
|
||||
validFrom: Date;
|
||||
validTo: Date;
|
||||
remainingUses: number;
|
||||
couponType: 'TIME' | 'FULL_REDUCTION';
|
||||
discountAmountCents: number;
|
||||
timeMinutes: number;
|
||||
minOrderAmountCents: number;
|
||||
storeId: string | null;
|
||||
roomCategoryId: string | null;
|
||||
roomId: string | null;
|
||||
weekdaysJson: string | number[] | null;
|
||||
holidayOnly: number;
|
||||
}
|
||||
|
||||
interface PackageRow extends RowDataPacket {
|
||||
id: string;
|
||||
status: string;
|
||||
validFrom: Date;
|
||||
validTo: Date;
|
||||
remainingMinutes: number;
|
||||
remainingAmountCents: number;
|
||||
storeId: string | null;
|
||||
roomCategoryId: string | null;
|
||||
roomId: string | null;
|
||||
weekdaysJson: string | number[] | null;
|
||||
holidayOnly: number;
|
||||
}
|
||||
|
||||
interface UsageRow extends RowDataPacket {
|
||||
id: string;
|
||||
benefitType: 'COUPON' | 'PACKAGE';
|
||||
benefitId: string;
|
||||
status: string;
|
||||
discountCents: number;
|
||||
packageCreditCents: number;
|
||||
minutes: number;
|
||||
}
|
||||
|
||||
export class MarketingBenefitError extends Error {
|
||||
constructor(public readonly code: string) { super(code); }
|
||||
}
|
||||
|
||||
export class MarketingBenefitService {
|
||||
constructor(private readonly pool: MySqlPool) {}
|
||||
|
||||
async reserveForOrder(input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
orderId: string;
|
||||
storeId: string;
|
||||
roomId?: string | null;
|
||||
roomCategoryId?: string | null;
|
||||
orderAmountCents: number;
|
||||
orderStartAt: Date;
|
||||
isHoliday?: boolean;
|
||||
couponGrantId?: string | null;
|
||||
packageHoldingId?: string | null;
|
||||
packageMinutes?: number;
|
||||
packageCreditCents?: number;
|
||||
clientRequestId: string;
|
||||
traceId: string;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const duplicate = await this.findUsageByRequest(connection, input);
|
||||
if (duplicate.length > 0) return reserveResponse(duplicate, true);
|
||||
|
||||
const usages: UsageRow[] = [];
|
||||
if (input.couponGrantId) {
|
||||
const coupon = await this.loadCoupon(connection, input, input.couponGrantId);
|
||||
assertUsableBenefit(coupon, input, 'COUPON');
|
||||
const discountCents = coupon.couponType === 'FULL_REDUCTION'
|
||||
? Math.min(Number(coupon.discountAmountCents), input.orderAmountCents)
|
||||
: 0;
|
||||
const minutes = coupon.couponType === 'TIME' ? Number(coupon.timeMinutes) : 0;
|
||||
const usage = await this.insertUsage(connection, {
|
||||
...input,
|
||||
benefitType: 'COUPON',
|
||||
benefitId: coupon.id,
|
||||
discountCents,
|
||||
packageCreditCents: 0,
|
||||
minutes
|
||||
});
|
||||
await connection.execute(
|
||||
`UPDATE qipai_coupon_grants
|
||||
SET status = 'FROZEN', frozen_order_id = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.orderId, input.tenantId, coupon.id]
|
||||
);
|
||||
usages.push(usage);
|
||||
}
|
||||
|
||||
if (input.packageHoldingId) {
|
||||
const holding = await this.loadPackageHolding(connection, input, input.packageHoldingId);
|
||||
assertUsableBenefit(holding, input, 'PACKAGE');
|
||||
const packageCreditCents = input.packageCreditCents ?? 0;
|
||||
const minutes = input.packageMinutes ?? 0;
|
||||
if (minutes <= 0 && packageCreditCents <= 0) {
|
||||
throw new MarketingBenefitError('BENEFIT_PACKAGE_USAGE_EMPTY');
|
||||
}
|
||||
if (minutes > Number(holding.remainingMinutes)) {
|
||||
throw new MarketingBenefitError('BENEFIT_PACKAGE_MINUTES_INSUFFICIENT');
|
||||
}
|
||||
if (packageCreditCents > Number(holding.remainingAmountCents)) {
|
||||
throw new MarketingBenefitError('BENEFIT_PACKAGE_AMOUNT_INSUFFICIENT');
|
||||
}
|
||||
const usage = await this.insertUsage(connection, {
|
||||
...input,
|
||||
benefitType: 'PACKAGE',
|
||||
benefitId: holding.id,
|
||||
discountCents: 0,
|
||||
packageCreditCents,
|
||||
minutes
|
||||
});
|
||||
await connection.execute(
|
||||
`UPDATE qipai_package_holdings
|
||||
SET status = 'FROZEN', frozen_order_id = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.orderId, input.tenantId, holding.id]
|
||||
);
|
||||
usages.push(usage);
|
||||
}
|
||||
|
||||
if (usages.length === 0) {
|
||||
throw new MarketingBenefitError('BENEFIT_EMPTY');
|
||||
}
|
||||
return reserveResponse(usages, false);
|
||||
});
|
||||
}
|
||||
|
||||
async confirmReserved(input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
orderId: string;
|
||||
traceId: string;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const usages = await this.loadOrderUsages(connection, input, true);
|
||||
if (usages.length === 0) throw new MarketingBenefitError('BENEFIT_USAGE_NOT_FOUND');
|
||||
if (usages.every((usage) => usage.status === 'CONFIRMED')) {
|
||||
return reserveResponse(usages, true);
|
||||
}
|
||||
for (const usage of usages) {
|
||||
if (usage.status !== 'FROZEN') {
|
||||
throw new MarketingBenefitError('BENEFIT_USAGE_STATUS_INVALID');
|
||||
}
|
||||
if (usage.benefitType === 'COUPON') {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_coupon_grants
|
||||
SET status = CASE WHEN remaining_uses <= 1 THEN 'USED' ELSE 'AVAILABLE' END,
|
||||
remaining_uses = GREATEST(remaining_uses - 1, 0),
|
||||
used_count = used_count + 1,
|
||||
used_at = UTC_TIMESTAMP(3),
|
||||
frozen_order_id = NULL
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.tenantId, usage.benefitId]
|
||||
);
|
||||
} else {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_package_holdings
|
||||
SET remaining_minutes = GREATEST(remaining_minutes - ?, 0),
|
||||
remaining_amount_cents = GREATEST(remaining_amount_cents - ?, 0),
|
||||
status = CASE
|
||||
WHEN remaining_minutes <= ? AND remaining_amount_cents <= ? THEN 'EXHAUSTED'
|
||||
ELSE 'ACTIVE'
|
||||
END,
|
||||
frozen_order_id = NULL
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[
|
||||
usage.minutes, usage.packageCreditCents,
|
||||
usage.minutes, usage.packageCreditCents,
|
||||
input.tenantId, usage.benefitId
|
||||
]
|
||||
);
|
||||
}
|
||||
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'CONFIRMED');
|
||||
}
|
||||
return reserveResponse(usages.map((usage) => ({ ...usage, status: 'CONFIRMED' })), false);
|
||||
});
|
||||
}
|
||||
|
||||
async releaseReserved(input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
orderId: string;
|
||||
traceId: string;
|
||||
}) {
|
||||
return this.transaction(async (connection) => {
|
||||
const usages = await this.loadOrderUsages(connection, input, true);
|
||||
if (usages.length === 0) throw new MarketingBenefitError('BENEFIT_USAGE_NOT_FOUND');
|
||||
if (usages.every((usage) => usage.status === 'RELEASED')) {
|
||||
return reserveResponse(usages, true);
|
||||
}
|
||||
for (const usage of usages) {
|
||||
if (usage.status !== 'FROZEN') {
|
||||
throw new MarketingBenefitError('BENEFIT_USAGE_STATUS_INVALID');
|
||||
}
|
||||
if (usage.benefitType === 'COUPON') {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_coupon_grants
|
||||
SET status = 'AVAILABLE', frozen_order_id = NULL
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.tenantId, usage.benefitId]
|
||||
);
|
||||
} else {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_package_holdings
|
||||
SET status = 'ACTIVE', frozen_order_id = NULL
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[input.tenantId, usage.benefitId]
|
||||
);
|
||||
}
|
||||
await this.updateUsageStatus(connection, input.tenantId, usage.id, 'RELEASED');
|
||||
}
|
||||
return reserveResponse(usages.map((usage) => ({ ...usage, status: 'RELEASED' })), false);
|
||||
});
|
||||
}
|
||||
|
||||
private async loadCoupon(
|
||||
connection: PoolConnection,
|
||||
input: { tenantId: string; userId: string },
|
||||
couponGrantId: string
|
||||
) {
|
||||
const [rows] = await connection.execute<CouponRow[]>(
|
||||
`SELECT g.id, g.status, g.valid_from AS validFrom, g.valid_to AS validTo,
|
||||
g.remaining_uses AS remainingUses,
|
||||
t.coupon_type AS couponType, t.discount_amount_cents AS discountAmountCents,
|
||||
t.time_minutes AS timeMinutes, t.min_order_amount_cents AS minOrderAmountCents,
|
||||
t.store_id AS storeId, t.room_category_id AS roomCategoryId,
|
||||
t.room_id AS roomId, t.weekdays_json AS weekdaysJson, t.holiday_only AS holidayOnly
|
||||
FROM qipai_coupon_grants g
|
||||
INNER JOIN qipai_coupon_templates t
|
||||
ON t.tenant_id = g.tenant_id AND t.id = g.template_id
|
||||
WHERE g.tenant_id = ? AND g.user_id = ? AND g.id = ?
|
||||
AND t.status = 'ACTIVE' AND t.deleted_at IS NULL
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.userId, couponGrantId]
|
||||
);
|
||||
if (!rows[0]) throw new MarketingBenefitError('BENEFIT_COUPON_NOT_FOUND');
|
||||
return normalizeScopeRow(rows[0]);
|
||||
}
|
||||
|
||||
private async loadPackageHolding(
|
||||
connection: PoolConnection,
|
||||
input: { tenantId: string; userId: string },
|
||||
packageHoldingId: string
|
||||
) {
|
||||
const [rows] = await connection.execute<PackageRow[]>(
|
||||
`SELECT h.id, h.status, h.valid_from AS validFrom, h.valid_to AS validTo,
|
||||
h.remaining_minutes AS remainingMinutes,
|
||||
h.remaining_amount_cents AS remainingAmountCents,
|
||||
p.store_id AS storeId, p.room_category_id AS roomCategoryId,
|
||||
p.room_id AS roomId, p.weekdays_json AS weekdaysJson, p.holiday_only AS holidayOnly
|
||||
FROM qipai_package_holdings h
|
||||
INNER JOIN qipai_package_plans p
|
||||
ON p.tenant_id = h.tenant_id AND p.id = h.plan_id
|
||||
WHERE h.tenant_id = ? AND h.user_id = ? AND h.id = ?
|
||||
AND p.status = 'ACTIVE' AND p.deleted_at IS NULL
|
||||
FOR UPDATE`,
|
||||
[input.tenantId, input.userId, packageHoldingId]
|
||||
);
|
||||
if (!rows[0]) throw new MarketingBenefitError('BENEFIT_PACKAGE_NOT_FOUND');
|
||||
return normalizeScopeRow(rows[0]);
|
||||
}
|
||||
|
||||
private async findUsageByRequest(
|
||||
connection: PoolConnection,
|
||||
input: { tenantId: string; userId: string; clientRequestId: string }
|
||||
) {
|
||||
const [rows] = await connection.execute<UsageRow[]>(
|
||||
`SELECT id, benefit_type AS benefitType, benefit_id AS benefitId, status,
|
||||
discount_cents AS discountCents,
|
||||
package_credit_cents AS packageCreditCents, minutes
|
||||
FROM qipai_benefit_usages
|
||||
WHERE tenant_id = ? AND user_id = ? AND client_request_id = ?`,
|
||||
[input.tenantId, input.userId, input.clientRequestId]
|
||||
);
|
||||
return rows.map(normalizeUsageRow);
|
||||
}
|
||||
|
||||
private async loadOrderUsages(
|
||||
connection: PoolConnection,
|
||||
input: { tenantId: string; userId: string; orderId: string },
|
||||
lock: boolean
|
||||
) {
|
||||
const [rows] = await connection.execute<UsageRow[]>(
|
||||
`SELECT id, benefit_type AS benefitType, benefit_id AS benefitId, status,
|
||||
discount_cents AS discountCents,
|
||||
package_credit_cents AS packageCreditCents, minutes
|
||||
FROM qipai_benefit_usages
|
||||
WHERE tenant_id = ? AND user_id = ? AND order_id = ?
|
||||
${lock ? 'FOR UPDATE' : ''}`,
|
||||
[input.tenantId, input.userId, input.orderId]
|
||||
);
|
||||
return rows.map(normalizeUsageRow);
|
||||
}
|
||||
|
||||
private async insertUsage(
|
||||
connection: PoolConnection,
|
||||
input: {
|
||||
tenantId: string;
|
||||
userId: string;
|
||||
orderId: string;
|
||||
clientRequestId: string;
|
||||
traceId: string;
|
||||
benefitType: 'COUPON' | 'PACKAGE';
|
||||
benefitId: string;
|
||||
discountCents: number;
|
||||
packageCreditCents: number;
|
||||
minutes: number;
|
||||
}
|
||||
) {
|
||||
const [result] = await connection.execute<ResultSetHeader>(
|
||||
`INSERT INTO qipai_benefit_usages
|
||||
(tenant_id, user_id, order_id, benefit_type, benefit_id,
|
||||
client_request_id, discount_cents, package_credit_cents, minutes, trace_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
input.tenantId, input.userId, input.orderId, input.benefitType, input.benefitId,
|
||||
input.clientRequestId, input.discountCents, input.packageCreditCents,
|
||||
input.minutes, input.traceId
|
||||
]
|
||||
);
|
||||
return {
|
||||
id: String(result.insertId),
|
||||
benefitType: input.benefitType,
|
||||
benefitId: input.benefitId,
|
||||
status: 'FROZEN',
|
||||
discountCents: input.discountCents,
|
||||
packageCreditCents: input.packageCreditCents,
|
||||
minutes: input.minutes
|
||||
} as UsageRow;
|
||||
}
|
||||
|
||||
private async updateUsageStatus(
|
||||
connection: PoolConnection,
|
||||
tenantId: string,
|
||||
usageId: string,
|
||||
status: 'CONFIRMED' | 'RELEASED'
|
||||
) {
|
||||
await connection.execute(
|
||||
`UPDATE qipai_benefit_usages
|
||||
SET status = ?
|
||||
WHERE tenant_id = ? AND id = ?`,
|
||||
[status, tenantId, usageId]
|
||||
);
|
||||
}
|
||||
|
||||
private async transaction<T>(work: (connection: PoolConnection) => Promise<T>) {
|
||||
const connection = await this.pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
const result = await work(connection);
|
||||
await connection.commit();
|
||||
return result;
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertUsableBenefit(
|
||||
row: CouponRow | PackageRow,
|
||||
input: {
|
||||
storeId: string;
|
||||
roomId?: string | null;
|
||||
roomCategoryId?: string | null;
|
||||
orderAmountCents: number;
|
||||
orderStartAt: Date;
|
||||
isHoliday?: boolean;
|
||||
},
|
||||
type: 'COUPON' | 'PACKAGE'
|
||||
) {
|
||||
if (row.status !== 'AVAILABLE' && row.status !== 'ACTIVE') {
|
||||
throw new MarketingBenefitError(`BENEFIT_${type}_STATUS_INVALID`);
|
||||
}
|
||||
const orderTime = input.orderStartAt.getTime();
|
||||
if (row.validFrom.getTime() > orderTime || row.validTo.getTime() <= orderTime) {
|
||||
throw new MarketingBenefitError(`BENEFIT_${type}_EXPIRED`);
|
||||
}
|
||||
if ('remainingUses' in row && Number(row.remainingUses) <= 0) {
|
||||
throw new MarketingBenefitError('BENEFIT_COUPON_USED_UP');
|
||||
}
|
||||
if ('minOrderAmountCents' in row && input.orderAmountCents < Number(row.minOrderAmountCents)) {
|
||||
throw new MarketingBenefitError('BENEFIT_COUPON_MIN_AMOUNT_NOT_MET');
|
||||
}
|
||||
if (row.storeId && row.storeId !== input.storeId) {
|
||||
throw new MarketingBenefitError(`BENEFIT_${type}_STORE_MISMATCH`);
|
||||
}
|
||||
if (row.roomCategoryId && row.roomCategoryId !== (input.roomCategoryId ?? null)) {
|
||||
throw new MarketingBenefitError(`BENEFIT_${type}_ROOM_CATEGORY_MISMATCH`);
|
||||
}
|
||||
if (row.roomId && row.roomId !== (input.roomId ?? null)) {
|
||||
throw new MarketingBenefitError(`BENEFIT_${type}_ROOM_MISMATCH`);
|
||||
}
|
||||
if (Number(row.holidayOnly) === 1 && input.isHoliday !== true) {
|
||||
throw new MarketingBenefitError(`BENEFIT_${type}_HOLIDAY_ONLY`);
|
||||
}
|
||||
const weekdays = parseWeekdays(row.weekdaysJson);
|
||||
if (weekdays.length > 0 && !weekdays.includes(input.orderStartAt.getUTCDay())) {
|
||||
throw new MarketingBenefitError(`BENEFIT_${type}_WEEKDAY_MISMATCH`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeScopeRow<T extends CouponRow | PackageRow>(row: T): T {
|
||||
return {
|
||||
...row,
|
||||
id: String(row.id),
|
||||
storeId: row.storeId === null ? null : String(row.storeId),
|
||||
roomCategoryId: row.roomCategoryId === null ? null : String(row.roomCategoryId),
|
||||
roomId: row.roomId === null ? null : String(row.roomId)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUsageRow(row: UsageRow): UsageRow {
|
||||
return {
|
||||
...row,
|
||||
id: String(row.id),
|
||||
benefitId: String(row.benefitId),
|
||||
discountCents: Number(row.discountCents),
|
||||
packageCreditCents: Number(row.packageCreditCents),
|
||||
minutes: Number(row.minutes)
|
||||
};
|
||||
}
|
||||
|
||||
function parseWeekdays(value: string | number[] | null): number[] {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value.map(Number);
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed.map(Number) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function reserveResponse(usages: UsageRow[], idempotent: boolean) {
|
||||
return {
|
||||
idempotent,
|
||||
status: usages.every((usage) => usage.status === usages[0]?.status)
|
||||
? usages[0]?.status ?? 'EMPTY'
|
||||
: 'MIXED',
|
||||
discountCents: usages.reduce((sum, usage) => sum + Number(usage.discountCents), 0),
|
||||
packageCreditCents: usages.reduce((sum, usage) => sum + Number(usage.packageCreditCents), 0),
|
||||
minutes: usages.reduce((sum, usage) => sum + Number(usage.minutes), 0),
|
||||
usages: usages.map((usage) => ({
|
||||
usageId: String(usage.id),
|
||||
benefitType: usage.benefitType,
|
||||
benefitId: String(usage.benefitId),
|
||||
status: usage.status
|
||||
}))
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user