feat(M07-C): 建立优惠券套餐权益核销

This commit is contained in:
Codex
2026-06-24 15:45:22 +08:00
parent ba99beb221
commit 4ae9296f83
9 changed files with 936 additions and 6 deletions
@@ -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.');
+19 -1
View File
@@ -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.');
+2 -1
View File
@@ -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);