import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { clampWecomPluginPage, clampWecomPluginPageSize, maskContactPhone, MOBILE_PHONE_RE, parseWecomPluginMetricsKind, toWecomPluginUserView, wecomPluginMetricsPeriod, type WecomReportStats, } from '@dukang/domain'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../../common/prisma/prisma.module'; import { PromoCodeService } from '../../modules/promo/promo-code.service'; import { WecomBotAuditService } from './wecom-bot-audit.service'; import { WECOM_PLUGIN_AUDIT_BOT } from './wecom-plugin.constants'; function asNumber(v: Prisma.Decimal | number | null | undefined): number { if (v == null) return 0; if (typeof v === 'number') return Number.isFinite(v) ? v : 0; return Number(v); } function requireQuery(q?: string): string { const v = String(q ?? '').trim(); if (!v) throw new BadRequestException('请提供查询关键词 q'); return v; } @Injectable() export class WecomPluginQueryService { constructor( private readonly prisma: PrismaService, private readonly promo: PromoCodeService, private readonly audit: WecomBotAuditService, ) {} info() { return { name: '杜康好客运营查询', description: '企微智能机器人只读 API 插件。查询订单、用户、门店、核销、推广码与经营指标。', auth: { header: 'X-Api-Key' }, tools: ['orders', 'users', 'stores', 'redeems', 'promo-codes', 'metrics'], }; } queryOrders(wecomUserId: string, q?: string, page?: string, pageSize?: string) { const keyword = requireQuery(q); const take = clampWecomPluginPageSize(pageSize); const skip = (clampWecomPluginPage(page) - 1) * take; return this.audit.run( { bot: WECOM_PLUGIN_AUDIT_BOT, wecomUserId, action: 'plugin.order.read', permission: 'order.read', inputSummary: keyword, }, async () => { const [items, total] = await Promise.all([ this.prisma.order.findMany({ where: { orderNo: { contains: keyword } }, orderBy: { createdAt: 'desc' }, skip, take, include: { user: { select: { userNo: true, nickname: true, phone: true } }, delivery: { select: { trackingNo: true, provider: true } }, }, }), this.prisma.order.count({ where: { orderNo: { contains: keyword } } }), ]); return { total, items: items.map((o) => ({ orderNo: o.orderNo, status: o.status, payStatus: o.payStatus, deliveryType: o.deliveryType, productName: o.productName, quantity: o.quantity, payAmount: asNumber(o.payAmount), user: toWecomPluginUserView({ userNo: o.user?.userNo || '—', nickname: o.user?.nickname, phone: o.user?.phone, }), receiverName: o.receiverName, receiverPhone: maskContactPhone(o.receiverPhone), receiverCity: o.receiverCity, trackingNo: o.delivery?.trackingNo || null, createdAt: o.createdAt.toISOString(), })), }; }, ); } queryUsers(wecomUserId: string, q?: string, page?: string, pageSize?: string) { const keyword = requireQuery(q); const take = clampWecomPluginPageSize(pageSize); const skip = (clampWecomPluginPage(page) - 1) * take; return this.audit.run( { bot: WECOM_PLUGIN_AUDIT_BOT, wecomUserId, action: 'plugin.user.read', permission: 'user.read', inputSummary: MOBILE_PHONE_RE.test(keyword) ? maskContactPhone(keyword) : keyword, }, async () => { const where = MOBILE_PHONE_RE.test(keyword) ? { phone: keyword, mergedIntoUserId: null } : { userNo: { contains: keyword }, mergedIntoUserId: null }; const [rows, total] = await Promise.all([ this.prisma.user.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take, select: { id: true, userNo: true, nickname: true, phone: true, status: true, createdAt: true, _count: { select: { orders: true } }, }, }), this.prisma.user.count({ where }), ]); const balances = await Promise.all( rows.map((u) => this.prisma.benefitCoupon.aggregate({ where: { userId: u.id, status: 'ACTIVE' }, _sum: { balance: true }, }), ), ); return { total, items: rows.map((u, i) => ({ ...toWecomPluginUserView(u), status: u.status, orderCount: u._count.orders, benefitBalance: asNumber(balances[i]?._sum.balance), createdAt: u.createdAt.toISOString(), })), }; }, ); } queryStores(wecomUserId: string, q?: string, page?: string, pageSize?: string) { const keyword = requireQuery(q); const take = clampWecomPluginPageSize(pageSize); const skip = (clampWecomPluginPage(page) - 1) * take; return this.audit.run( { bot: WECOM_PLUGIN_AUDIT_BOT, wecomUserId, action: 'plugin.store.read', permission: 'store.read', inputSummary: keyword, }, async () => { const where = { name: { contains: keyword } }; const [rows, total] = await Promise.all([ this.prisma.store.findMany({ where, orderBy: { updatedAt: 'desc' }, skip, take, select: { name: true, status: true, cityName: true, district: true, address: true, contactPhone: true, phone: true, }, }), this.prisma.store.count({ where }), ]); return { total, items: rows.map((s) => ({ name: s.name, status: s.status, cityName: s.cityName, district: s.district, address: s.address, contactPhone: maskContactPhone(s.contactPhone || s.phone), })), }; }, ); } queryRedeems(wecomUserId: string, q?: string, page?: string, pageSize?: string) { const keyword = requireQuery(q); const take = clampWecomPluginPageSize(pageSize); const skip = (clampWecomPluginPage(page) - 1) * take; return this.audit.run( { bot: WECOM_PLUGIN_AUDIT_BOT, wecomUserId, action: 'plugin.redeem.read', permission: 'redeem.read', inputSummary: keyword, }, async () => { const byNo = await this.prisma.redeemRecord.findMany({ where: { redeemNo: { contains: keyword } }, orderBy: { createdAt: 'desc' }, skip, take, include: { store: { select: { name: true } } }, }); const rows = byNo.length > 0 ? byNo : await this.prisma.redeemRecord.findMany({ where: { store: { name: { contains: keyword } } }, orderBy: { createdAt: 'desc' }, skip, take, include: { store: { select: { name: true } } }, }); const total = byNo.length > 0 ? await this.prisma.redeemRecord.count({ where: { redeemNo: { contains: keyword } } }) : await this.prisma.redeemRecord.count({ where: { store: { name: { contains: keyword } } }, }); return { total, items: rows.map((r) => ({ redeemNo: r.redeemNo, amount: asNumber(r.amount), channel: r.channel, storeName: r.store?.name || '—', createdAt: r.createdAt.toISOString(), })), }; }, ); } queryPromoCodes(wecomUserId: string, q?: string, page?: string, pageSize?: string) { const keyword = requireQuery(q); const take = clampWecomPluginPageSize(pageSize); const skip = (clampWecomPluginPage(page) - 1) * take; return this.audit.run( { bot: WECOM_PLUGIN_AUDIT_BOT, wecomUserId, action: 'plugin.promo.read', inputSummary: keyword, }, async () => { const where = { OR: [ { code: { contains: keyword.toUpperCase() } }, { name: { contains: keyword } }, ], }; const [rows, total] = await Promise.all([ this.prisma.commonPromoCode.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take, select: { code: true, name: true, scene: true, status: true, scanCount: true, orderCount: true, }, }), this.prisma.commonPromoCode.count({ where }), ]); return { total, items: rows }; }, ); } queryPromoCodeStats(wecomUserId: string, code?: string) { const keyword = requireQuery(code); return this.audit.run( { bot: WECOM_PLUGIN_AUDIT_BOT, wecomUserId, action: 'plugin.promo.stats', inputSummary: keyword, }, async () => { const row = (await this.prisma.commonPromoCode.findUnique({ where: { code: keyword.toUpperCase() }, select: { id: true, code: true, name: true, status: true }, })) || (await this.prisma.commonPromoCode.findFirst({ where: { code: { contains: keyword.toUpperCase() } }, select: { id: true, code: true, name: true, status: true }, })); if (!row) throw new NotFoundException(`未找到推广码:${keyword}`); const stats = await this.promo.stats(row.id); return { code: row.code, name: row.name, status: row.status, stats }; }, ); } queryMetrics(wecomUserId: string, kindRaw?: string) { const kind = parseWecomPluginMetricsKind(kindRaw); if (!kind) { throw new BadRequestException('kind 须为 today | daily | weekly | monthly'); } return this.audit.run( { bot: WECOM_PLUGIN_AUDIT_BOT, wecomUserId, action: 'plugin.metrics.read', inputSummary: kind, }, async () => { const period = wecomPluginMetricsPeriod(kind); const stats = await this.loadStats(period.start, period.endExclusive); return { kind: period.kind, title: period.title, rangeLabel: period.rangeLabel, incrementLabel: period.incrementLabel, periodKey: period.periodKey, stats, }; }, ); } /** 日报口径:用户=有效未合并;订单金额=已付 payAmount;核销=RedeemRecord。today 期末为当前时刻。 */ private async loadStats(start: Date, cutoff: Date): Promise { const userBase = { status: 1, mergedIntoUserId: null } as const; const partnerBase = { isPrimary: 1 } as const; const paid = { payStatus: 'PAID' as const }; const [ usersTotal, usersIncrement, partnersTotal, partnersIncrement, storesTotal, storesIncrement, ordersTotal, ordersIncrement, orderAmountTotal, orderAmountIncrement, redeemsTotal, redeemsIncrement, redeemAmountTotal, redeemAmountIncrement, ] = await Promise.all([ this.prisma.user.count({ where: { ...userBase, createdAt: { lt: cutoff } } }), this.prisma.user.count({ where: { ...userBase, createdAt: { gte: start, lt: cutoff } }, }), this.prisma.partnerAccount.count({ where: { ...partnerBase, createdAt: { lt: cutoff } }, }), this.prisma.partnerAccount.count({ where: { ...partnerBase, createdAt: { gte: start, lt: cutoff } }, }), this.prisma.store.count({ where: { createdAt: { lt: cutoff } } }), this.prisma.store.count({ where: { createdAt: { gte: start, lt: cutoff } } }), this.prisma.order.count({ where: { createdAt: { lt: cutoff } } }), this.prisma.order.count({ where: { createdAt: { gte: start, lt: cutoff } } }), this.prisma.order.aggregate({ _sum: { payAmount: true }, where: { ...paid, paidAt: { lt: cutoff } }, }), this.prisma.order.aggregate({ _sum: { payAmount: true }, where: { ...paid, paidAt: { gte: start, lt: cutoff } }, }), this.prisma.redeemRecord.count({ where: { createdAt: { lt: cutoff } } }), this.prisma.redeemRecord.count({ where: { createdAt: { gte: start, lt: cutoff } } }), this.prisma.redeemRecord.aggregate({ _sum: { amount: true }, where: { createdAt: { lt: cutoff } }, }), this.prisma.redeemRecord.aggregate({ _sum: { amount: true }, where: { createdAt: { gte: start, lt: cutoff } }, }), ]); return { usersTotal, usersIncrement, partnersTotal, partnersIncrement, storesTotal, storesIncrement, ordersTotal, ordersIncrement, orderAmountTotal: asNumber(orderAmountTotal._sum.payAmount), orderAmountIncrement: asNumber(orderAmountIncrement._sum.payAmount), redeemsTotal, redeemsIncrement, redeemAmountTotal: asNumber(redeemAmountTotal._sum.amount), redeemAmountIncrement: asNumber(redeemAmountIncrement._sum.amount), }; } }