feat(M07-C): 建立优惠券套餐权益核销
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user