@@ -1,4 +1,5 @@
|
||||
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AuthUser } from '../guards/jwt-auth.guard';
|
||||
|
||||
export const CurrentUser = createParamDecorator(
|
||||
@@ -7,8 +8,29 @@ export const CurrentUser = createParamDecorator(
|
||||
},
|
||||
);
|
||||
|
||||
export function serializeBigInt<T>(value: T): T {
|
||||
return JSON.parse(
|
||||
JSON.stringify(value, (_k, v) => (typeof v === 'bigint' ? v.toString() : v)),
|
||||
);
|
||||
function isPrismaDecimal(value: unknown): value is Prisma.Decimal {
|
||||
return Prisma.Decimal.isDecimal(value);
|
||||
}
|
||||
|
||||
function convertForJson(value: unknown): unknown {
|
||||
if (typeof value === 'bigint') return value.toString();
|
||||
if (isPrismaDecimal(value)) {
|
||||
const n = Number(value.toString());
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
if (value instanceof Date) return value.toISOString();
|
||||
if (Array.isArray(value)) return value.map(convertForJson);
|
||||
if (value && typeof value === 'object') {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||
out[k] = convertForJson(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** JSON 安全序列化:bigint → string,Prisma.Decimal → number(避免金额变成 {} 被前端显示成 0) */
|
||||
export function serializeBigInt<T>(value: T): T {
|
||||
return JSON.parse(JSON.stringify(convertForJson(value))) as T;
|
||||
}
|
||||
|
||||
@@ -245,6 +245,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
||||
key: 'SHARE_HINT',
|
||||
label: '分享引导文案',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'global',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_HINT,
|
||||
@@ -254,6 +255,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
||||
key: 'SHARE_DEFAULT_TITLE',
|
||||
label: '默认分享标题',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'global',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_TITLE,
|
||||
@@ -263,6 +265,7 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
||||
key: 'SHARE_DEFAULT_DESC',
|
||||
label: '默认分享描述',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'global',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_DESC,
|
||||
@@ -272,150 +275,170 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
||||
key: 'SHARE_DEFAULT_IMAGE_URL',
|
||||
label: '默认分享图',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'global',
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
description: '各场景未配置图且无业务图时使用;建议接近 5:4',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_HOME_TITLE',
|
||||
label: '首页 · 标题',
|
||||
label: '标题',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'home',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_HOME_DESC',
|
||||
label: '首页 · 描述',
|
||||
label: '描述',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'home',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_HOME_IMAGE_URL',
|
||||
label: '首页 · 分享图',
|
||||
label: '分享图',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'home',
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
description: '留空则优先用首页首张轮播图,再回退默认分享图',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_STORES_TITLE',
|
||||
label: '门店列表 · 标题',
|
||||
label: '标题',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'stores',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_STORES_TITLE,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_STORES_DESC',
|
||||
label: '门店列表 · 描述',
|
||||
label: '描述',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'stores',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_STORES_IMAGE_URL',
|
||||
label: '门店列表 · 分享图',
|
||||
label: '分享图',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'stores',
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_STORE_DETAIL_TITLE',
|
||||
label: '门店详情 · 标题',
|
||||
label: '标题',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'storeDetail',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
description: '留空则用门店名称',
|
||||
description: '小程序好友/朋友圈分享固定用门店名称;本项仅作其它端回退参考,可留空',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_STORE_DETAIL_DESC',
|
||||
label: '门店详情 · 描述',
|
||||
label: '描述',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'storeDetail',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
description: '留空则用门店简介/地址',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_STORE_DETAIL_IMAGE_URL',
|
||||
label: '门店详情 · 分享图',
|
||||
label: '分享图',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'storeDetail',
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
description: '留空则用门头图/环境图',
|
||||
description: '小程序分享固定用门头图/环境图;本项可留空',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_BENEFIT_TITLE',
|
||||
label: '权益页 · 标题',
|
||||
label: '标题',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'benefit',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_BENEFIT_TITLE,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_BENEFIT_DESC',
|
||||
label: '权益页 · 描述',
|
||||
label: '描述',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'benefit',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_BENEFIT_IMAGE_URL',
|
||||
label: '权益页 · 分享图',
|
||||
label: '分享图',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'benefit',
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_MINE_TITLE',
|
||||
label: '我的 · 标题',
|
||||
label: '标题',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'mine',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_MINE_TITLE,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_MINE_DESC',
|
||||
label: '我的 · 描述',
|
||||
label: '描述',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'mine',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_MINE_IMAGE_URL',
|
||||
label: '我的 · 分享图',
|
||||
label: '分享图',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'mine',
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_PRODUCT_DETAIL_TITLE',
|
||||
label: '商品详情 · 标题',
|
||||
label: '标题',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'productDetail',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
description: '留空则用商品名称',
|
||||
description: '小程序好友/朋友圈分享固定用商品名称;本项可留空',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_PRODUCT_DETAIL_DESC',
|
||||
label: '商品详情 · 描述',
|
||||
label: '描述',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'productDetail',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
description: '留空则用商品副标题',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_PRODUCT_DETAIL_IMAGE_URL',
|
||||
label: '商品详情 · 分享图',
|
||||
label: '分享图',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'productDetail',
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
description: '留空则用商品主图',
|
||||
description: '小程序分享固定用商品主图;本项可留空',
|
||||
},
|
||||
{
|
||||
key: 'SHARE_ORDER_DETAIL_TITLE',
|
||||
label: '订单详情 · 标题',
|
||||
label: '标题',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'orderDetail',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
placeholder: DEFAULT_SHARE_ORDER_TITLE,
|
||||
@@ -423,15 +446,17 @@ export const SYSTEM_CONFIG_FIELDS: SystemConfigFieldMeta[] = [
|
||||
},
|
||||
{
|
||||
key: 'SHARE_ORDER_DETAIL_DESC',
|
||||
label: '订单详情 · 描述',
|
||||
label: '描述',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'orderDetail',
|
||||
type: 'string',
|
||||
requiresRestart: false,
|
||||
},
|
||||
{
|
||||
key: 'SHARE_ORDER_DETAIL_IMAGE_URL',
|
||||
label: '订单详情 · 分享图',
|
||||
label: '分享图',
|
||||
group: G.wechat_mini_share,
|
||||
subgroup: 'orderDetail',
|
||||
type: 'image',
|
||||
requiresRestart: false,
|
||||
},
|
||||
|
||||
@@ -3,6 +3,9 @@ import {
|
||||
WECOM_PUSH_CONDITIONS,
|
||||
WECOM_PUSH_DEFAULT_ALERT_CONDITIONS,
|
||||
WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS,
|
||||
WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS,
|
||||
WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
|
||||
WECOM_STORE_AUDIT_PUSH_NAME,
|
||||
maskWecomWebhookUrl,
|
||||
parseWecomPushConditions,
|
||||
type WecomMessagePushDto,
|
||||
@@ -40,62 +43,102 @@ export class WecomMessagePushService implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
/** 表空时从 .env / 旧 dev_plan_settings 迁移默认推送(v3.4.11) */
|
||||
/** 表空时从 .env / 旧 dev_plan_settings 迁移默认推送;并按名称 upsert「门店审核通知群」(v3.5.3) */
|
||||
async ensureDefaults(): Promise<void> {
|
||||
const count = await this.prisma.wecomMessagePush.count();
|
||||
if (count > 0) return;
|
||||
|
||||
const alertUrl = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
|
||||
if (alertUrl) {
|
||||
const alertEnabled = process.env.WECOM_ALERT_ENABLED !== 'false';
|
||||
await this.prisma.wecomMessagePush.create({
|
||||
data: {
|
||||
name: '运营告警',
|
||||
webhookUrl: alertUrl,
|
||||
enabled: alertEnabled,
|
||||
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_ALERT_CONDITIONS),
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
this.logger.log('seeded wecom message push: 运营告警');
|
||||
}
|
||||
|
||||
let devWebhook: string | null = null;
|
||||
let devUserId: string | null = null;
|
||||
let devEnabled = false;
|
||||
try {
|
||||
const rows = await this.prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
task_dispatch_webhook_url: string | null;
|
||||
task_dispatch_wecom_user_id: string | null;
|
||||
task_dispatch_enabled: number | boolean | null;
|
||||
}>
|
||||
>(
|
||||
'SELECT task_dispatch_webhook_url, task_dispatch_wecom_user_id, task_dispatch_enabled FROM dev_plan_settings LIMIT 1',
|
||||
);
|
||||
const row = rows[0];
|
||||
if (row) {
|
||||
devWebhook = row.task_dispatch_webhook_url;
|
||||
devUserId = row.task_dispatch_wecom_user_id;
|
||||
devEnabled = !!row.task_dispatch_enabled;
|
||||
if (count === 0) {
|
||||
const alertUrl = (process.env.WECOM_ALERT_WEBHOOK_URL || '').trim();
|
||||
if (alertUrl) {
|
||||
const alertEnabled = process.env.WECOM_ALERT_ENABLED !== 'false';
|
||||
await this.prisma.wecomMessagePush.create({
|
||||
data: {
|
||||
name: '运营告警',
|
||||
webhookUrl: alertUrl,
|
||||
enabled: alertEnabled,
|
||||
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_ALERT_CONDITIONS),
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
this.logger.log('seeded wecom message push: 运营告警');
|
||||
}
|
||||
|
||||
let devWebhook: string | null = null;
|
||||
let devUserId: string | null = null;
|
||||
let devEnabled = false;
|
||||
try {
|
||||
const rows = await this.prisma.$queryRawUnsafe<
|
||||
Array<{
|
||||
task_dispatch_webhook_url: string | null;
|
||||
task_dispatch_wecom_user_id: string | null;
|
||||
task_dispatch_enabled: number | boolean | null;
|
||||
}>
|
||||
>(
|
||||
'SELECT task_dispatch_webhook_url, task_dispatch_wecom_user_id, task_dispatch_enabled FROM dev_plan_settings LIMIT 1',
|
||||
);
|
||||
const row = rows[0];
|
||||
if (row) {
|
||||
devWebhook = row.task_dispatch_webhook_url;
|
||||
devUserId = row.task_dispatch_wecom_user_id;
|
||||
devEnabled = !!row.task_dispatch_enabled;
|
||||
}
|
||||
} catch {
|
||||
// 列已迁移删除,跳过
|
||||
}
|
||||
|
||||
if (devWebhook?.trim()) {
|
||||
await this.prisma.wecomMessagePush.create({
|
||||
data: {
|
||||
name: '开发任务派发',
|
||||
webhookUrl: devWebhook.trim(),
|
||||
enabled: devEnabled,
|
||||
mentionWecomUserId: devUserId?.trim() || null,
|
||||
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS),
|
||||
sortOrder: 10,
|
||||
},
|
||||
});
|
||||
this.logger.log('seeded wecom message push: 开发任务派发');
|
||||
}
|
||||
} catch {
|
||||
// 列已迁移删除,跳过
|
||||
}
|
||||
|
||||
if (devWebhook?.trim()) {
|
||||
await this.ensureStoreAuditPush();
|
||||
}
|
||||
|
||||
/** 按名称 upsert「门店审核通知群」:已有行保留 webhook;无行则 env 或占位 URL */
|
||||
private async ensureStoreAuditPush(): Promise<void> {
|
||||
const existing = await this.prisma.wecomMessagePush.findFirst({
|
||||
where: { name: WECOM_STORE_AUDIT_PUSH_NAME },
|
||||
});
|
||||
if (existing) return;
|
||||
|
||||
const conditionsJson = JSON.stringify(WECOM_PUSH_DEFAULT_STORE_AUDIT_CONDITIONS);
|
||||
const envUrl = (process.env.WECOM_STORE_AUDIT_WEBHOOK_URL || '').trim();
|
||||
|
||||
if (envUrl) {
|
||||
await this.prisma.wecomMessagePush.create({
|
||||
data: {
|
||||
name: '开发任务派发',
|
||||
webhookUrl: devWebhook.trim(),
|
||||
enabled: devEnabled,
|
||||
mentionWecomUserId: devUserId?.trim() || null,
|
||||
pushConditions: JSON.stringify(WECOM_PUSH_DEFAULT_DEV_DISPATCH_CONDITIONS),
|
||||
sortOrder: 10,
|
||||
name: WECOM_STORE_AUDIT_PUSH_NAME,
|
||||
webhookUrl: envUrl,
|
||||
enabled: true,
|
||||
pushConditions: conditionsJson,
|
||||
sortOrder: 20,
|
||||
},
|
||||
});
|
||||
this.logger.log('seeded wecom message push: 开发任务派发');
|
||||
this.logger.log(`seeded wecom message push: ${WECOM_STORE_AUDIT_PUSH_NAME} (from env)`);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.prisma.wecomMessagePush.create({
|
||||
data: {
|
||||
name: WECOM_STORE_AUDIT_PUSH_NAME,
|
||||
webhookUrl: WECOM_STORE_AUDIT_PLACEHOLDER_WEBHOOK,
|
||||
enabled: false,
|
||||
pushConditions: conditionsJson,
|
||||
sortOrder: 20,
|
||||
},
|
||||
});
|
||||
this.logger.log(
|
||||
`seeded wecom message push: ${WECOM_STORE_AUDIT_PUSH_NAME} (placeholder, disabled)`,
|
||||
);
|
||||
}
|
||||
|
||||
async listMatchingPushes(eventKey: WecomPushCondition): Promise<PushRow[]> {
|
||||
|
||||
@@ -58,7 +58,16 @@ export class AdminRedeemService {
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where }),
|
||||
]);
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
return serializeBigInt({
|
||||
items: items.map((row) => ({
|
||||
...row,
|
||||
amount: Number(row.amount),
|
||||
settleAmount: Number(row.settleAmount),
|
||||
})),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
async detailRecord(id: bigint) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
generateRedeemNo,
|
||||
generateRedeemPendingNo,
|
||||
REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||
resolveSettlementRate,
|
||||
validateRedeemAmount,
|
||||
allocateBenefitCoupons,
|
||||
} from '@dukang/domain';
|
||||
@@ -176,8 +177,15 @@ export class RedeemService {
|
||||
normalizedAllocations: Array<{ couponId: string; amount: number }>,
|
||||
analyticsExtra?: { channel: 'token' | 'phone'; sessionId?: string; tokenSuffix?: string },
|
||||
) {
|
||||
const settlementRate = Number(account.store.settlementRate);
|
||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||
const amountNum = Number(amount);
|
||||
if (!Number.isFinite(amountNum) || amountNum <= 0) {
|
||||
throw new BadRequestException('核销金额必须大于 0');
|
||||
}
|
||||
const settlementRate = resolveSettlementRate(account.store.settlementRate);
|
||||
const settleAmount = calcRedeemSettleAmount(amountNum, settlementRate);
|
||||
if (!Number.isFinite(settleAmount) || settleAmount < 0) {
|
||||
throw new BadRequestException('结算金额计算异常');
|
||||
}
|
||||
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
|
||||
|
||||
const [userRow, storeRow] = await Promise.all([
|
||||
@@ -200,7 +208,7 @@ export class RedeemService {
|
||||
userId,
|
||||
couponId: BigInt(normalizedAllocations[0].couponId),
|
||||
storeId: account.storeId,
|
||||
amount,
|
||||
amount: amountNum,
|
||||
settleAmount,
|
||||
channel: redeemChannel,
|
||||
isTest,
|
||||
@@ -214,16 +222,14 @@ export class RedeemService {
|
||||
},
|
||||
});
|
||||
|
||||
if (!isTest) {
|
||||
await this.settlementService.createStorePayout(
|
||||
redeemRecord.id,
|
||||
account.storeId,
|
||||
amount,
|
||||
settleAmount,
|
||||
settlementRate,
|
||||
tx,
|
||||
);
|
||||
}
|
||||
await this.settlementService.createStorePayout(
|
||||
redeemRecord.id,
|
||||
account.storeId,
|
||||
amountNum,
|
||||
settleAmount,
|
||||
settlementRate,
|
||||
tx,
|
||||
);
|
||||
|
||||
return redeemRecord;
|
||||
});
|
||||
@@ -240,7 +246,7 @@ export class RedeemService {
|
||||
const redeemExtra = {
|
||||
redeemRecordId: record.id.toString(),
|
||||
storeId: account.storeId.toString(),
|
||||
amount,
|
||||
amount: amountNum,
|
||||
channel: analyticsExtra?.channel ?? 'token',
|
||||
...(analyticsExtra?.sessionId ? { sessionId: analyticsExtra.sessionId } : {}),
|
||||
...(analyticsExtra?.tokenSuffix ? { tokenSuffix: analyticsExtra.tokenSuffix } : {}),
|
||||
@@ -252,7 +258,7 @@ export class RedeemService {
|
||||
refId: record.id,
|
||||
extraJson: {
|
||||
redeemNo: record.redeemNo,
|
||||
amount,
|
||||
amount: amountNum,
|
||||
userId: userId.toString(),
|
||||
channel: analyticsExtra?.channel ?? 'token',
|
||||
},
|
||||
@@ -270,7 +276,11 @@ export class RedeemService {
|
||||
extraJson: redeemExtra,
|
||||
});
|
||||
|
||||
return record;
|
||||
return {
|
||||
...record,
|
||||
amount: amountNum,
|
||||
settleAmount,
|
||||
};
|
||||
}
|
||||
|
||||
async sendPhoneLookupSms(storeAccountId: bigint, storeId: bigint, phone: string) {
|
||||
@@ -484,6 +494,12 @@ export class RedeemService {
|
||||
}
|
||||
|
||||
async createToken(userId: bigint, body: { couponId?: string; amount: number; storeId?: string }) {
|
||||
const amount = Number(body.amount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new BadRequestException('核销金额必须大于 0');
|
||||
}
|
||||
body = { ...body, amount };
|
||||
|
||||
let allocations: Array<{ couponId: string; amount: number }>;
|
||||
|
||||
if (body.couponId) {
|
||||
@@ -993,6 +1009,9 @@ export class RedeemService {
|
||||
await this.validateAllocations(normalizedAllocations);
|
||||
|
||||
const amount = Number(pending.amount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new BadRequestException('待处理单核销金额无效');
|
||||
}
|
||||
const record = await this.executeRedeem(
|
||||
account,
|
||||
pending.userId,
|
||||
@@ -1101,7 +1120,26 @@ export class RedeemService {
|
||||
}),
|
||||
this.prisma.redeemRecord.count({ where: { storeId } }),
|
||||
]);
|
||||
return { list: serializeBigInt(list), total, page, pageSize };
|
||||
return {
|
||||
list: serializeBigInt(
|
||||
list.map((r) => ({
|
||||
...r,
|
||||
amount: Number(r.amount),
|
||||
settleAmount: Number(r.settleAmount),
|
||||
payout: r.payout
|
||||
? {
|
||||
...r.payout,
|
||||
redeemAmount: Number(r.payout.redeemAmount),
|
||||
payoutAmount: Number(r.payout.payoutAmount),
|
||||
settlementRate: Number(r.payout.settlementRate),
|
||||
}
|
||||
: r.payout,
|
||||
})),
|
||||
),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getShopRedeemStats(
|
||||
@@ -1174,7 +1212,11 @@ export class RedeemService {
|
||||
todayAmount,
|
||||
todayScanCount,
|
||||
todayPhoneCount,
|
||||
recentRecords: recent,
|
||||
recentRecords: recent.map((r) => ({
|
||||
...r,
|
||||
amount: Number(r.amount),
|
||||
settleAmount: Number(r.settleAmount),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
} from '@dukang/shared-types';
|
||||
import {
|
||||
calcLogisticsFeeByBottles,
|
||||
calcRedeemSettleAmount,
|
||||
pickPayoutsForWithdrawAmount,
|
||||
resolveSettlementRate,
|
||||
sumUnbilledPayoutAmount,
|
||||
validateStoreWithdraw,
|
||||
type LogisticsPricingRule,
|
||||
@@ -96,6 +98,12 @@ export class SettlementService {
|
||||
settlementRate: number,
|
||||
tx?: Prisma.TransactionClient,
|
||||
) {
|
||||
const redeemAmountNum = Number(redeemAmount);
|
||||
const payoutAmountNum = Number(payoutAmount);
|
||||
const settlementRateNum = resolveSettlementRate(settlementRate);
|
||||
if (!(redeemAmountNum > 0) || !(payoutAmountNum > 0)) {
|
||||
throw new BadRequestException('结算金额无效,无法入账');
|
||||
}
|
||||
const expectedPayAt = new Date();
|
||||
expectedPayAt.setDate(expectedPayAt.getDate() + 1);
|
||||
const client = tx ?? this.prisma;
|
||||
@@ -103,9 +111,9 @@ export class SettlementService {
|
||||
data: {
|
||||
redeemRecordId,
|
||||
storeId,
|
||||
redeemAmount,
|
||||
payoutAmount,
|
||||
settlementRate,
|
||||
redeemAmount: redeemAmountNum,
|
||||
payoutAmount: payoutAmountNum,
|
||||
settlementRate: settlementRateNum,
|
||||
status: 'PENDING',
|
||||
expectedPayAt,
|
||||
},
|
||||
@@ -152,17 +160,65 @@ export class SettlementService {
|
||||
}
|
||||
|
||||
private async listAvailableUnbilledPayouts(storeId: bigint) {
|
||||
await this.backfillMissingStorePayouts(storeId);
|
||||
return this.prisma.storePayout.findMany({
|
||||
where: {
|
||||
storeId,
|
||||
status: 'PENDING',
|
||||
storeBillId: null,
|
||||
withdrawItem: null,
|
||||
withdrawItem: { is: null },
|
||||
},
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 补写历史核销缺失的 store_payout(含测试流水)。
|
||||
* 线上曾出现核销成功但未入账、可提余额为 0 的情况。
|
||||
*/
|
||||
async backfillMissingStorePayouts(storeId: bigint) {
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id: storeId },
|
||||
select: { settlementRate: true },
|
||||
});
|
||||
if (!store) return 0;
|
||||
|
||||
const orphans = await this.prisma.redeemRecord.findMany({
|
||||
where: {
|
||||
storeId,
|
||||
payout: { is: null },
|
||||
},
|
||||
select: { id: true, amount: true, settleAmount: true },
|
||||
take: 200,
|
||||
});
|
||||
if (!orphans.length) return 0;
|
||||
|
||||
const settlementRate = resolveSettlementRate(store.settlementRate);
|
||||
let created = 0;
|
||||
for (const row of orphans) {
|
||||
const redeemAmount = Number(row.amount);
|
||||
let payoutAmount = Number(row.settleAmount);
|
||||
if (!(redeemAmount > 0)) continue;
|
||||
if (!(payoutAmount > 0)) {
|
||||
payoutAmount = calcRedeemSettleAmount(redeemAmount, settlementRate);
|
||||
}
|
||||
if (!(payoutAmount > 0)) continue;
|
||||
try {
|
||||
await this.createStorePayout(
|
||||
row.id,
|
||||
storeId,
|
||||
redeemAmount,
|
||||
payoutAmount,
|
||||
settlementRate,
|
||||
);
|
||||
created += 1;
|
||||
} catch {
|
||||
// 并发补写时可能已存在 payout
|
||||
}
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
private async todayWithdrawAppliedAmount(storeId: bigint, now = new Date()) {
|
||||
const start = startOfDay(now);
|
||||
const end = new Date(start);
|
||||
@@ -291,7 +347,7 @@ export class SettlementService {
|
||||
storeId,
|
||||
status: 'PENDING',
|
||||
storeBillId: null,
|
||||
withdrawItem: null,
|
||||
withdrawItem: { is: null },
|
||||
},
|
||||
select: { id: true, payoutAmount: true },
|
||||
});
|
||||
@@ -782,7 +838,7 @@ export class SettlementService {
|
||||
storeBillId: null,
|
||||
createdAt: { gte: start, lt: end },
|
||||
// 排除已锁定在待审提现单中的明细,避免出账与提现双占
|
||||
withdrawItem: null,
|
||||
withdrawItem: { is: null },
|
||||
status: 'PENDING',
|
||||
},
|
||||
include: { store: { select: { id: true, settlementRate: true } } },
|
||||
@@ -1322,12 +1378,9 @@ export class SettlementService {
|
||||
cityId: primary.cityId,
|
||||
payStatus: 'PAID',
|
||||
paidAt: { gte: periodStart, lte: periodEnd },
|
||||
isTest: false,
|
||||
},
|
||||
});
|
||||
const orderCommission = primary.isTest
|
||||
? 0
|
||||
: orders.reduce((sum, o) => {
|
||||
const orderCommission = orders.reduce((sum, o) => {
|
||||
if (o.partnerAccountIdAtPay) {
|
||||
if (o.partnerAccountIdAtPay !== primary.id) return sum;
|
||||
const rate = o.orderCommissionRateAtPay != null ? Number(o.orderCommissionRateAtPay) : 0;
|
||||
@@ -1341,14 +1394,12 @@ export class SettlementService {
|
||||
select: { id: true },
|
||||
});
|
||||
const storeIds = stores.map((s) => s.id);
|
||||
const redeems =
|
||||
primary.isTest || !storeIds.length
|
||||
? []
|
||||
: await this.prisma.redeemRecord.findMany({
|
||||
const redeems = !storeIds.length
|
||||
? []
|
||||
: await this.prisma.redeemRecord.findMany({
|
||||
where: {
|
||||
storeId: { in: storeIds },
|
||||
createdAt: { gte: periodStart, lte: periodEnd },
|
||||
isTest: false,
|
||||
},
|
||||
});
|
||||
const redeemCommission = redeems.reduce(
|
||||
@@ -1593,7 +1644,6 @@ export class SettlementService {
|
||||
payStatus: 'PAID',
|
||||
deliveryType: { in: ['LOCAL', 'CROSS_CITY'] },
|
||||
completedAt: { gte: start, lt: end },
|
||||
isTest: false,
|
||||
},
|
||||
orderBy: { completedAt: 'asc' },
|
||||
});
|
||||
@@ -1944,7 +1994,6 @@ export class SettlementService {
|
||||
quantity: true,
|
||||
deliveryType: true,
|
||||
payStatus: true,
|
||||
isTest: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1953,7 +2002,6 @@ export class SettlementService {
|
||||
|
||||
const eligible = deliveries.filter(
|
||||
(d) =>
|
||||
!d.order.isTest &&
|
||||
d.order.payStatus === 'PAID' &&
|
||||
(d.order.deliveryType === 'LOCAL' || d.order.deliveryType === 'CROSS_CITY'),
|
||||
);
|
||||
|
||||
@@ -22,6 +22,14 @@ import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
|
||||
type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
|
||||
|
||||
const MIN_ENV_PHOTOS = 3;
|
||||
const MAX_ENV_PHOTOS = 20;
|
||||
|
||||
/** Store 表标量字段(不含媒体) */
|
||||
const STORE_SCALAR_CHANGE_FIELDS = STORE_INFO_CHANGEABLE_FIELDS.filter(
|
||||
(f) => f !== 'coverUrl' && f !== 'envPhotoUrls',
|
||||
) as ChangeableField[];
|
||||
|
||||
function normalizeOptionalTextField(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const s = String(value).trim();
|
||||
@@ -45,6 +53,27 @@ function coerceNumberOrNull(value: unknown): number | null {
|
||||
return n;
|
||||
}
|
||||
|
||||
function normalizeEnvPhotoUrls(raw: unknown): string[] {
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new BadRequestException('环境照片须为 URL 数组');
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const urls: string[] = [];
|
||||
for (const item of raw) {
|
||||
const u = String(item ?? '').trim();
|
||||
if (!u || seen.has(u)) continue;
|
||||
seen.add(u);
|
||||
urls.push(u);
|
||||
}
|
||||
if (urls.length < MIN_ENV_PHOTOS) {
|
||||
throw new BadRequestException(`请上传至少 ${MIN_ENV_PHOTOS} 张环境照片`);
|
||||
}
|
||||
if (urls.length > MAX_ENV_PHOTOS) {
|
||||
throw new BadRequestException(`环境照片最多 ${MAX_ENV_PHOTOS} 张`);
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
/** 将白名单字段从提交 body 规整为可落库的 proposedSnapshot */
|
||||
function buildProposedSnapshot(fields: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -67,6 +96,15 @@ function buildProposedSnapshot(fields: Record<string, unknown>): Record<string,
|
||||
case 'benefitUsageRule':
|
||||
out[field] = normalizeOptionalTextField(raw);
|
||||
break;
|
||||
case 'coverUrl': {
|
||||
const url = String(raw ?? '').trim();
|
||||
if (!url) throw new BadRequestException('请上传门头照');
|
||||
out[field] = url;
|
||||
break;
|
||||
}
|
||||
case 'envPhotoUrls':
|
||||
out[field] = normalizeEnvPhotoUrls(raw);
|
||||
break;
|
||||
default:
|
||||
out[field] = raw == null ? null : String(raw);
|
||||
}
|
||||
@@ -74,18 +112,13 @@ function buildProposedSnapshot(fields: Record<string, unknown>): Record<string,
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 取 live store 上白名单字段的当前值(用于快照与 diff) */
|
||||
function pickLiveFields(store: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
|
||||
const v = store[field];
|
||||
out[field] = v == null ? null : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function looseEqual(a: unknown, b: unknown): boolean {
|
||||
if (a == null && b == null) return true;
|
||||
if (Array.isArray(a) || Array.isArray(b)) {
|
||||
const aa = Array.isArray(a) ? a.map((x) => String(x ?? '').trim()).filter(Boolean) : [];
|
||||
const bb = Array.isArray(b) ? b.map((x) => String(x ?? '').trim()).filter(Boolean) : [];
|
||||
return aa.length === bb.length && aa.every((x, i) => x === bb[i]);
|
||||
}
|
||||
return String(a) === String(b);
|
||||
}
|
||||
|
||||
@@ -111,45 +144,150 @@ export class StoreInfoChangeService {
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
) {}
|
||||
|
||||
/** 合伙人 / 门店端 提交基础信息变更 */
|
||||
private async loadLiveMediaFields(storeId: bigint, coverResourceId: bigint | null) {
|
||||
let coverUrl: string | null = null;
|
||||
if (coverResourceId) {
|
||||
const cover = await this.prisma.commonResource.findUnique({
|
||||
where: { id: coverResourceId },
|
||||
select: { url: true, status: true },
|
||||
});
|
||||
if (cover?.status === 'ACTIVE' && cover.url) coverUrl = cover.url;
|
||||
}
|
||||
const envs = await this.prisma.commonResource.findMany({
|
||||
where: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: storeId,
|
||||
bizType: 'ENV',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
select: { url: true },
|
||||
});
|
||||
return {
|
||||
coverUrl,
|
||||
envPhotoUrls: envs.map((e) => e.url).filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
private pickLiveScalarFields(store: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of STORE_SCALAR_CHANGE_FIELDS) {
|
||||
const v = store[field];
|
||||
out[field] = v == null ? null : v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 审核通过后写入门头照 / 环境图 */
|
||||
private async applyApprovedMedia(storeId: bigint, proposed: Record<string, unknown>) {
|
||||
const ossBucket = process.env.OSS_BUCKET ?? 'legacy';
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id: storeId },
|
||||
select: { coverResourceId: true },
|
||||
});
|
||||
if (!store) throw new NotFoundException('门店不存在');
|
||||
|
||||
if ('coverUrl' in proposed) {
|
||||
const coverUrl = String(proposed.coverUrl ?? '').trim();
|
||||
if (!coverUrl) throw new BadRequestException('门头照不能为空');
|
||||
if (store.coverResourceId) {
|
||||
await this.prisma.commonResource.update({
|
||||
where: { id: store.coverResourceId },
|
||||
data: { url: coverUrl, ossKey: coverUrl, status: 'ACTIVE' },
|
||||
});
|
||||
} else {
|
||||
const cover = await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: storeId,
|
||||
bizType: 'COVER',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket,
|
||||
ossKey: coverUrl,
|
||||
url: coverUrl,
|
||||
},
|
||||
});
|
||||
await this.prisma.store.update({
|
||||
where: { id: storeId },
|
||||
data: { coverResourceId: cover.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if ('envPhotoUrls' in proposed) {
|
||||
const urls = normalizeEnvPhotoUrls(proposed.envPhotoUrls);
|
||||
await this.prisma.commonResource.updateMany({
|
||||
where: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: storeId,
|
||||
bizType: 'ENV',
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
data: { status: 'DELETED' },
|
||||
});
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
await this.prisma.commonResource.create({
|
||||
data: {
|
||||
ownerType: 'STORE',
|
||||
ownerId: storeId,
|
||||
bizType: 'ENV',
|
||||
mediaType: 'IMAGE',
|
||||
ossBucket,
|
||||
ossKey: urls[i],
|
||||
url: urls[i],
|
||||
sortOrder: i,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 合伙人 / 门店端 提交基础信息变更(含门头照/环境图) */
|
||||
async submitChange(input: {
|
||||
submitterType: StoreInfoChangeSubmitterType;
|
||||
submitterId: bigint;
|
||||
storeId: bigint;
|
||||
fields: Record<string, unknown>;
|
||||
}): Promise<StoreInfoChangeRequestDto> {
|
||||
// 1) 校验归属
|
||||
let store: Record<string, unknown>;
|
||||
let store: Record<string, unknown> & { coverResourceId?: bigint | null };
|
||||
if (input.submitterType === 'PARTNER') {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(input.submitterId);
|
||||
const found = await this.prisma.store.findFirst({
|
||||
where: { id: input.storeId, partnerAccountId: primary.id },
|
||||
});
|
||||
if (!found) throw new NotFoundException('门店不存在或无权操作');
|
||||
store = found as unknown as Record<string, unknown>;
|
||||
store = found as unknown as Record<string, unknown> & { coverResourceId?: bigint | null };
|
||||
} else {
|
||||
// SHOP / HQ_DIRECT_ADMIN:先校验门店绑定/存在
|
||||
if (input.submitterType === 'SHOP') {
|
||||
await this.storeService.getShopStore(input.submitterId, input.storeId);
|
||||
}
|
||||
const found = await this.prisma.store.findUnique({ where: { id: input.storeId } });
|
||||
if (!found) throw new NotFoundException('门店不存在');
|
||||
store = found as unknown as Record<string, unknown>;
|
||||
store = found as unknown as Record<string, unknown> & { coverResourceId?: bigint | null };
|
||||
}
|
||||
|
||||
if (store.status === 'CLOSED') {
|
||||
throw new BadRequestException('门店已关闭,不可提交变更');
|
||||
}
|
||||
if (String(store.auditStatus || '') === 'PENDING') {
|
||||
throw new BadRequestException('门店审核中,暂不可提交变更');
|
||||
}
|
||||
|
||||
// 2) 规整 proposed + diff
|
||||
const proposed = buildProposedSnapshot(input.fields);
|
||||
const live = pickLiveFields(store);
|
||||
const media = await this.loadLiveMediaFields(
|
||||
input.storeId,
|
||||
store.coverResourceId != null ? BigInt(store.coverResourceId as never) : null,
|
||||
);
|
||||
const live: Record<string, unknown> = {
|
||||
...this.pickLiveScalarFields(store),
|
||||
coverUrl: media.coverUrl,
|
||||
envPhotoUrls: media.envPhotoUrls,
|
||||
};
|
||||
const changedFields = computeChangedFields(live, proposed);
|
||||
if (changedFields.length === 0) {
|
||||
throw new BadRequestException('没有检测到需要变更的字段');
|
||||
}
|
||||
|
||||
// 3) 基础校验
|
||||
if (proposed.name != null && !String(proposed.name).trim()) {
|
||||
throw new BadRequestException('请填写门店名称');
|
||||
}
|
||||
@@ -172,7 +310,6 @@ export class StoreInfoChangeService {
|
||||
throw new BadRequestException('经纬度须同时提供');
|
||||
}
|
||||
|
||||
// 4) 同门店已有 PENDING 则替换(最新优先)
|
||||
await this.prisma.storeInfoChangeRequest.deleteMany({
|
||||
where: { storeId: input.storeId, status: 'PENDING' },
|
||||
});
|
||||
@@ -195,7 +332,6 @@ export class StoreInfoChangeService {
|
||||
return serializeBigInt(this.toDto(created as unknown as Record<string, unknown>));
|
||||
}
|
||||
|
||||
/** 合伙人端查看本门店历史变更 */
|
||||
async listPartnerRequests(
|
||||
storeId: bigint,
|
||||
partnerAccountId: bigint,
|
||||
@@ -214,7 +350,6 @@ export class StoreInfoChangeService {
|
||||
return rows.map((r) => serializeBigInt(this.toDto(r as unknown as Record<string, unknown>)));
|
||||
}
|
||||
|
||||
/** 总部列表 */
|
||||
async adminList(opts: {
|
||||
status?: StoreInfoChangeStatus;
|
||||
page?: number;
|
||||
@@ -241,7 +376,6 @@ export class StoreInfoChangeService {
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
/** 总部待审总数(与套餐审核汇总,用于统一 badge) */
|
||||
async adminSummary(): Promise<{ pendingCount: number; packagePendingCount: number }> {
|
||||
const [infoPending, packagePending] = await Promise.all([
|
||||
this.prisma.storeInfoChangeRequest.count({ where: { status: 'PENDING' } }),
|
||||
@@ -250,7 +384,6 @@ export class StoreInfoChangeService {
|
||||
return { pendingCount: infoPending, packagePendingCount: packagePending };
|
||||
}
|
||||
|
||||
/** 总部详情(含字段级 diff) */
|
||||
async adminDetail(id: bigint): Promise<StoreInfoChangeRequestDto> {
|
||||
const row = await this.prisma.storeInfoChangeRequest.findUnique({
|
||||
where: { id },
|
||||
@@ -272,7 +405,6 @@ export class StoreInfoChangeService {
|
||||
return { ...dto, diffs };
|
||||
}
|
||||
|
||||
/** 总部审核通过/驳回 */
|
||||
async audit(input: {
|
||||
id: bigint;
|
||||
action: 'APPROVE' | 'REJECT';
|
||||
@@ -299,15 +431,19 @@ export class StoreInfoChangeService {
|
||||
return serializeBigInt(this.toDto(updated as unknown as Record<string, unknown>));
|
||||
}
|
||||
|
||||
// APPROVE:将 proposedSnapshot 写入 Store(白名单内)
|
||||
const proposed = (row as { proposedSnapshot?: Record<string, unknown> }).proposedSnapshot || {};
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
|
||||
for (const field of STORE_SCALAR_CHANGE_FIELDS) {
|
||||
if (!(field in proposed)) continue;
|
||||
const v = proposed[field];
|
||||
data[field] = v == null ? null : v;
|
||||
}
|
||||
await this.prisma.store.update({ where: { id: row.storeId }, data: data as never });
|
||||
if (Object.keys(data).length) {
|
||||
await this.prisma.store.update({ where: { id: row.storeId }, data: data as never });
|
||||
}
|
||||
if ('coverUrl' in proposed || 'envPhotoUrls' in proposed) {
|
||||
await this.applyApprovedMedia(row.storeId, proposed);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.storeInfoChangeRequest.update({
|
||||
where: { id: input.id },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
@@ -13,14 +14,18 @@ import {
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { StoreService } from './store.service';
|
||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||
|
||||
type PackageInput = Record<string, unknown>;
|
||||
|
||||
@Injectable()
|
||||
export class StorePackageService {
|
||||
private readonly logger = new Logger(StorePackageService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storeService: StoreService,
|
||||
private readonly wecomPush: WecomMessagePushService,
|
||||
) {}
|
||||
|
||||
normalizePackages(raw: unknown): StorePackageItemDto[] {
|
||||
@@ -181,6 +186,34 @@ export class StorePackageService {
|
||||
submitterId,
|
||||
},
|
||||
});
|
||||
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id: storeId },
|
||||
select: { name: true, cityName: true },
|
||||
});
|
||||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
|
||||
const submitterLabel = submitterType === 'PARTNER' ? '合伙人' : '门店';
|
||||
void this.wecomPush
|
||||
.dispatchMarkdown(
|
||||
'store.package_audit_pending',
|
||||
[
|
||||
'**套餐变更待审核**',
|
||||
`门店:${store?.name ?? storeId}`,
|
||||
store?.cityName ? `城市:${store.cityName}` : null,
|
||||
`提交端:${submitterLabel}`,
|
||||
`套餐条数:${packages.length}`,
|
||||
`时间:${now}`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n'),
|
||||
{ applyMention: false },
|
||||
)
|
||||
.catch((e) =>
|
||||
this.logger.warn(
|
||||
`store.package_audit_pending wecom push failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
),
|
||||
);
|
||||
|
||||
return serializeBigInt({
|
||||
id: req.id.toString(),
|
||||
status: req.status,
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { loadAppConfig, ClientApp, SmsScene } from '@dukang/shared-types';
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
TestWhitelistService,
|
||||
normalizeTestPhone,
|
||||
} from '../../common/test-whitelist/test-whitelist.service';
|
||||
import { WecomMessagePushService } from '../../integrations/wecom/wecom-message-push.service';
|
||||
|
||||
function haversineMeters(lat1: number, lng1: number, lat2: number, lng2: number): number {
|
||||
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||
@@ -61,6 +63,7 @@ function parseOptionalCoord(value: unknown, kind: 'lat' | 'lng' = 'lng'): number
|
||||
@Injectable()
|
||||
export class StoreService {
|
||||
private readonly config = loadAppConfig();
|
||||
private readonly logger = new Logger(StoreService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -70,8 +73,33 @@ export class StoreService {
|
||||
private readonly storeCategoryService: StoreCategoryService,
|
||||
private readonly tencentLbs: TencentLbsProvider,
|
||||
private readonly testWhitelist: TestWhitelistService,
|
||||
private readonly wecomPush: WecomMessagePushService,
|
||||
) {}
|
||||
|
||||
/** 门店进入 PENDING 时通知企微(失败不挡业务) */
|
||||
private notifyStoreAuditPending(opts: {
|
||||
storeName: string;
|
||||
cityName?: string | null;
|
||||
partnerLabel?: string | null;
|
||||
submitType: '新建' | '重提';
|
||||
}) {
|
||||
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
|
||||
const lines = [
|
||||
`**门店审核待处理 · ${opts.submitType}**`,
|
||||
`门店:${opts.storeName}`,
|
||||
opts.cityName ? `城市:${opts.cityName}` : null,
|
||||
opts.partnerLabel ? `合伙人:${opts.partnerLabel}` : null,
|
||||
`时间:${now}`,
|
||||
].filter(Boolean);
|
||||
void this.wecomPush
|
||||
.dispatchMarkdown('store.audit_pending', lines.join('\n'), { applyMention: false })
|
||||
.catch((e) =>
|
||||
this.logger.warn(
|
||||
`store.audit_pending wecom push failed: ${e instanceof Error ? e.message : String(e)}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private async whitelistPhoneSet(): Promise<Set<string>> {
|
||||
const rows = await this.prisma.commonTestWhitelistPhone.findMany({
|
||||
select: { phone: true },
|
||||
@@ -559,6 +587,19 @@ export class StoreService {
|
||||
extraJson: { storeName: store.name, phone: normalizedPhone },
|
||||
});
|
||||
|
||||
if (!this.config.autoApproveStore) {
|
||||
const partner = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: primaryId },
|
||||
select: { name: true, phone: true },
|
||||
});
|
||||
this.notifyStoreAuditPending({
|
||||
storeName: store.name,
|
||||
cityName: store.cityName,
|
||||
partnerLabel: partner?.name || partner?.phone || String(primaryId),
|
||||
submitType: '新建',
|
||||
});
|
||||
}
|
||||
|
||||
return serializeBigInt({
|
||||
store: mapStoreCompat({
|
||||
...store,
|
||||
@@ -716,6 +757,16 @@ export class StoreService {
|
||||
remark: '合伙人修改资料后重新提交审核',
|
||||
},
|
||||
});
|
||||
const partner = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: primaryId },
|
||||
select: { name: true, phone: true },
|
||||
});
|
||||
this.notifyStoreAuditPending({
|
||||
storeName: updated.name,
|
||||
cityName: updated.cityName,
|
||||
partnerLabel: partner?.name || partner?.phone || String(primaryId),
|
||||
submitType: '重提',
|
||||
});
|
||||
}
|
||||
|
||||
return this.partnerGetStore(partnerAccountId, storeId);
|
||||
@@ -739,6 +790,11 @@ export class StoreService {
|
||||
if (store.auditStatus === 'PENDING') {
|
||||
throw new BadRequestException('门店审核中,暂不可修改资料');
|
||||
}
|
||||
if (store.auditStatus === 'APPROVED') {
|
||||
throw new BadRequestException(
|
||||
'已营业门店修改门头照/环境图须通过「提交变更」由总部审核通过后生效',
|
||||
);
|
||||
}
|
||||
|
||||
const coverUrl = body.coverUrl !== undefined ? String(body.coverUrl ?? '').trim() : undefined;
|
||||
const hasEnv = body.envPhotoUrls !== undefined;
|
||||
@@ -825,6 +881,16 @@ export class StoreService {
|
||||
remark: '合伙人重新上传资料后重新提交审核',
|
||||
},
|
||||
});
|
||||
const partner = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: primaryId },
|
||||
select: { name: true, phone: true },
|
||||
});
|
||||
this.notifyStoreAuditPending({
|
||||
storeName: store.name,
|
||||
cityName: store.cityName,
|
||||
partnerLabel: partner?.name || partner?.phone || String(primaryId),
|
||||
submitType: '重提',
|
||||
});
|
||||
}
|
||||
|
||||
return this.partnerGetStore(partnerAccountId, storeId);
|
||||
|
||||
Reference in New Issue
Block a user