490 lines
16 KiB
TypeScript
490 lines
16 KiB
TypeScript
/**
|
|
* 概览页统计 Mock(用户/订单/合伙人/门店/核销,幂等可重复跑)
|
|
* 用法:cd server/dukang-api && pnpm prisma:seed-stats
|
|
*/
|
|
import { PrismaClient, type OrderStatus, type PayStatus } from '@prisma/client';
|
|
import { createHash, randomBytes } from 'crypto';
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
const USER_PREFIX = 'STAT';
|
|
const ORDER_PREFIX = 'STAT';
|
|
const COUPON_PREFIX = 'STATCPN';
|
|
const REDEEM_PREFIX = 'STATRD';
|
|
const STORE_NAME_PREFIX = 'STAT门店';
|
|
const PARTNER_PHONE_PREFIX = '13788';
|
|
|
|
const PROMO_CODES = [
|
|
{ code: 'STAT_A', name: '统计演示·品鉴会A', scene: 'EVENT' as const },
|
|
{ code: 'STAT_B', name: '统计演示·线下提货B', scene: 'OFFLINE_PICKUP' as const },
|
|
{ code: 'STAT_C', name: '统计演示·线上渠道C', scene: 'ONLINE_LINK' as const },
|
|
];
|
|
|
|
const CITY_DEFS = [
|
|
{ code: '410100', name: '郑州市', province: '河南省', district: '金水区' },
|
|
{ code: '410300', name: '洛阳市', province: '河南省', district: '涧西区' },
|
|
];
|
|
|
|
function startOfDay(d: Date) {
|
|
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
|
}
|
|
|
|
function qrcodeIdFor(code: string) {
|
|
return createHash('sha256').update(`stats-mock:${code}`).digest('hex');
|
|
}
|
|
|
|
function mulberry32(seed: number) {
|
|
return () => {
|
|
let t = (seed += 0x6d2b79f5);
|
|
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
async function cleanup() {
|
|
console.log('Cleaning previous STAT* mock...');
|
|
|
|
const redeemIds = (
|
|
await prisma.redeemRecord.findMany({
|
|
where: { redeemNo: { startsWith: REDEEM_PREFIX } },
|
|
select: { id: true },
|
|
})
|
|
).map((r) => r.id);
|
|
if (redeemIds.length) {
|
|
await prisma.redeemRecordAllocation.deleteMany({
|
|
where: { redeemRecordId: { in: redeemIds } },
|
|
});
|
|
await prisma.storeRating.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
|
await prisma.storePayout.deleteMany({ where: { redeemRecordId: { in: redeemIds } } });
|
|
await prisma.redeemPendingRecord.deleteMany({
|
|
where: { redeemRecordId: { in: redeemIds } },
|
|
});
|
|
await prisma.redeemRecord.deleteMany({ where: { id: { in: redeemIds } } });
|
|
}
|
|
|
|
await prisma.benefitCoupon.deleteMany({
|
|
where: { couponNo: { startsWith: COUPON_PREFIX } },
|
|
});
|
|
|
|
const statOrders = await prisma.order.findMany({
|
|
where: { orderNo: { startsWith: ORDER_PREFIX } },
|
|
select: { id: true },
|
|
});
|
|
const orderIds = statOrders.map((o) => o.id);
|
|
if (orderIds.length) {
|
|
await prisma.benefitCoupon.deleteMany({ where: { orderId: { in: orderIds } } });
|
|
await prisma.orderDelivery.deleteMany({ where: { orderId: { in: orderIds } } });
|
|
await prisma.userInvoice.deleteMany({ where: { orderId: { in: orderIds } } });
|
|
await prisma.order.deleteMany({ where: { id: { in: orderIds } } });
|
|
}
|
|
|
|
const statUsers = await prisma.user.findMany({
|
|
where: { userNo: { startsWith: USER_PREFIX } },
|
|
select: { id: true },
|
|
});
|
|
const userIds = statUsers.map((u) => u.id);
|
|
if (userIds.length) {
|
|
await prisma.userPromoAttribution.deleteMany({ where: { userId: { in: userIds } } });
|
|
await prisma.userCityPreference.deleteMany({ where: { userId: { in: userIds } } });
|
|
await prisma.userAddress.deleteMany({ where: { userId: { in: userIds } } });
|
|
await prisma.benefitCoupon.deleteMany({ where: { userId: { in: userIds } } });
|
|
await prisma.user.deleteMany({ where: { id: { in: userIds } } });
|
|
}
|
|
|
|
await prisma.commonPromoCode.deleteMany({
|
|
where: { code: { in: PROMO_CODES.map((p) => p.code) } },
|
|
});
|
|
|
|
const statStores = await prisma.store.findMany({
|
|
where: { name: { startsWith: STORE_NAME_PREFIX } },
|
|
select: { id: true },
|
|
});
|
|
const storeIds = statStores.map((s) => s.id);
|
|
if (storeIds.length) {
|
|
await prisma.storeAccountStore.deleteMany({ where: { storeId: { in: storeIds } } });
|
|
await prisma.storeBill.deleteMany({ where: { storeId: { in: storeIds } } });
|
|
await prisma.store.deleteMany({ where: { id: { in: storeIds } } });
|
|
}
|
|
|
|
const statPartners = await prisma.partnerAccount.findMany({
|
|
where: { phone: { startsWith: PARTNER_PHONE_PREFIX } },
|
|
select: { id: true },
|
|
});
|
|
const partnerIds = statPartners.map((p) => p.id);
|
|
if (partnerIds.length) {
|
|
await prisma.partnerBill.deleteMany({ where: { partnerAccountId: { in: partnerIds } } });
|
|
await prisma.partnerAccount.deleteMany({
|
|
where: { OR: [{ id: { in: partnerIds } }, { parentAccountId: { in: partnerIds } }] },
|
|
});
|
|
}
|
|
}
|
|
|
|
async function ensureCities() {
|
|
const cities = [];
|
|
for (const def of CITY_DEFS) {
|
|
const city = await prisma.commonCity.upsert({
|
|
where: { code: def.code },
|
|
create: {
|
|
code: def.code,
|
|
name: def.name,
|
|
province: def.province,
|
|
status: 'ACTIVE',
|
|
},
|
|
update: {
|
|
name: def.name,
|
|
province: def.province,
|
|
status: 'ACTIVE',
|
|
},
|
|
});
|
|
cities.push({ ...city, district: def.district });
|
|
}
|
|
return cities;
|
|
}
|
|
|
|
async function main() {
|
|
console.log('Seeding stats mock (users/orders/partners/stores/redeems)...');
|
|
await cleanup();
|
|
|
|
const cities = await ensureCities();
|
|
const product = await prisma.commonProductItem.findFirst({
|
|
orderBy: { id: 'asc' },
|
|
select: {
|
|
id: true,
|
|
barcode69: true,
|
|
name: true,
|
|
spec: true,
|
|
price: true,
|
|
},
|
|
});
|
|
if (!product) throw new Error('没有商品,请先 pnpm prisma:seed');
|
|
|
|
const category = await prisma.commonStoreCategory.findFirst({ orderBy: { id: 'asc' } });
|
|
|
|
const promos = [];
|
|
for (const def of PROMO_CODES) {
|
|
const promo = await prisma.commonPromoCode.create({
|
|
data: {
|
|
code: def.code,
|
|
name: def.name,
|
|
scene: def.scene,
|
|
qrcodeId: qrcodeIdFor(def.code),
|
|
status: 'ACTIVE',
|
|
scanCount: 0,
|
|
orderCount: 0,
|
|
},
|
|
});
|
|
promos.push(promo);
|
|
}
|
|
|
|
const rand = mulberry32(20260731);
|
|
const today = startOfDay(new Date());
|
|
|
|
// ── 城市合伙人 + 门店(近 30 天分散创建) ──
|
|
const createdPartners: Array<{
|
|
id: bigint;
|
|
cityId: bigint;
|
|
city: (typeof cities)[0];
|
|
}> = [];
|
|
const createdStores: Array<{
|
|
id: bigint;
|
|
cityId: bigint;
|
|
partnerAccountId: bigint;
|
|
settlementRate: number;
|
|
}> = [];
|
|
|
|
for (let i = 0; i < cities.length; i++) {
|
|
const city = cities[i];
|
|
// 每城 2 个主合伙人
|
|
for (let j = 0; j < 2; j++) {
|
|
const dayOffset = Math.floor(rand() * 28);
|
|
const createdAt = new Date(today);
|
|
createdAt.setDate(createdAt.getDate() - dayOffset);
|
|
createdAt.setHours(9, 0, 0, 0);
|
|
const phone = `${PARTNER_PHONE_PREFIX}${String(i * 10 + j + 1).padStart(5, '0')}`;
|
|
const partner = await prisma.partnerAccount.create({
|
|
data: {
|
|
phone,
|
|
name: `${city.name}统计合伙人${j + 1}`,
|
|
companyName: `STAT${city.name}合伙人${j + 1}`,
|
|
isPrimary: 1,
|
|
status: 'ACTIVE',
|
|
cityId: city.id,
|
|
scopeType: 'CITY_WIDE',
|
|
bindingStatus: 'ACTIVE',
|
|
orderCommissionRate: 0,
|
|
redeemCommissionRate: 0.03,
|
|
bankAccountName: `${city.name}统计合伙人${j + 1}`,
|
|
bankAccountNo: `622202${String(1000000000 + i * 10 + j)}`,
|
|
bankBranch: `${city.name}工商银行`,
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
},
|
|
});
|
|
createdPartners.push({ id: partner.id, cityId: city.id, city });
|
|
|
|
// 每位合伙人 3~5 家门店
|
|
const storeN = 3 + Math.floor(rand() * 3);
|
|
for (let k = 0; k < storeN; k++) {
|
|
const sDay = Math.floor(rand() * Math.max(1, dayOffset + 1));
|
|
const sCreated = new Date(today);
|
|
sCreated.setDate(sCreated.getDate() - sDay);
|
|
sCreated.setHours(11, Math.floor(rand() * 40), 0, 0);
|
|
const rate = 0.6;
|
|
const store = await prisma.store.create({
|
|
data: {
|
|
cityId: city.id,
|
|
partnerAccountId: partner.id,
|
|
categoryId: category?.id,
|
|
name: `${STORE_NAME_PREFIX}-${city.name}-${j + 1}-${k + 1}`,
|
|
phone: `1399${String(100000 + i * 100 + j * 10 + k).slice(-7)}`,
|
|
province: city.province,
|
|
cityName: city.name,
|
|
district: city.district,
|
|
address: `统计路${k + 1}号`,
|
|
settlementRate: rate,
|
|
status: 'OPEN',
|
|
auditStatus: 'APPROVED',
|
|
openTime: '10:00',
|
|
closeTime: '22:00',
|
|
createdAt: sCreated,
|
|
updatedAt: sCreated,
|
|
},
|
|
});
|
|
createdStores.push({
|
|
id: store.id,
|
|
cityId: city.id,
|
|
partnerAccountId: partner.id,
|
|
settlementRate: rate,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── 用户 + 订单 ──
|
|
const userCount = 100;
|
|
const createdUsers: Array<{
|
|
id: bigint;
|
|
cityId: bigint;
|
|
city: (typeof cities)[0];
|
|
promoId: bigint | null;
|
|
phone: string;
|
|
}> = [];
|
|
const paidOrders: Array<{
|
|
id: bigint;
|
|
userId: bigint;
|
|
cityId: bigint;
|
|
payAmount: number;
|
|
productName: string;
|
|
}> = [];
|
|
|
|
for (let i = 0; i < userCount; i++) {
|
|
const dayOffset = Math.floor(rand() * 30);
|
|
const createdAt = new Date(today);
|
|
createdAt.setDate(createdAt.getDate() - dayOffset);
|
|
createdAt.setHours(8 + Math.floor(rand() * 12), Math.floor(rand() * 60), 0, 0);
|
|
|
|
const city = cities[Math.floor(rand() * cities.length)];
|
|
const promo = rand() < 0.7 ? promos[Math.floor(rand() * promos.length)] : null;
|
|
const seq = String(i + 1).padStart(4, '0');
|
|
const phone = `13888${String(10000 + i).slice(-5)}`;
|
|
|
|
const user = await prisma.user.create({
|
|
data: {
|
|
userNo: `${USER_PREFIX}${seq}`,
|
|
phone,
|
|
phoneVerifiedAt: createdAt,
|
|
nickname: `统计用户${seq}`,
|
|
deviceKey: `stat-device-${seq}-${randomBytes(4).toString('hex')}`,
|
|
sourceType: promo ? 'PROMO_CODE' : 'ORGANIC',
|
|
sourceRefId: promo?.id ?? null,
|
|
sourceLabel: promo?.name ?? null,
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
cityPreference: {
|
|
create: {
|
|
selectedCityCode: city.code,
|
|
selectedDistrict: city.district,
|
|
locateCityCode: city.code,
|
|
locateDistrict: city.district,
|
|
updatedAt: createdAt,
|
|
},
|
|
},
|
|
...(promo
|
|
? {
|
|
promoTouch: {
|
|
create: {
|
|
promoCodeId: promo.id,
|
|
channelName: promo.name,
|
|
firstTouchAt: createdAt,
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
});
|
|
|
|
createdUsers.push({
|
|
id: user.id,
|
|
cityId: city.id,
|
|
city,
|
|
promoId: promo?.id ?? null,
|
|
phone,
|
|
});
|
|
}
|
|
|
|
const statuses: Array<{ status: OrderStatus; payStatus: PayStatus }> = [
|
|
{ status: 'COMPLETED', payStatus: 'PAID' },
|
|
{ status: 'COMPLETED', payStatus: 'PAID' },
|
|
{ status: 'PENDING_SHIP', payStatus: 'PAID' },
|
|
{ status: 'SHIPPING', payStatus: 'PAID' },
|
|
{ status: 'PENDING_PAY', payStatus: 'UNPAID' },
|
|
{ status: 'CANCELLED', payStatus: 'UNPAID' },
|
|
];
|
|
|
|
let orderCount = 0;
|
|
const promoOrderInc = new Map<string, number>();
|
|
|
|
for (const u of createdUsers) {
|
|
const n = 1 + Math.floor(rand() * 3);
|
|
for (let j = 0; j < n; j++) {
|
|
const dayOffset = Math.floor(rand() * 30);
|
|
const createdAt = new Date(today);
|
|
createdAt.setDate(createdAt.getDate() - dayOffset);
|
|
createdAt.setHours(10 + Math.floor(rand() * 10), Math.floor(rand() * 60), 0, 0);
|
|
|
|
const qty = 2 + Math.floor(rand() * 3);
|
|
const unit = Number(product.price);
|
|
const listAmount = unit * qty;
|
|
const st = statuses[Math.floor(rand() * statuses.length)];
|
|
const paid = st.payStatus === 'PAID';
|
|
orderCount += 1;
|
|
const orderNo = `${ORDER_PREFIX}${String(orderCount).padStart(6, '0')}`;
|
|
|
|
const order = await prisma.order.create({
|
|
data: {
|
|
orderNo,
|
|
userId: u.id,
|
|
cityId: u.cityId,
|
|
promoCodeId: u.promoId,
|
|
channelSource: u.promoId ? 'STATS_MOCK' : null,
|
|
status: st.status,
|
|
payStatus: st.payStatus,
|
|
deliveryType: 'LOCAL',
|
|
productId: product.id,
|
|
barcode69: product.barcode69,
|
|
productName: product.name,
|
|
productSpec: product.spec,
|
|
quantity: qty,
|
|
listUnitPrice: unit,
|
|
listAmount,
|
|
productAmount: listAmount,
|
|
payAmount: listAmount,
|
|
benefitAmount: listAmount,
|
|
receiverName: `统计用户`,
|
|
receiverPhone: u.phone,
|
|
receiverAddress: `${u.city.name}${u.city.district}统计路1号`,
|
|
receiverProvince: u.city.province,
|
|
receiverCity: u.city.name,
|
|
receiverDistrict: u.city.district,
|
|
paidAt: paid ? createdAt : null,
|
|
completedAt: st.status === 'COMPLETED' ? createdAt : null,
|
|
cancelledAt: st.status === 'CANCELLED' ? createdAt : null,
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
},
|
|
});
|
|
|
|
if (paid) {
|
|
paidOrders.push({
|
|
id: order.id,
|
|
userId: u.id,
|
|
cityId: u.cityId,
|
|
payAmount: listAmount,
|
|
productName: product.name,
|
|
});
|
|
}
|
|
|
|
if (u.promoId) {
|
|
const key = u.promoId.toString();
|
|
promoOrderInc.set(key, (promoOrderInc.get(key) ?? 0) + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const [id, inc] of promoOrderInc) {
|
|
await prisma.commonPromoCode.update({
|
|
where: { id: BigInt(id) },
|
|
data: { orderCount: { increment: inc }, scanCount: { increment: Math.floor(inc * 1.5) } },
|
|
});
|
|
}
|
|
|
|
// ── 权益券 + 核销(约 80 笔,分散近 30 天) ──
|
|
const storesByCity = new Map<string, typeof createdStores>();
|
|
for (const s of createdStores) {
|
|
const key = s.cityId.toString();
|
|
const list = storesByCity.get(key) ?? [];
|
|
list.push(s);
|
|
storesByCity.set(key, list);
|
|
}
|
|
|
|
let redeemCount = 0;
|
|
const redeemTarget = Math.min(80, paidOrders.length);
|
|
for (let i = 0; i < redeemTarget; i++) {
|
|
const order = paidOrders[i];
|
|
const cityStores = storesByCity.get(order.cityId.toString()) ?? createdStores;
|
|
if (!cityStores.length) continue;
|
|
const store = cityStores[Math.floor(rand() * cityStores.length)];
|
|
|
|
const dayOffset = Math.floor(rand() * 30);
|
|
const createdAt = new Date(today);
|
|
createdAt.setDate(createdAt.getDate() - dayOffset);
|
|
createdAt.setHours(14 + Math.floor(rand() * 6), Math.floor(rand() * 60), 0, 0);
|
|
|
|
const amount = Math.round((40 + rand() * 200) * 100) / 100;
|
|
const settleAmount = Math.round(amount * store.settlementRate * 100) / 100;
|
|
|
|
const coupon = await prisma.benefitCoupon.create({
|
|
data: {
|
|
couponNo: `${COUPON_PREFIX}${String(i + 1).padStart(5, '0')}`,
|
|
userId: order.userId,
|
|
orderId: order.id,
|
|
totalAmount: order.payAmount,
|
|
usedAmount: amount,
|
|
balance: Math.max(0, order.payAmount - amount),
|
|
status: amount >= order.payAmount ? 'USED_UP' : 'ACTIVE',
|
|
sourceProduct: order.productName,
|
|
createdAt,
|
|
updatedAt: createdAt,
|
|
},
|
|
});
|
|
|
|
redeemCount += 1;
|
|
await prisma.redeemRecord.create({
|
|
data: {
|
|
redeemNo: `${REDEEM_PREFIX}${String(redeemCount).padStart(5, '0')}`,
|
|
userId: order.userId,
|
|
couponId: coupon.id,
|
|
storeId: store.id,
|
|
amount,
|
|
settleAmount,
|
|
createdAt,
|
|
},
|
|
});
|
|
}
|
|
|
|
console.log(
|
|
`Done: cities=${cities.length}, promos=${promos.length}, partners=${createdPartners.length}, ` +
|
|
`stores=${createdStores.length}, users=${createdUsers.length}, orders=${orderCount}, redeems=${redeemCount}`,
|
|
);
|
|
}
|
|
|
|
main()
|
|
.catch((e) => {
|
|
console.error(e);
|
|
process.exit(1);
|
|
})
|
|
.finally(async () => {
|
|
await prisma.$disconnect();
|
|
});
|