feat(M07-C): 建立优惠券套餐权益核销
This commit is contained in:
@@ -17,7 +17,7 @@
|
||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||
"test:mysql:migration": "npm run build && node tests/mysql-migration-roundtrip.test.mjs",
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs"
|
||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/mqtt-service.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs && node tests/task-repository.test.mjs && node tests/platform-config-repository.test.mjs && node tests/auth.test.mjs && node tests/rbac.test.mjs && node tests/user-management.test.mjs && node tests/store-room.test.mjs && node tests/content-management.test.mjs && node tests/store-discovery.test.mjs && node tests/store-access.test.mjs && node tests/pricing.test.mjs && node tests/order-state.test.mjs && node tests/order-management.test.mjs && node tests/order-share.test.mjs && node tests/payment.test.mjs && node tests/wechat-pay.test.mjs && node tests/third-party.test.mjs && node tests/profit-sharing.test.mjs && node tests/device.test.mjs && node tests/iot-protocol.test.mjs && node tests/device-control.test.mjs && node tests/order-device-automation.test.mjs && node tests/hardware-smoke-runner.test.mjs && node tests/wallet-ledger.test.mjs && node tests/recharge-service.test.mjs && node tests/marketing-benefit-service.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^11.2.0",
|
||||
|
||||
@@ -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
|
||||
}))
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
MarketingBenefitError,
|
||||
MarketingBenefitService
|
||||
} from '../dist/wallets/marketing-benefit-service.js';
|
||||
|
||||
const validFrom = new Date(Date.now() - 86_400_000);
|
||||
const validTo = new Date(Date.now() + 86_400_000);
|
||||
|
||||
function createHarness(overrides = {}) {
|
||||
const state = {
|
||||
coupon: {
|
||||
id: '401',
|
||||
status: 'AVAILABLE',
|
||||
validFrom,
|
||||
validTo,
|
||||
remainingUses: 1,
|
||||
couponType: 'FULL_REDUCTION',
|
||||
discountAmountCents: 3000,
|
||||
timeMinutes: 0,
|
||||
minOrderAmountCents: 10000,
|
||||
storeId: '11',
|
||||
roomCategoryId: null,
|
||||
roomId: null,
|
||||
weekdaysJson: null,
|
||||
holidayOnly: 0,
|
||||
...overrides.coupon
|
||||
},
|
||||
packageHolding: {
|
||||
id: '501',
|
||||
status: 'ACTIVE',
|
||||
validFrom,
|
||||
validTo,
|
||||
remainingMinutes: 180,
|
||||
remainingAmountCents: 6000,
|
||||
storeId: '11',
|
||||
roomCategoryId: null,
|
||||
roomId: null,
|
||||
weekdaysJson: null,
|
||||
holidayOnly: 0,
|
||||
...overrides.packageHolding
|
||||
},
|
||||
usages: [],
|
||||
nextUsageId: 700
|
||||
};
|
||||
|
||||
const connection = {
|
||||
async beginTransaction() {},
|
||||
async commit() {},
|
||||
async rollback() {},
|
||||
release() {},
|
||||
async execute(sql, params) {
|
||||
if (sql.includes('FROM qipai_benefit_usages') && sql.includes('client_request_id')) {
|
||||
return [state.usages.filter((usage) =>
|
||||
usage.tenantId === String(params[0])
|
||||
&& usage.userId === String(params[1])
|
||||
&& usage.clientRequestId === params[2]
|
||||
).map(toUsageRow), []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_coupon_grants')) {
|
||||
if (state.coupon && String(params[2]) === state.coupon.id) {
|
||||
return [[state.coupon], []];
|
||||
}
|
||||
return [[], []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_package_holdings')) {
|
||||
if (state.packageHolding && String(params[2]) === state.packageHolding.id) {
|
||||
return [[state.packageHolding], []];
|
||||
}
|
||||
return [[], []];
|
||||
}
|
||||
if (sql.includes('INSERT INTO qipai_benefit_usages')) {
|
||||
const usage = {
|
||||
id: String(state.nextUsageId++),
|
||||
tenantId: String(params[0]),
|
||||
userId: String(params[1]),
|
||||
orderId: String(params[2]),
|
||||
benefitType: params[3],
|
||||
benefitId: String(params[4]),
|
||||
clientRequestId: params[5],
|
||||
discountCents: Number(params[6]),
|
||||
packageCreditCents: Number(params[7]),
|
||||
minutes: Number(params[8]),
|
||||
traceId: params[9],
|
||||
status: 'FROZEN'
|
||||
};
|
||||
state.usages.push(usage);
|
||||
return [{ insertId: usage.id, affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes("SET status = 'FROZEN'") && sql.includes('qipai_coupon_grants')) {
|
||||
state.coupon.status = 'FROZEN';
|
||||
state.coupon.frozenOrderId = String(params[0]);
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes("SET status = 'FROZEN'") && sql.includes('qipai_package_holdings')) {
|
||||
state.packageHolding.status = 'FROZEN';
|
||||
state.packageHolding.frozenOrderId = String(params[0]);
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes('FROM qipai_benefit_usages') && sql.includes('order_id')) {
|
||||
return [state.usages.filter((usage) =>
|
||||
usage.tenantId === String(params[0])
|
||||
&& usage.userId === String(params[1])
|
||||
&& usage.orderId === String(params[2])
|
||||
).map(toUsageRow), []];
|
||||
}
|
||||
if (sql.includes('UPDATE qipai_coupon_grants') && sql.includes("used_count = used_count + 1")) {
|
||||
state.coupon.remainingUses = Math.max(state.coupon.remainingUses - 1, 0);
|
||||
state.coupon.status = state.coupon.remainingUses === 0 ? 'USED' : 'AVAILABLE';
|
||||
state.coupon.frozenOrderId = null;
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes('UPDATE qipai_package_holdings') && sql.includes('remaining_minutes')) {
|
||||
const usage = state.usages.find((item) =>
|
||||
item.benefitType === 'PACKAGE' && item.benefitId === state.packageHolding.id
|
||||
);
|
||||
state.packageHolding.remainingMinutes -= usage.minutes;
|
||||
state.packageHolding.remainingAmountCents -= usage.packageCreditCents;
|
||||
state.packageHolding.status =
|
||||
state.packageHolding.remainingMinutes === 0
|
||||
&& state.packageHolding.remainingAmountCents === 0
|
||||
? 'EXHAUSTED'
|
||||
: 'ACTIVE';
|
||||
state.packageHolding.frozenOrderId = null;
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes('UPDATE qipai_benefit_usages')) {
|
||||
const usage = state.usages.find((item) =>
|
||||
item.tenantId === String(params[1]) && item.id === String(params[2])
|
||||
);
|
||||
usage.status = params[0];
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes("SET status = 'AVAILABLE'")) {
|
||||
state.coupon.status = 'AVAILABLE';
|
||||
state.coupon.frozenOrderId = null;
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
if (sql.includes("SET status = 'ACTIVE'")) {
|
||||
state.packageHolding.status = 'ACTIVE';
|
||||
state.packageHolding.frozenOrderId = null;
|
||||
return [{ affectedRows: 1 }, []];
|
||||
}
|
||||
throw new Error(`Unexpected SQL: ${sql}`);
|
||||
}
|
||||
};
|
||||
const pool = {
|
||||
async getConnection() {
|
||||
return connection;
|
||||
}
|
||||
};
|
||||
return { service: new MarketingBenefitService(pool), state };
|
||||
}
|
||||
|
||||
function toUsageRow(usage) {
|
||||
return {
|
||||
id: usage.id,
|
||||
benefitType: usage.benefitType,
|
||||
benefitId: usage.benefitId,
|
||||
status: usage.status,
|
||||
discountCents: usage.discountCents,
|
||||
packageCreditCents: usage.packageCreditCents,
|
||||
minutes: usage.minutes
|
||||
};
|
||||
}
|
||||
|
||||
const baseInput = {
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
orderId: '31',
|
||||
storeId: '11',
|
||||
orderAmountCents: 12000,
|
||||
orderStartAt: new Date('2026-06-25T02:00:00.000Z'),
|
||||
couponGrantId: '401',
|
||||
packageHoldingId: '501',
|
||||
packageMinutes: 60,
|
||||
packageCreditCents: 2000,
|
||||
clientRequestId: 'benefit-001',
|
||||
traceId: 'benefit-test'
|
||||
};
|
||||
|
||||
{
|
||||
const { service, state } = createHarness();
|
||||
const reserved = await service.reserveForOrder(baseInput);
|
||||
assert.equal(reserved.status, 'FROZEN');
|
||||
assert.equal(reserved.discountCents, 3000);
|
||||
assert.equal(reserved.packageCreditCents, 2000);
|
||||
assert.equal(reserved.minutes, 60);
|
||||
assert.equal(state.coupon.status, 'FROZEN');
|
||||
assert.equal(state.packageHolding.status, 'FROZEN');
|
||||
|
||||
const duplicate = await service.reserveForOrder(baseInput);
|
||||
assert.equal(duplicate.idempotent, true);
|
||||
assert.equal(state.usages.length, 2);
|
||||
|
||||
const confirmed = await service.confirmReserved({
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
orderId: '31',
|
||||
traceId: 'benefit-test-confirm'
|
||||
});
|
||||
assert.equal(confirmed.status, 'CONFIRMED');
|
||||
assert.equal(state.coupon.status, 'USED');
|
||||
assert.equal(state.packageHolding.status, 'ACTIVE');
|
||||
assert.equal(state.packageHolding.remainingMinutes, 120);
|
||||
assert.equal(state.packageHolding.remainingAmountCents, 4000);
|
||||
|
||||
const confirmAgain = await service.confirmReserved({
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
orderId: '31',
|
||||
traceId: 'benefit-test-confirm'
|
||||
});
|
||||
assert.equal(confirmAgain.idempotent, true);
|
||||
}
|
||||
|
||||
{
|
||||
const { service } = createHarness({ coupon: { minOrderAmountCents: 20000 } });
|
||||
await assert.rejects(
|
||||
() => service.reserveForOrder(baseInput),
|
||||
(error) => error instanceof MarketingBenefitError
|
||||
&& error.code === 'BENEFIT_COUPON_MIN_AMOUNT_NOT_MET'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const { service } = createHarness({ packageHolding: { remainingMinutes: 30 } });
|
||||
await assert.rejects(
|
||||
() => service.reserveForOrder(baseInput),
|
||||
(error) => error instanceof MarketingBenefitError
|
||||
&& error.code === 'BENEFIT_PACKAGE_MINUTES_INSUFFICIENT'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const { service, state } = createHarness();
|
||||
await service.reserveForOrder({ ...baseInput, clientRequestId: 'benefit-release' });
|
||||
const released = await service.releaseReserved({
|
||||
tenantId: '7',
|
||||
userId: '21',
|
||||
orderId: '31',
|
||||
traceId: 'benefit-test-release'
|
||||
});
|
||||
assert.equal(released.status, 'RELEASED');
|
||||
assert.equal(state.coupon.status, 'AVAILABLE');
|
||||
assert.equal(state.packageHolding.status, 'ACTIVE');
|
||||
assert.equal(state.packageHolding.remainingMinutes, 180);
|
||||
}
|
||||
|
||||
console.log('PASS: M07-C benefits freeze, confirm, release and validate coupon/package usage.');
|
||||
@@ -75,6 +75,9 @@ const walletVerifySql = read('database/migrations/2026062421_m07a_wallet_ledger.
|
||||
const rechargeUpSql = read('database/migrations/2026062422_m07b_recharge_plans.up.sql');
|
||||
const rechargeDownSql = read('database/migrations/2026062422_m07b_recharge_plans.down.sql');
|
||||
const rechargeVerifySql = read('database/migrations/2026062422_m07b_recharge_plans.verify.sql');
|
||||
const benefitUpSql = read('database/migrations/2026062423_m07c_benefits.up.sql');
|
||||
const benefitDownSql = read('database/migrations/2026062423_m07c_benefits.down.sql');
|
||||
const benefitVerifySql = read('database/migrations/2026062423_m07c_benefits.verify.sql');
|
||||
|
||||
const coreTables = [
|
||||
'qipai_schema_migrations',
|
||||
@@ -346,4 +349,19 @@ assert.match(rechargeUpSql, /UNIQUE KEY uq_qipai_recharge_order_request/);
|
||||
assert.match(rechargeUpSql, /payment_id BIGINT UNSIGNED NULL/);
|
||||
assert.match(rechargeUpSql, /credited_at DATETIME\(3\) NULL/);
|
||||
|
||||
console.log('PASS: M01-B through M07-B migration contracts are present.');
|
||||
for (const table of [
|
||||
'qipai_coupon_templates', 'qipai_coupon_grants', 'qipai_package_plans',
|
||||
'qipai_package_holdings', 'qipai_benefit_usages'
|
||||
]) {
|
||||
assert.match(benefitUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
|
||||
assert.match(benefitDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
|
||||
assert.match(benefitVerifySql, new RegExp(`'${table}'`));
|
||||
}
|
||||
assert.match(benefitUpSql, /coupon_type VARCHAR/);
|
||||
assert.match(benefitUpSql, /weekdays_json JSON/);
|
||||
assert.match(benefitUpSql, /remaining_minutes INT UNSIGNED/);
|
||||
assert.match(benefitUpSql, /remaining_amount_cents INT UNSIGNED/);
|
||||
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_request/);
|
||||
assert.match(benefitUpSql, /UNIQUE KEY uq_qipai_benefit_usage_order_benefit/);
|
||||
|
||||
console.log('PASS: M01-B through M07-C migration contracts are present.');
|
||||
|
||||
@@ -33,7 +33,8 @@ assert.match(plan.file, /2026062218_m05d_profit_sharing\.up\.sql/);
|
||||
assert.match(plan.file, /2026062219_m06b_device_topology\.up\.sql/);
|
||||
assert.match(plan.file, /2026062220_m06c_iot_messages\.up\.sql/);
|
||||
assert.match(plan.file, /2026062421_m07a_wallet_ledger\.up\.sql/);
|
||||
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql$/);
|
||||
assert.match(plan.file, /2026062422_m07b_recharge_plans\.up\.sql/);
|
||||
assert.match(plan.file, /2026062423_m07c_benefits\.up\.sql$/);
|
||||
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
|
||||
assert.ok(plan.statements.length >= 11);
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
DROP TABLE IF EXISTS qipai_benefit_usages;
|
||||
DROP TABLE IF EXISTS qipai_package_holdings;
|
||||
DROP TABLE IF EXISTS qipai_package_plans;
|
||||
DROP TABLE IF EXISTS qipai_coupon_grants;
|
||||
DROP TABLE IF EXISTS qipai_coupon_templates;
|
||||
DELETE FROM qipai_schema_migrations WHERE version = '2026062423';
|
||||
@@ -0,0 +1,149 @@
|
||||
CREATE TABLE IF NOT EXISTS qipai_coupon_templates (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
coupon_type VARCHAR(32) NOT NULL,
|
||||
discount_amount_cents INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
time_minutes INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
min_order_amount_cents INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
room_category_id BIGINT UNSIGNED NULL,
|
||||
room_id BIGINT UNSIGNED NULL,
|
||||
weekdays_json JSON NULL,
|
||||
holiday_only TINYINT(1) NOT NULL DEFAULT 0,
|
||||
starts_at DATETIME(3) NULL,
|
||||
ends_at DATETIME(3) NULL,
|
||||
valid_days INT UNSIGNED NULL,
|
||||
usage_limit_per_user INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
deleted_at DATETIME(3) NULL,
|
||||
CONSTRAINT fk_qipai_coupon_template_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_coupon_template_store
|
||||
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||
CONSTRAINT fk_qipai_coupon_template_room_category
|
||||
FOREIGN KEY (room_category_id) REFERENCES qipai_room_categories(id),
|
||||
CONSTRAINT fk_qipai_coupon_template_room
|
||||
FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
|
||||
KEY idx_qipai_coupon_template_scope
|
||||
(tenant_id, store_id, coupon_type, status, starts_at, ends_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_coupon_grants (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
template_id BIGINT UNSIGNED NOT NULL,
|
||||
coupon_code VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'AVAILABLE',
|
||||
valid_from DATETIME(3) NOT NULL,
|
||||
valid_to DATETIME(3) NOT NULL,
|
||||
remaining_uses INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
used_count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
frozen_order_id BIGINT UNSIGNED NULL,
|
||||
used_at DATETIME(3) NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_coupon_grant_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_coupon_grant_user
|
||||
FOREIGN KEY (user_id) REFERENCES qipai_users(id),
|
||||
CONSTRAINT fk_qipai_coupon_grant_template
|
||||
FOREIGN KEY (template_id) REFERENCES qipai_coupon_templates(id),
|
||||
CONSTRAINT fk_qipai_coupon_grant_order
|
||||
FOREIGN KEY (frozen_order_id) REFERENCES qipai_orders(id),
|
||||
UNIQUE KEY uq_qipai_coupon_grant_code (tenant_id, coupon_code),
|
||||
KEY idx_qipai_coupon_grant_user (tenant_id, user_id, status, valid_to)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_package_plans (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
store_id BIGINT UNSIGNED NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
price_cents INT UNSIGNED NOT NULL,
|
||||
minutes_total INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
amount_cents_total INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
room_category_id BIGINT UNSIGNED NULL,
|
||||
room_id BIGINT UNSIGNED NULL,
|
||||
weekdays_json JSON NULL,
|
||||
holiday_only TINYINT(1) NOT NULL DEFAULT 0,
|
||||
valid_days INT UNSIGNED NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
deleted_at DATETIME(3) NULL,
|
||||
CONSTRAINT fk_qipai_package_plan_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_package_plan_store
|
||||
FOREIGN KEY (store_id) REFERENCES qipai_stores(id),
|
||||
CONSTRAINT fk_qipai_package_plan_room_category
|
||||
FOREIGN KEY (room_category_id) REFERENCES qipai_room_categories(id),
|
||||
CONSTRAINT fk_qipai_package_plan_room
|
||||
FOREIGN KEY (room_id) REFERENCES qipai_rooms(id),
|
||||
KEY idx_qipai_package_plan_scope (tenant_id, store_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_package_holdings (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
plan_id BIGINT UNSIGNED NOT NULL,
|
||||
purchase_order_id BIGINT UNSIGNED NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
|
||||
valid_from DATETIME(3) NOT NULL,
|
||||
valid_to DATETIME(3) NOT NULL,
|
||||
remaining_minutes INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
remaining_amount_cents INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
frozen_order_id BIGINT UNSIGNED NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_package_holding_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_package_holding_user
|
||||
FOREIGN KEY (user_id) REFERENCES qipai_users(id),
|
||||
CONSTRAINT fk_qipai_package_holding_plan
|
||||
FOREIGN KEY (plan_id) REFERENCES qipai_package_plans(id),
|
||||
CONSTRAINT fk_qipai_package_holding_purchase_order
|
||||
FOREIGN KEY (purchase_order_id) REFERENCES qipai_orders(id),
|
||||
CONSTRAINT fk_qipai_package_holding_frozen_order
|
||||
FOREIGN KEY (frozen_order_id) REFERENCES qipai_orders(id),
|
||||
KEY idx_qipai_package_holding_user (tenant_id, user_id, status, valid_to)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS qipai_benefit_usages (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
tenant_id BIGINT UNSIGNED NOT NULL,
|
||||
user_id BIGINT UNSIGNED NOT NULL,
|
||||
order_id BIGINT UNSIGNED NOT NULL,
|
||||
benefit_type VARCHAR(32) NOT NULL,
|
||||
benefit_id BIGINT UNSIGNED NOT NULL,
|
||||
client_request_id VARCHAR(128) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'FROZEN',
|
||||
discount_cents INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
package_credit_cents INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
minutes INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
trace_id VARCHAR(128) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)
|
||||
ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
CONSTRAINT fk_qipai_benefit_usage_tenant
|
||||
FOREIGN KEY (tenant_id) REFERENCES qipai_tenants(id),
|
||||
CONSTRAINT fk_qipai_benefit_usage_user
|
||||
FOREIGN KEY (user_id) REFERENCES qipai_users(id),
|
||||
CONSTRAINT fk_qipai_benefit_usage_order
|
||||
FOREIGN KEY (order_id) REFERENCES qipai_orders(id),
|
||||
UNIQUE KEY uq_qipai_benefit_usage_request (tenant_id, user_id, client_request_id),
|
||||
UNIQUE KEY uq_qipai_benefit_usage_order_benefit
|
||||
(tenant_id, order_id, benefit_type, benefit_id),
|
||||
KEY idx_qipai_benefit_usage_order (tenant_id, order_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
INSERT IGNORE INTO qipai_schema_migrations (version, name)
|
||||
VALUES ('2026062423', 'm07c_benefits');
|
||||
@@ -0,0 +1,41 @@
|
||||
SELECT table_name
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name IN (
|
||||
'qipai_coupon_templates', 'qipai_coupon_grants',
|
||||
'qipai_package_plans', 'qipai_package_holdings',
|
||||
'qipai_benefit_usages'
|
||||
);
|
||||
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_coupon_templates'
|
||||
AND column_name IN (
|
||||
'coupon_type', 'discount_amount_cents', 'time_minutes',
|
||||
'min_order_amount_cents', 'room_category_id', 'room_id',
|
||||
'weekdays_json', 'holiday_only', 'valid_days', 'usage_limit_per_user'
|
||||
);
|
||||
|
||||
SELECT column_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_package_holdings'
|
||||
AND column_name IN (
|
||||
'purchase_order_id', 'remaining_minutes', 'remaining_amount_cents',
|
||||
'frozen_order_id', 'valid_from', 'valid_to', 'status'
|
||||
);
|
||||
|
||||
SELECT index_name
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
AND table_name = 'qipai_benefit_usages'
|
||||
AND index_name IN (
|
||||
'uq_qipai_benefit_usage_request',
|
||||
'uq_qipai_benefit_usage_order_benefit',
|
||||
'idx_qipai_benefit_usage_order'
|
||||
);
|
||||
|
||||
SELECT '2026062423' AS version
|
||||
FROM qipai_schema_migrations
|
||||
WHERE version = '2026062423';
|
||||
Reference in New Issue
Block a user