480 lines
15 KiB
JavaScript
480 lines
15 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import {
|
|
MemberProfileError,
|
|
MemberProfileService
|
|
} from '../dist/wallets/member-profile-service.js';
|
|
|
|
const customers = [
|
|
{
|
|
id: '21',
|
|
tenantId: '7',
|
|
userType: 'CUSTOMER',
|
|
status: 'ACTIVE',
|
|
nickname: 'Alice',
|
|
phone: '13800138000',
|
|
createdAt: new Date('2026-06-01T00:00:00.000Z'),
|
|
lastLoginAt: new Date('2026-06-20T00:00:00.000Z'),
|
|
deleted: false
|
|
},
|
|
{
|
|
id: '22',
|
|
tenantId: '7',
|
|
userType: 'CUSTOMER',
|
|
status: 'ACTIVE',
|
|
nickname: 'Bob',
|
|
phone: '13900139000',
|
|
createdAt: new Date('2026-06-02T00:00:00.000Z'),
|
|
lastLoginAt: null,
|
|
deleted: false
|
|
},
|
|
{
|
|
id: '23',
|
|
tenantId: '8',
|
|
userType: 'CUSTOMER',
|
|
status: 'ACTIVE',
|
|
nickname: 'OtherTenant',
|
|
phone: '13700137000',
|
|
createdAt: new Date('2026-06-03T00:00:00.000Z'),
|
|
lastLoginAt: null,
|
|
deleted: false
|
|
}
|
|
];
|
|
|
|
const wallets = [
|
|
{ tenantId: '7', userId: '21', storeId: '11', status: 'ACTIVE', cash: 10_000, gift: 2_000 },
|
|
{ tenantId: '7', userId: '21', storeId: null, status: 'ACTIVE', cash: 5_000, gift: 500 },
|
|
{ tenantId: '7', userId: '22', storeId: '12', status: 'ACTIVE', cash: 1_000, gift: 0 }
|
|
];
|
|
|
|
const coupons = [
|
|
{
|
|
id: '401',
|
|
tenantId: '7',
|
|
userId: '21',
|
|
templateId: '301',
|
|
name: '30元抵扣券',
|
|
status: 'AVAILABLE',
|
|
couponType: 'FULL_REDUCTION',
|
|
discountAmountCents: 3000,
|
|
timeMinutes: 0,
|
|
minOrderAmountCents: 10000,
|
|
remainingUses: 1,
|
|
validFrom: new Date('2026-06-01T00:00:00.000Z'),
|
|
validTo: new Date('2026-07-01T00:00:00.000Z'),
|
|
storeId: '11',
|
|
roomCategoryId: null,
|
|
roomId: null,
|
|
holidayOnly: 0,
|
|
frozenOrderId: null,
|
|
usedAt: null
|
|
},
|
|
{
|
|
id: '402',
|
|
tenantId: '7',
|
|
userId: '21',
|
|
templateId: '302',
|
|
name: '60分钟券',
|
|
status: 'FROZEN',
|
|
couponType: 'TIME',
|
|
discountAmountCents: 0,
|
|
timeMinutes: 60,
|
|
minOrderAmountCents: 0,
|
|
remainingUses: 1,
|
|
validFrom: new Date('2026-06-01T00:00:00.000Z'),
|
|
validTo: new Date('2026-07-02T00:00:00.000Z'),
|
|
storeId: null,
|
|
roomCategoryId: null,
|
|
roomId: null,
|
|
holidayOnly: 0,
|
|
frozenOrderId: '301',
|
|
usedAt: null
|
|
},
|
|
{
|
|
id: '403',
|
|
tenantId: '7',
|
|
userId: '22',
|
|
templateId: '303',
|
|
name: '其他用户券',
|
|
status: 'USED',
|
|
couponType: 'FULL_REDUCTION',
|
|
discountAmountCents: 500,
|
|
timeMinutes: 0,
|
|
minOrderAmountCents: 0,
|
|
remainingUses: 0,
|
|
validFrom: new Date('2026-06-01T00:00:00.000Z'),
|
|
validTo: new Date('2026-07-01T00:00:00.000Z'),
|
|
storeId: null,
|
|
roomCategoryId: null,
|
|
roomId: null,
|
|
holidayOnly: 0,
|
|
frozenOrderId: null,
|
|
usedAt: new Date('2026-06-20T00:00:00.000Z')
|
|
}
|
|
];
|
|
|
|
const packages = [
|
|
{
|
|
id: '501',
|
|
tenantId: '7',
|
|
userId: '21',
|
|
planId: '601',
|
|
name: '三小时畅玩包',
|
|
status: 'ACTIVE',
|
|
priceCents: 9000,
|
|
minutesTotal: 180,
|
|
amountCentsTotal: 3000,
|
|
remainingMinutes: 120,
|
|
remainingAmountCents: 3_000,
|
|
validFrom: new Date('2026-06-01T00:00:00.000Z'),
|
|
validTo: new Date('2026-08-01T00:00:00.000Z'),
|
|
storeId: '11',
|
|
roomCategoryId: null,
|
|
roomId: null,
|
|
holidayOnly: 0,
|
|
frozenOrderId: null
|
|
},
|
|
{
|
|
id: '502',
|
|
tenantId: '7',
|
|
userId: '21',
|
|
planId: '602',
|
|
name: '锁定中的套餐',
|
|
status: 'FROZEN',
|
|
priceCents: 5000,
|
|
minutesTotal: 60,
|
|
amountCentsTotal: 2000,
|
|
remainingMinutes: 60,
|
|
remainingAmountCents: 2_000,
|
|
validFrom: new Date('2026-06-01T00:00:00.000Z'),
|
|
validTo: new Date('2026-08-02T00:00:00.000Z'),
|
|
storeId: null,
|
|
roomCategoryId: null,
|
|
roomId: null,
|
|
holidayOnly: 0,
|
|
frozenOrderId: '301'
|
|
}
|
|
];
|
|
|
|
const recharges = [
|
|
{
|
|
tenantId: '7',
|
|
userId: '21',
|
|
status: 'CREDITED',
|
|
payAmountCents: 10_000,
|
|
giftAmountCents: 2_000,
|
|
creditedAt: new Date('2026-06-21T00:00:00.000Z')
|
|
},
|
|
{
|
|
tenantId: '7',
|
|
userId: '21',
|
|
status: 'PENDING_PAYMENT',
|
|
payAmountCents: 5_000,
|
|
giftAmountCents: 500,
|
|
creditedAt: null
|
|
}
|
|
];
|
|
|
|
const ledger = [
|
|
{
|
|
id: '1002',
|
|
tenantId: '7',
|
|
userId: '21',
|
|
storeId: '11',
|
|
businessType: 'ORDER',
|
|
businessId: 'order-1',
|
|
entryType: 'CONSUME',
|
|
cashDeltaCents: -200,
|
|
giftDeltaCents: -300,
|
|
cashBalanceAfterCents: 14_800,
|
|
giftBalanceAfterCents: 2_200,
|
|
createdAt: new Date('2026-06-22T00:00:00.000Z')
|
|
},
|
|
{
|
|
id: '1001',
|
|
tenantId: '7',
|
|
userId: '21',
|
|
storeId: '11',
|
|
businessType: 'RECHARGE',
|
|
businessId: 'recharge-1',
|
|
entryType: 'RECHARGE',
|
|
cashDeltaCents: 10_000,
|
|
giftDeltaCents: 2_000,
|
|
cashBalanceAfterCents: 15_000,
|
|
giftBalanceAfterCents: 2_500,
|
|
createdAt: new Date('2026-06-21T00:00:00.000Z')
|
|
}
|
|
];
|
|
|
|
const orders = [
|
|
{
|
|
id: '301',
|
|
tenantId: '7',
|
|
userId: '21',
|
|
status: 'COMPLETED',
|
|
paidAmountCents: 12_000,
|
|
createdAt: new Date('2026-06-23T00:00:00.000Z')
|
|
},
|
|
{
|
|
id: '302',
|
|
tenantId: '7',
|
|
userId: '21',
|
|
status: 'CANCELLED',
|
|
paidAmountCents: 0,
|
|
createdAt: new Date('2026-06-24T00:00:00.000Z')
|
|
}
|
|
];
|
|
|
|
function createService() {
|
|
const pool = {
|
|
async execute(sql, params) {
|
|
if (sql.includes('COUNT(DISTINCT u.id)')) {
|
|
return [[{ total: queryMembers(sql, params).length }], []];
|
|
}
|
|
if (sql.includes('FROM qipai_users u') && sql.includes('ORDER BY u.id DESC')) {
|
|
return [queryMembers(sql, params).sort((a, b) => Number(b.id) - Number(a.id)), []];
|
|
}
|
|
if (sql.includes('FROM qipai_users u') && sql.includes('LIMIT 1')) {
|
|
const found = queryMembers(sql, params).find((user) => user.id === String(params[1]));
|
|
return [found ? [found] : [], []];
|
|
}
|
|
if (sql.includes('FROM qipai_wallet_accounts')) {
|
|
const tenantId = String(params[0]);
|
|
const userId = String(params[1]);
|
|
const rows = wallets.filter((item) =>
|
|
item.tenantId === tenantId && item.userId === userId && item.status === 'ACTIVE'
|
|
);
|
|
return [[{
|
|
accountCount: rows.length,
|
|
cashBalanceCents: rows.reduce((sum, item) => sum + item.cash, 0),
|
|
giftBalanceCents: rows.reduce((sum, item) => sum + item.gift, 0)
|
|
}], []];
|
|
}
|
|
if (sql.includes('FROM qipai_coupon_grants c')) {
|
|
const tenantId = String(params[8]);
|
|
const userId = String(params[9]);
|
|
const memberCoupons = coupons.filter((item) =>
|
|
item.tenantId === tenantId && item.userId === userId
|
|
);
|
|
const memberPackages = packages.filter((item) =>
|
|
item.tenantId === tenantId && item.userId === userId
|
|
);
|
|
return [[{
|
|
availableCoupons: memberCoupons.filter((item) => item.status === 'AVAILABLE').length,
|
|
frozenCoupons: memberCoupons.filter((item) => item.status === 'FROZEN').length,
|
|
activePackages: memberPackages.filter((item) => item.status === 'ACTIVE').length,
|
|
frozenPackages: memberPackages.filter((item) => item.status === 'FROZEN').length,
|
|
packageMinutes: memberPackages
|
|
.filter((item) => ['ACTIVE', 'FROZEN'].includes(item.status))
|
|
.reduce((sum, item) => sum + item.remainingMinutes, 0),
|
|
packageAmountCents: memberPackages
|
|
.filter((item) => ['ACTIVE', 'FROZEN'].includes(item.status))
|
|
.reduce((sum, item) => sum + item.remainingAmountCents, 0)
|
|
}], []];
|
|
}
|
|
if (sql.includes('FROM qipai_coupon_grants g')) {
|
|
const rows = coupons
|
|
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
|
|
.slice(0, Number(params[2]));
|
|
return [rows.map((item) => ({
|
|
id: item.id,
|
|
templateId: item.templateId,
|
|
name: item.name,
|
|
status: item.status,
|
|
couponType: item.couponType,
|
|
discountAmountCents: item.discountAmountCents,
|
|
timeMinutes: item.timeMinutes,
|
|
minOrderAmountCents: item.minOrderAmountCents,
|
|
remainingUses: item.remainingUses,
|
|
validFrom: item.validFrom,
|
|
validTo: item.validTo,
|
|
storeId: item.storeId,
|
|
roomCategoryId: item.roomCategoryId,
|
|
roomId: item.roomId,
|
|
holidayOnly: item.holidayOnly,
|
|
frozenOrderId: item.frozenOrderId,
|
|
usedAt: item.usedAt
|
|
})), []];
|
|
}
|
|
if (sql.includes('FROM qipai_package_holdings h')) {
|
|
const rows = packages
|
|
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
|
|
.slice(0, Number(params[2]));
|
|
return [rows.map((item) => ({
|
|
id: item.id,
|
|
planId: item.planId,
|
|
name: item.name,
|
|
status: item.status,
|
|
priceCents: item.priceCents,
|
|
minutesTotal: item.minutesTotal,
|
|
amountCentsTotal: item.amountCentsTotal,
|
|
remainingMinutes: item.remainingMinutes,
|
|
remainingAmountCents: item.remainingAmountCents,
|
|
validFrom: item.validFrom,
|
|
validTo: item.validTo,
|
|
storeId: item.storeId,
|
|
roomCategoryId: item.roomCategoryId,
|
|
roomId: item.roomId,
|
|
holidayOnly: item.holidayOnly,
|
|
frozenOrderId: item.frozenOrderId
|
|
})), []];
|
|
}
|
|
if (sql.includes('FROM qipai_recharge_orders')) {
|
|
const tenantId = String(params[0]);
|
|
const userId = String(params[1]);
|
|
const rows = recharges.filter((item) => item.tenantId === tenantId && item.userId === userId);
|
|
const credited = rows.filter((item) => item.status === 'CREDITED');
|
|
return [[{
|
|
rechargeOrderCount: rows.length,
|
|
creditedRechargeCount: credited.length,
|
|
creditedRechargeCents: credited.reduce((sum, item) => sum + item.payAmountCents, 0),
|
|
giftedRechargeCents: credited.reduce((sum, item) => sum + item.giftAmountCents, 0),
|
|
lastRechargeAt: credited[0]?.creditedAt ?? null
|
|
}], []];
|
|
}
|
|
if (sql.includes('FROM qipai_orders o')) {
|
|
const userId = String(params[0]);
|
|
const tenantId = String(params[1]);
|
|
const rows = orders.filter((item) => item.tenantId === tenantId && item.userId === userId);
|
|
const paid = rows.filter((item) => ['PAID', 'IN_USE', 'COMPLETED'].includes(item.status));
|
|
return [[{
|
|
orderCount: rows.length,
|
|
paidOrderCount: paid.length,
|
|
paidAmountCents: paid.reduce((sum, item) => sum + item.paidAmountCents, 0),
|
|
lastOrderAt: rows.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]?.createdAt ?? null
|
|
}], []];
|
|
}
|
|
if (sql.includes('FROM qipai_wallet_ledger_entries')) {
|
|
const rows = ledger
|
|
.filter((item) => item.tenantId === String(params[0]) && item.userId === String(params[1]))
|
|
.sort((a, b) => Number(b.id) - Number(a.id))
|
|
.slice(0, Number(params[2]));
|
|
return [rows.map((item) => ({
|
|
id: item.id,
|
|
storeId: item.storeId,
|
|
businessType: item.businessType,
|
|
businessId: item.businessId,
|
|
entryType: item.entryType,
|
|
cashDeltaCents: item.cashDeltaCents,
|
|
giftDeltaCents: item.giftDeltaCents,
|
|
cashBalanceAfterCents: item.cashBalanceAfterCents,
|
|
giftBalanceAfterCents: item.giftBalanceAfterCents,
|
|
createdAt: item.createdAt
|
|
})), []];
|
|
}
|
|
throw new Error(`Unexpected SQL: ${sql}`);
|
|
}
|
|
};
|
|
return new MemberProfileService(pool);
|
|
}
|
|
|
|
function queryMembers(sql, params) {
|
|
if (sql.includes('1 = 0')) return [];
|
|
const tenantId = String(params[0]);
|
|
const statusFilter = sql.includes('u.status = ?') ? params[1] : null;
|
|
const hasStoreScope = sql.includes('qipai_wallet_accounts wa');
|
|
const storeParams = hasStoreScope
|
|
? params.slice(statusFilter ? 2 : 1).filter((value) => /^\d+$/.test(String(value)))
|
|
: [];
|
|
return customers.filter((user) => {
|
|
if (user.tenantId !== tenantId || user.userType !== 'CUSTOMER' || user.deleted) return false;
|
|
if (statusFilter && user.status !== statusFilter) return false;
|
|
if (hasStoreScope) {
|
|
return wallets.some((wallet) =>
|
|
wallet.tenantId === tenantId
|
|
&& wallet.userId === user.id
|
|
&& storeParams.includes(wallet.storeId)
|
|
);
|
|
}
|
|
return true;
|
|
}).map((user) => ({
|
|
id: user.id,
|
|
status: user.status,
|
|
nickname: user.nickname,
|
|
phone: user.phone,
|
|
createdAt: user.createdAt,
|
|
lastLoginAt: user.lastLoginAt
|
|
}));
|
|
}
|
|
|
|
const tenantAdmin = {
|
|
tenantId: '7',
|
|
userId: '1',
|
|
access: { roles: ['TENANT_ADMIN'], capabilities: ['tenant.manage'], storeIds: [] }
|
|
};
|
|
const storeReader = {
|
|
tenantId: '7',
|
|
userId: '2',
|
|
access: { roles: ['STORE_ADMIN'], capabilities: ['user.read'], storeIds: ['11'] }
|
|
};
|
|
const forbiddenReader = {
|
|
tenantId: '7',
|
|
userId: '3',
|
|
access: { roles: ['STAFF'], capabilities: ['user.read'], storeIds: [] }
|
|
};
|
|
|
|
{
|
|
const service = createService();
|
|
const list = await service.listMembers({
|
|
actor: tenantAdmin,
|
|
page: 1,
|
|
pageSize: 20
|
|
});
|
|
assert.equal(list.total, 2);
|
|
assert.equal(list.items[0].memberId, '22');
|
|
assert.equal(list.items[1].wallet.totalBalanceCents, 17_500);
|
|
assert.equal(list.items[1].benefits.availableCoupons, 1);
|
|
assert.equal(list.items[1].benefits.frozenPackages, 1);
|
|
assert.equal(list.items[1].recharge.creditedRechargeCents, 10_000);
|
|
assert.equal(list.items[1].orders.orderCount, 2);
|
|
assert.equal(list.items[1].orders.paidAmountCents, 12_000);
|
|
assert.equal(list.items[1].maskedPhone, '138****8000');
|
|
}
|
|
|
|
{
|
|
const service = createService();
|
|
const member = await service.getMember({ actor: tenantAdmin, memberId: '21' });
|
|
assert.equal(member.recentLedger.length, 2);
|
|
assert.equal(member.recentLedger[0].ledgerId, '1002');
|
|
assert.equal(member.wallet.cashBalanceCents, 15_000);
|
|
assert.equal(member.benefits.packageMinutes, 180);
|
|
}
|
|
|
|
{
|
|
const service = createService();
|
|
const benefits = await service.getMyBenefits({ tenantId: '7', userId: '21' });
|
|
assert.equal(benefits.coupons.length, 2);
|
|
assert.equal(benefits.coupons[0].couponGrantId, '401');
|
|
assert.equal(benefits.coupons[0].discountAmountCents, 3000);
|
|
assert.equal(benefits.coupons[1].frozenOrderId, '301');
|
|
assert.equal(benefits.packages.length, 2);
|
|
assert.equal(benefits.packages[0].packageHoldingId, '501');
|
|
assert.equal(benefits.packages[0].remainingAmountCents, 3000);
|
|
}
|
|
|
|
{
|
|
const service = createService();
|
|
const scoped = await service.listMembers({
|
|
actor: storeReader,
|
|
page: 1,
|
|
pageSize: 20
|
|
});
|
|
assert.equal(scoped.total, 1);
|
|
assert.equal(scoped.items[0].memberId, '21');
|
|
await assert.rejects(
|
|
() => service.getMember({ actor: storeReader, memberId: '22' }),
|
|
(error) => error instanceof MemberProfileError && error.code === 'MEMBER_NOT_FOUND'
|
|
);
|
|
}
|
|
|
|
{
|
|
const service = createService();
|
|
const scoped = await service.listMembers({
|
|
actor: forbiddenReader,
|
|
page: 1,
|
|
pageSize: 20
|
|
});
|
|
assert.equal(scoped.total, 0);
|
|
}
|
|
|
|
console.log('PASS: M07-D member profiles aggregate wallets, benefits, recharge and scoped access.');
|