@@ -3,6 +3,8 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"predev": "pnpm --dir ../../packages/domain build",
|
||||
"prebuild": "pnpm --dir ../../packages/domain build",
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main",
|
||||
|
||||
@@ -302,6 +302,18 @@ enum StorePackageSubmitterType {
|
||||
SHOP
|
||||
}
|
||||
|
||||
enum StoreInfoChangeStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
}
|
||||
|
||||
enum StoreInfoChangeSubmitterType {
|
||||
PARTNER
|
||||
SHOP
|
||||
HQ_DIRECT_ADMIN
|
||||
}
|
||||
|
||||
enum UserSourceType {
|
||||
ORGANIC
|
||||
PROMO_CODE
|
||||
@@ -1179,6 +1191,7 @@ model User {
|
||||
ownedPromoCodes CommonPromoCode[] @relation("PromoOwnerUser")
|
||||
orders Order[]
|
||||
invoices UserInvoice[]
|
||||
invoiceTitles UserInvoiceTitle[]
|
||||
benefitCoupons BenefitCoupon[]
|
||||
redeemRecords RedeemRecord[]
|
||||
redeemPendingRecords RedeemPendingRecord[]
|
||||
@@ -1299,6 +1312,7 @@ model Store {
|
||||
visibilityPhones StoreVisibilityPhone[]
|
||||
packages StorePackage[]
|
||||
packageChangeRequests StorePackageChangeRequest[]
|
||||
infoChangeRequests StoreInfoChangeRequest[]
|
||||
|
||||
@@index([cityId, status])
|
||||
@@index([partnerAccountId])
|
||||
@@ -1362,6 +1376,52 @@ model StorePackageChangeRequest {
|
||||
@@map("store_package_change_request")
|
||||
}
|
||||
|
||||
/// 用户发票抬头(C 端"我的 → 发票管理")
|
||||
model UserInvoiceTitle {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
userId BigInt @map("user_id") @db.UnsignedBigInt
|
||||
titleType InvoiceTitleType @map("title_type") // PERSONAL | ENTERPRISE
|
||||
titleName String @map("title_name") @db.VarChar(128)
|
||||
taxNo String? @map("tax_no") @db.VarChar(32)
|
||||
email String? @db.VarChar(128)
|
||||
phone String? @db.VarChar(20)
|
||||
addressPhone String? @map("address_phone") @db.VarChar(256)
|
||||
bankAccount String? @map("bank_account") @db.VarChar(256)
|
||||
isDefault Boolean @default(false) @map("is_default")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, isDefault])
|
||||
@@map("user_invoice_title")
|
||||
}
|
||||
|
||||
/// 门店基础信息变更请求(合伙人/门店端提交 → 总部审核)
|
||||
model StoreInfoChangeRequest {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||
status StoreInfoChangeStatus @default(PENDING)
|
||||
/// 变更前 Store 全量快照
|
||||
liveSnapshot Json @map("live_snapshot")
|
||||
/// 提交时希望变更的字段集合(白名单内)
|
||||
proposedSnapshot Json @map("proposed_snapshot")
|
||||
/// 变更的字段名数组
|
||||
changedFields Json @map("changed_fields")
|
||||
submitterType StoreInfoChangeSubmitterType @map("submitter_type")
|
||||
submitterId BigInt @map("submitter_id") @db.UnsignedBigInt
|
||||
rejectReason String? @map("reject_reason") @db.VarChar(512)
|
||||
reviewedAt DateTime? @map("reviewed_at") @db.DateTime(3)
|
||||
reviewerId BigInt? @map("reviewer_id") @db.UnsignedBigInt
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||
|
||||
store Store @relation(fields: [storeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([storeId, status])
|
||||
@@index([status, createdAt])
|
||||
@@map("store_info_change_request")
|
||||
}
|
||||
|
||||
model StoreAccount {
|
||||
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
|
||||
phone String @unique @db.VarChar(20)
|
||||
@@ -1495,6 +1555,7 @@ model Order {
|
||||
@@index([fulfillmentWarehouseId])
|
||||
@@index([proxyPartnerAccountId])
|
||||
@@index([isTest])
|
||||
@@index([status, payExpireAt])
|
||||
@@map("user_order")
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,23 @@ export function mapOrderCompat<T extends OrderLike & {
|
||||
};
|
||||
}
|
||||
|
||||
/** C 端订单列表/详情:附带最新发票状态,不把 invoices 原样透出 */
|
||||
export function mapUserOrderWithInvoice<
|
||||
T extends OrderLike & {
|
||||
orderType?: string | null;
|
||||
proxyPartnerName?: string | null;
|
||||
proxyPartnerPhone?: string | null;
|
||||
proxyPartnerAccountId?: bigint | number | string | null;
|
||||
invoices?: { status: string }[];
|
||||
},
|
||||
>(order: T) {
|
||||
const { invoices, ...rest } = order;
|
||||
return {
|
||||
...mapOrderCompat(rest),
|
||||
invoiceStatus: invoices?.[0]?.status ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 对外联系电话;未单独配置时回退登录手机号 */
|
||||
export function resolveStoreContactPhone(store: {
|
||||
phone?: string | null;
|
||||
|
||||
@@ -55,10 +55,14 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
dedupeTtlSec: 120,
|
||||
});
|
||||
}
|
||||
const resObj =
|
||||
typeof res === 'object' && res !== null ? (res as Record<string, unknown>) : null;
|
||||
const reason = (resObj?.reason as string | undefined) ?? null;
|
||||
response.status(status).json({
|
||||
code: status,
|
||||
message: msgText,
|
||||
data: null,
|
||||
...(reason != null ? { reason } : {}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
@Injectable()
|
||||
export class HqAuthGuard extends JwtAuthGuard implements CanActivate {
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const ok = super.canActivate(context);
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const ok = await super.canActivate(context);
|
||||
if (!ok) return false;
|
||||
|
||||
const req = context.switchToHttp().getRequest();
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
CanActivate,
|
||||
ExecutionContext,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { CLIENT_APP_ACTOR_MAP, ClientApp } from '@dukang/shared-types';
|
||||
import { PrismaService } from '../prisma/prisma.module';
|
||||
|
||||
export interface AuthUser {
|
||||
actorType: string;
|
||||
@@ -17,11 +19,20 @@ export interface AuthUser {
|
||||
storeId?: bigint;
|
||||
}
|
||||
|
||||
/**
|
||||
* v3.5.1 #9:门店/合伙人账号停用或解绑后,运行时接口强制拦截。
|
||||
* 仅对 STORE / PARTNER actor 做 DB 状态校验;USER / HQ 跳过(不影响 C 端/小程序/总部)。
|
||||
* 不通过时抛 ForbiddenException({ reason: 'ACCOUNT_DISABLED' }),由 HttpExceptionFilter 透传 reason,
|
||||
* 前端据此 clearAuth() 并跳登录页。
|
||||
*/
|
||||
@Injectable()
|
||||
export class JwtAuthGuard implements CanActivate {
|
||||
constructor(protected readonly jwtService: JwtService) {}
|
||||
constructor(
|
||||
protected readonly jwtService: JwtService,
|
||||
protected readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const auth = req.headers.authorization as string | undefined;
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
@@ -47,10 +58,81 @@ export class JwtAuthGuard implements CanActivate {
|
||||
? { storeId: BigInt(payload.storeId) }
|
||||
: {}),
|
||||
} satisfies AuthUser;
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedException) throw err;
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
// 账号启停 / 绑定态校验(仅门店与合伙人)
|
||||
await this.assertAccountActive(req.user);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async assertAccountActive(user: AuthUser): Promise<void> {
|
||||
if (user.actorType === 'STORE') {
|
||||
const acc = await this.prisma.storeAccount.findUnique({
|
||||
where: { id: user.actorId },
|
||||
select: { status: true, isTest: true },
|
||||
});
|
||||
if (!acc) {
|
||||
throw new ForbiddenException({
|
||||
reason: 'ACCOUNT_DISABLED',
|
||||
message: '门店账号不存在或已停用,请重新登录',
|
||||
});
|
||||
}
|
||||
// 测试门店账号跳过强校验,避免测试环境自锁
|
||||
if (acc.isTest) return;
|
||||
if (acc.status !== 'ACTIVE') {
|
||||
throw new ForbiddenException({
|
||||
reason: 'ACCOUNT_DISABLED',
|
||||
message: '门店账号已停用,请重新登录',
|
||||
});
|
||||
}
|
||||
if (user.storeId != null) {
|
||||
const store = await this.prisma.store.findUnique({
|
||||
where: { id: user.storeId },
|
||||
select: { status: true, isTest: true },
|
||||
});
|
||||
if (!store) {
|
||||
throw new ForbiddenException({
|
||||
reason: 'ACCOUNT_DISABLED',
|
||||
message: '门店不存在或已关闭,请重新登录',
|
||||
});
|
||||
}
|
||||
if (!store.isTest && store.status !== 'OPEN') {
|
||||
throw new ForbiddenException({
|
||||
reason: 'ACCOUNT_DISABLED',
|
||||
message: '门店已停用或关闭,请重新登录',
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.actorType === 'PARTNER') {
|
||||
const acc = await this.prisma.partnerAccount.findUnique({
|
||||
where: { id: user.actorId },
|
||||
select: { status: true, bindingStatus: true, isTest: true },
|
||||
});
|
||||
if (!acc) {
|
||||
throw new ForbiddenException({
|
||||
reason: 'ACCOUNT_DISABLED',
|
||||
message: '合伙人账号不存在或已停用,请重新登录',
|
||||
});
|
||||
}
|
||||
// 测试合伙人账号跳过强校验,避免测试环境自锁
|
||||
if (acc.isTest) return;
|
||||
if (acc.status !== 'ACTIVE' || acc.bindingStatus !== 'ACTIVE') {
|
||||
throw new ForbiddenException({
|
||||
reason: 'ACCOUNT_DISABLED',
|
||||
message: '合伙人账号已停用或解绑,请重新登录',
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// USER / HQ 不在此校验(按需求仅门店 + 合伙人)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export class PartnerPrimaryGuard implements CanActivate {
|
||||
) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
this.jwtAuthGuard.canActivate(context);
|
||||
await this.jwtAuthGuard.canActivate(context);
|
||||
const req = context.switchToHttp().getRequest();
|
||||
const user = req.user as AuthUser;
|
||||
if (user.actorType !== 'PARTNER') {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { SettlementModule } from '../modules/settlement/settlement.module';
|
||||
import { DeliveryProcessor } from './delivery.processor';
|
||||
import { SettlementScheduler } from './settlement.scheduler';
|
||||
import { MonitorScheduler } from './monitor.scheduler';
|
||||
import { OrderExpiryScheduler } from './order-expiry.scheduler';
|
||||
import { DELIVERY_QUEUE } from './jobs.constants';
|
||||
|
||||
@Module({
|
||||
@@ -15,6 +16,6 @@ import { DELIVERY_QUEUE } from './jobs.constants';
|
||||
TradeModule,
|
||||
SettlementModule,
|
||||
],
|
||||
providers: [DeliveryProcessor, SettlementScheduler, MonitorScheduler],
|
||||
providers: [DeliveryProcessor, SettlementScheduler, MonitorScheduler, OrderExpiryScheduler],
|
||||
})
|
||||
export class JobsModule {}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron } from '@nestjs/schedule';
|
||||
import { TradeService } from '../modules/trade/trade.service';
|
||||
import { AlertService } from '../common/alert/alert.service';
|
||||
|
||||
/**
|
||||
* v3.5.1 #8:订单 30 分钟未支付自动取消。
|
||||
* 每分钟扫描一次:将 status=PENDING_PAY 且 payExpireAt 已过期且非测试订单 翻为 CANCELLED。
|
||||
* 下单时 trade.service 已写入 payExpireAt = now+30min,无需新增字段。
|
||||
*/
|
||||
@Injectable()
|
||||
export class OrderExpiryScheduler {
|
||||
private readonly logger = new Logger(OrderExpiryScheduler.name);
|
||||
|
||||
constructor(
|
||||
private readonly trade: TradeService,
|
||||
private readonly alert: AlertService,
|
||||
) {}
|
||||
|
||||
@Cron('*/1 * * * *', { timeZone: 'Asia/Shanghai' })
|
||||
async handleExpiredPendingOrders() {
|
||||
try {
|
||||
const n = await this.trade.cancelExpiredPendingOrders(200);
|
||||
if (n > 0) this.logger.log(`Auto-cancelled ${n} expired pending orders`);
|
||||
} catch (e) {
|
||||
this.logger.error('Order expiry job failed', e instanceof Error ? e.stack : e);
|
||||
this.alert.notify({
|
||||
level: 'P1',
|
||||
category: 'job',
|
||||
title: '订单自动取消任务失败',
|
||||
detail: e instanceof Error ? e.message : String(e),
|
||||
dedupeKey: `job_order_expiry_fail|${new Date().toISOString().slice(0, 10)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@ export class AdminOrdersController {
|
||||
return this.ordersService.list(query);
|
||||
}
|
||||
|
||||
/** 必须写在 :id 之前,否则 big-screen 会被当成订单 ID */
|
||||
@Get('big-screen')
|
||||
bigScreen(@Query('limit') limit?: string) {
|
||||
return this.ordersService.listBigScreen(limit);
|
||||
}
|
||||
|
||||
@Post('batch-delete')
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('orders_delete')
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { maskContactPhone } from '@dukang/domain';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
@@ -24,6 +25,34 @@ export class AdminOrdersService {
|
||||
private readonly adminRedeemService: AdminRedeemService,
|
||||
) {}
|
||||
|
||||
/** v3.5.1 #1:发布会大屏,返回全部订单(按时间倒序,上限 2000) */
|
||||
async listBigScreen(limit?: string) {
|
||||
const take = Math.min(Math.max(Number(limit) || 2000, 1), 2000);
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: {
|
||||
isTest: false,
|
||||
payAmount: { gte: 100 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
include: { user: { select: { phone: true } } },
|
||||
});
|
||||
return {
|
||||
items: orders.map((o) => {
|
||||
const spec = o.productSpec ? ` ${o.productSpec}` : '';
|
||||
const phone = o.user?.phone || o.receiverPhone;
|
||||
return {
|
||||
id: o.id.toString(),
|
||||
orderNo: o.orderNo,
|
||||
payAmount: Number(o.payAmount),
|
||||
items: `${o.productName}${spec} × ${o.quantity}瓶`,
|
||||
createdAt: o.createdAt.toISOString(),
|
||||
userPhoneMasked: phone ? maskContactPhone(phone) : null,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async list(query: AdminOrdersQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
|
||||
@@ -96,12 +96,22 @@ export class AdminStoresService {
|
||||
|
||||
// 每家店是否有待审核套餐变更,供总部列表「审核套餐 / 对比」快捷入口使用
|
||||
const pendingByStore = new Map<string, string>();
|
||||
// 每家店是否有待审核信息变更,供总部列表「审核信息 / 对比」快捷入口使用
|
||||
const pendingInfoByStore = new Map<string, string>();
|
||||
if (items.length) {
|
||||
const pendingReqs = await this.prisma.storePackageChangeRequest.findMany({
|
||||
where: { storeId: { in: items.map((s) => s.id) }, status: 'PENDING' },
|
||||
select: { id: true, storeId: true },
|
||||
});
|
||||
const storeIds = items.map((s) => s.id);
|
||||
const [pendingReqs, pendingInfoReqs] = await Promise.all([
|
||||
this.prisma.storePackageChangeRequest.findMany({
|
||||
where: { storeId: { in: storeIds }, status: 'PENDING' },
|
||||
select: { id: true, storeId: true },
|
||||
}),
|
||||
this.prisma.storeInfoChangeRequest.findMany({
|
||||
where: { storeId: { in: storeIds }, status: 'PENDING' },
|
||||
select: { id: true, storeId: true },
|
||||
}),
|
||||
]);
|
||||
for (const r of pendingReqs) pendingByStore.set(r.storeId.toString(), r.id.toString());
|
||||
for (const r of pendingInfoReqs) pendingInfoByStore.set(r.storeId.toString(), r.id.toString());
|
||||
}
|
||||
|
||||
return serializeBigInt({
|
||||
@@ -113,6 +123,7 @@ export class AdminStoresService {
|
||||
visibilityPhones: visibilityPhones.map((p) => p.phone),
|
||||
// 透传:mapStoreCompat 为 { ...store } 展开,新字段不会被丢弃
|
||||
pendingPackageAuditId: pendingByStore.get(s.id.toString()) ?? null,
|
||||
pendingInfoChangeId: pendingInfoByStore.get(s.id.toString()) ?? null,
|
||||
partner: s.partnerAccount,
|
||||
account: s.bindings[0]?.storeAccount ?? null,
|
||||
bindings: undefined,
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { StoreInfoChangeService } from './store-info-change.service';
|
||||
|
||||
@Controller('partner/stores')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class PartnerStoreInfoChangeController {
|
||||
constructor(private readonly svc: StoreInfoChangeService) {}
|
||||
|
||||
@Post(':storeId/info-change-request')
|
||||
submit(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('storeId') storeId: string,
|
||||
@Body() body: Record<string, unknown>,
|
||||
) {
|
||||
return this.svc.submitChange({
|
||||
submitterType: 'PARTNER',
|
||||
submitterId: user.actorId,
|
||||
storeId: BigInt(storeId),
|
||||
fields: body,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':storeId/info-change-requests')
|
||||
list(@CurrentUser() user: AuthUser, @Param('storeId') storeId: string) {
|
||||
return this.svc.listPartnerRequests(BigInt(storeId), user.actorId);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('shop/store')
|
||||
@UseGuards(JwtAuthGuard, ShopStoreGuard)
|
||||
export class ShopStoreInfoChangeController {
|
||||
constructor(private readonly svc: StoreInfoChangeService) {}
|
||||
|
||||
@Post('info-change-request')
|
||||
submit(@CurrentUser() user: AuthUser, @Body() body: Record<string, unknown>) {
|
||||
return this.svc.submitChange({
|
||||
submitterType: 'SHOP',
|
||||
submitterId: user.actorId,
|
||||
storeId: user.storeId!,
|
||||
fields: body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin/store-info-change-requests')
|
||||
@UseGuards(HqAuthGuard)
|
||||
export class AdminStoreInfoChangeController {
|
||||
constructor(private readonly svc: StoreInfoChangeService) {}
|
||||
|
||||
@Get('summary')
|
||||
summary() {
|
||||
return this.svc.adminSummary();
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.svc.adminList({
|
||||
status: (status as 'PENDING' | 'APPROVED' | 'REJECTED') || undefined,
|
||||
page: page ? Number(page) : undefined,
|
||||
pageSize: pageSize ? Number(pageSize) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@Param('id') id: string) {
|
||||
return this.svc.adminDetail(BigInt(id));
|
||||
}
|
||||
|
||||
@Put(':id/audit')
|
||||
audit(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: { action: 'APPROVE' | 'REJECT'; rejectReason?: string },
|
||||
) {
|
||||
return this.svc.audit({
|
||||
id: BigInt(id),
|
||||
action: body.action,
|
||||
rejectReason: body.rejectReason,
|
||||
reviewerId: user.actorId,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
isStoreContactPhone,
|
||||
STORE_CONTACT_PHONE_HINT,
|
||||
} from '@dukang/domain';
|
||||
import {
|
||||
STORE_INFO_CHANGEABLE_FIELDS,
|
||||
type StoreInfoChangeFieldDiff,
|
||||
type StoreInfoChangeRequestDto,
|
||||
type StoreInfoChangeStatus,
|
||||
type StoreInfoChangeSubmitterType,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
import { StoreService } from './store.service';
|
||||
import { PartnerCityService } from '../city-scope/partner-city.service';
|
||||
|
||||
type ChangeableField = (typeof STORE_INFO_CHANGEABLE_FIELDS)[number];
|
||||
|
||||
function normalizeOptionalTextField(value: unknown): string | null {
|
||||
if (value == null) return null;
|
||||
const s = String(value).trim();
|
||||
if (!s || /^null$/i.test(s)) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
function normalizeBusinessHour(value: unknown): string | null {
|
||||
if (value == null || String(value).trim() === '') return null;
|
||||
const s = String(value).trim();
|
||||
if (!/^\d{1,2}:\d{2}$/.test(s)) {
|
||||
throw new BadRequestException('营业时间格式应为 HH:MM,如 09:00');
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function coerceNumberOrNull(value: unknown): number | null {
|
||||
if (value == null || String(value).trim() === '') return null;
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) throw new BadRequestException('数值字段格式不正确');
|
||||
return n;
|
||||
}
|
||||
|
||||
/** 将白名单字段从提交 body 规整为可落库的 proposedSnapshot */
|
||||
function buildProposedSnapshot(fields: Record<string, unknown>): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
|
||||
if (!(field in fields)) continue;
|
||||
const raw = fields[field];
|
||||
switch (field) {
|
||||
case 'latitude':
|
||||
case 'longitude':
|
||||
case 'avgPrice':
|
||||
out[field] = coerceNumberOrNull(raw);
|
||||
break;
|
||||
case 'openTime':
|
||||
case 'closeTime':
|
||||
case 'openTime2':
|
||||
case 'closeTime2':
|
||||
out[field] = normalizeBusinessHour(raw);
|
||||
break;
|
||||
case 'intro':
|
||||
case 'benefitUsageRule':
|
||||
out[field] = normalizeOptionalTextField(raw);
|
||||
break;
|
||||
default:
|
||||
out[field] = raw == null ? null : String(raw);
|
||||
}
|
||||
}
|
||||
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;
|
||||
return String(a) === String(b);
|
||||
}
|
||||
|
||||
function computeChangedFields(
|
||||
live: Record<string, unknown>,
|
||||
proposed: Record<string, unknown>,
|
||||
): ChangeableField[] {
|
||||
const changed: ChangeableField[] = [];
|
||||
for (const field of STORE_INFO_CHANGEABLE_FIELDS) {
|
||||
if (!(field in proposed)) continue;
|
||||
if (!looseEqual(live[field], proposed[field])) changed.push(field as ChangeableField);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class StoreInfoChangeService {
|
||||
private readonly logger = new Logger(StoreInfoChangeService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly storeService: StoreService,
|
||||
private readonly partnerCityService: PartnerCityService,
|
||||
) {}
|
||||
|
||||
/** 合伙人 / 门店端 提交基础信息变更 */
|
||||
async submitChange(input: {
|
||||
submitterType: StoreInfoChangeSubmitterType;
|
||||
submitterId: bigint;
|
||||
storeId: bigint;
|
||||
fields: Record<string, unknown>;
|
||||
}): Promise<StoreInfoChangeRequestDto> {
|
||||
// 1) 校验归属
|
||||
let store: Record<string, unknown>;
|
||||
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>;
|
||||
} 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>;
|
||||
}
|
||||
|
||||
if (store.status === 'CLOSED') {
|
||||
throw new BadRequestException('门店已关闭,不可提交变更');
|
||||
}
|
||||
|
||||
// 2) 规整 proposed + diff
|
||||
const proposed = buildProposedSnapshot(input.fields);
|
||||
const live = pickLiveFields(store);
|
||||
const changedFields = computeChangedFields(live, proposed);
|
||||
if (changedFields.length === 0) {
|
||||
throw new BadRequestException('没有检测到需要变更的字段');
|
||||
}
|
||||
|
||||
// 3) 基础校验
|
||||
if (proposed.name != null && !String(proposed.name).trim()) {
|
||||
throw new BadRequestException('请填写门店名称');
|
||||
}
|
||||
if (proposed.contactPhone != null && !isStoreContactPhone(String(proposed.contactPhone))) {
|
||||
throw new BadRequestException(STORE_CONTACT_PHONE_HINT);
|
||||
}
|
||||
if (proposed.intro != null && (String(proposed.intro).length < 2 || String(proposed.intro).length > 500)) {
|
||||
throw new BadRequestException('门店简介须为 2~500 字');
|
||||
}
|
||||
if (
|
||||
proposed.benefitUsageRule != null &&
|
||||
String(proposed.benefitUsageRule).length > 1000
|
||||
) {
|
||||
throw new BadRequestException('好客权益券使用规则最多 1000 字');
|
||||
}
|
||||
if (
|
||||
(proposed.latitude != null || proposed.longitude != null) &&
|
||||
(proposed.latitude == null || proposed.longitude == null)
|
||||
) {
|
||||
throw new BadRequestException('经纬度须同时提供');
|
||||
}
|
||||
|
||||
// 4) 同门店已有 PENDING 则替换(最新优先)
|
||||
await this.prisma.storeInfoChangeRequest.deleteMany({
|
||||
where: { storeId: input.storeId, status: 'PENDING' },
|
||||
});
|
||||
|
||||
const created = await this.prisma.storeInfoChangeRequest.create({
|
||||
data: {
|
||||
storeId: input.storeId,
|
||||
status: 'PENDING',
|
||||
liveSnapshot: live as object,
|
||||
proposedSnapshot: proposed as object,
|
||||
changedFields: changedFields as unknown as never,
|
||||
submitterType: input.submitterType,
|
||||
submitterId: input.submitterId,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(
|
||||
`Store info change submitted storeId=${input.storeId} fields=${changedFields.join(',')}`,
|
||||
);
|
||||
return serializeBigInt(this.toDto(created as unknown as Record<string, unknown>));
|
||||
}
|
||||
|
||||
/** 合伙人端查看本门店历史变更 */
|
||||
async listPartnerRequests(
|
||||
storeId: bigint,
|
||||
partnerAccountId: bigint,
|
||||
): Promise<StoreInfoChangeRequestDto[]> {
|
||||
const primary = await this.partnerCityService.resolvePrimaryAccount(partnerAccountId);
|
||||
const owned = await this.prisma.store.findFirst({
|
||||
where: { id: storeId, partnerAccountId: primary.id },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owned) throw new NotFoundException('门店不存在或无权操作');
|
||||
const rows = await this.prisma.storeInfoChangeRequest.findMany({
|
||||
where: { storeId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
return rows.map((r) => serializeBigInt(this.toDto(r as unknown as Record<string, unknown>)));
|
||||
}
|
||||
|
||||
/** 总部列表 */
|
||||
async adminList(opts: {
|
||||
status?: StoreInfoChangeStatus;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}): Promise<{ items: StoreInfoChangeRequestDto[]; total: number; page: number; pageSize: number }> {
|
||||
const page = Math.max(1, opts.page || 1);
|
||||
const pageSize = Math.min(Math.max(opts.pageSize || 20, 1), 100);
|
||||
const where = opts.status ? { status: opts.status } : {};
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.storeInfoChangeRequest.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
include: { store: { select: { name: true } } },
|
||||
}),
|
||||
this.prisma.storeInfoChangeRequest.count({ where }),
|
||||
]);
|
||||
const items = rows.map((r) =>
|
||||
serializeBigInt(
|
||||
this.toDto(r as unknown as Record<string, unknown>, (r as { store?: { name?: string } }).store?.name),
|
||||
),
|
||||
);
|
||||
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' } }),
|
||||
this.prisma.storePackageChangeRequest.count({ where: { status: 'PENDING' } }),
|
||||
]);
|
||||
return { pendingCount: infoPending, packagePendingCount: packagePending };
|
||||
}
|
||||
|
||||
/** 总部详情(含字段级 diff) */
|
||||
async adminDetail(id: bigint): Promise<StoreInfoChangeRequestDto> {
|
||||
const row = await this.prisma.storeInfoChangeRequest.findUnique({
|
||||
where: { id },
|
||||
include: { store: { select: { name: true } } },
|
||||
});
|
||||
if (!row) throw new NotFoundException('变更请求不存在');
|
||||
const dto = this.toDto(
|
||||
row as unknown as Record<string, unknown>,
|
||||
(row as { store?: { name?: string } }).store?.name,
|
||||
);
|
||||
const live = (row as { liveSnapshot?: Record<string, unknown> }).liveSnapshot || {};
|
||||
const proposed = (row as { proposedSnapshot?: Record<string, unknown> }).proposedSnapshot || {};
|
||||
const changed = ((row as { changedFields?: ChangeableField[] }).changedFields as ChangeableField[]) || [];
|
||||
const diffs: StoreInfoChangeFieldDiff[] = changed.map((field) => ({
|
||||
field,
|
||||
live: live[field] ?? null,
|
||||
proposed: proposed[field] ?? null,
|
||||
}));
|
||||
return { ...dto, diffs };
|
||||
}
|
||||
|
||||
/** 总部审核通过/驳回 */
|
||||
async audit(input: {
|
||||
id: bigint;
|
||||
action: 'APPROVE' | 'REJECT';
|
||||
rejectReason?: string;
|
||||
reviewerId: bigint;
|
||||
}): Promise<StoreInfoChangeRequestDto> {
|
||||
const row = await this.prisma.storeInfoChangeRequest.findUnique({ where: { id: input.id } });
|
||||
if (!row) throw new NotFoundException('变更请求不存在');
|
||||
if (row.status !== 'PENDING') {
|
||||
throw new BadRequestException('该变更请求已处理');
|
||||
}
|
||||
|
||||
if (input.action === 'REJECT') {
|
||||
const updated = await this.prisma.storeInfoChangeRequest.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
status: 'REJECTED',
|
||||
rejectReason: normalizeOptionalTextField(input.rejectReason) || '总部驳回',
|
||||
reviewerId: input.reviewerId,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
});
|
||||
this.logger.log(`Store info change rejected id=${input.id}`);
|
||||
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) {
|
||||
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 });
|
||||
|
||||
const updated = await this.prisma.storeInfoChangeRequest.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
status: 'APPROVED',
|
||||
reviewerId: input.reviewerId,
|
||||
reviewedAt: new Date(),
|
||||
},
|
||||
});
|
||||
this.logger.log(`Store info change approved id=${input.id} storeId=${row.storeId}`);
|
||||
return serializeBigInt(this.toDto(updated as unknown as Record<string, unknown>));
|
||||
}
|
||||
|
||||
private toDto(
|
||||
row: Record<string, unknown>,
|
||||
storeName?: string,
|
||||
): StoreInfoChangeRequestDto {
|
||||
return {
|
||||
id: String(row.id),
|
||||
storeId: String(row.storeId),
|
||||
storeName,
|
||||
status: row.status as StoreInfoChangeStatus,
|
||||
changedFields: ((row.changedFields as ChangeableField[]) || []).map(String) as never,
|
||||
submitterType: row.submitterType as StoreInfoChangeSubmitterType,
|
||||
submitterId: String(row.submitterId),
|
||||
rejectReason: (row.rejectReason as string | null) ?? null,
|
||||
reviewedAt: row.reviewedAt ? (row.reviewedAt as Date).toISOString() : null,
|
||||
createdAt: (row.createdAt as Date).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
ShopStorePackageController,
|
||||
} from './store-package.controller';
|
||||
import { StorePackageService } from './store-package.service';
|
||||
import {
|
||||
AdminStoreInfoChangeController,
|
||||
PartnerStoreInfoChangeController,
|
||||
ShopStoreInfoChangeController,
|
||||
} from './store-info-change.controller';
|
||||
import { StoreInfoChangeService } from './store-info-change.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -47,8 +53,11 @@ import { StorePackageService } from './store-package.service';
|
||||
ShopDashboardController,
|
||||
AdminStorePackageController,
|
||||
AdminStorePackageAuditController,
|
||||
PartnerStoreInfoChangeController,
|
||||
ShopStoreInfoChangeController,
|
||||
AdminStoreInfoChangeController,
|
||||
],
|
||||
providers: [StoreService, StoreCategoryService, StorePackageService],
|
||||
providers: [StoreService, StoreCategoryService, StorePackageService, StoreInfoChangeService],
|
||||
exports: [StoreService, StoreCategoryService, StorePackageService],
|
||||
})
|
||||
export class StoreModule {}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateIf,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateAfterSaleTicketDto {
|
||||
@@ -37,18 +38,26 @@ export class CreatePackageDisputeDto {
|
||||
}
|
||||
|
||||
export class CreateInvoiceDto {
|
||||
/** v3.5.1:从已保存的发票抬头中选择(若存在则覆盖下方抬头字段) */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleId?: string;
|
||||
|
||||
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
|
||||
@IsString()
|
||||
@IsIn(['PERSONAL', 'ENTERPRISE'])
|
||||
titleType: string;
|
||||
titleType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['NORMAL', 'SPECIAL'])
|
||||
invoiceKind: string;
|
||||
invoiceKind?: string;
|
||||
|
||||
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(128)
|
||||
titleName: string;
|
||||
titleName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -65,13 +74,15 @@ export class CreateInvoiceDto {
|
||||
@MaxLength(256)
|
||||
bankAccount?: string;
|
||||
|
||||
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
|
||||
@IsEmail()
|
||||
email: string;
|
||||
email?: string;
|
||||
|
||||
@ValidateIf((o: CreateInvoiceDto) => !o.titleId)
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(20)
|
||||
phone: string;
|
||||
phone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard, AuthUser } from '../../common/guards/jwt-auth.guard';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import {
|
||||
type UpsertInvoiceTitleRequest,
|
||||
type UserInvoiceTitleDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { InvoiceTitleService } from './invoice-title.service';
|
||||
import { TradeService } from './trade.service';
|
||||
|
||||
@Controller('trade')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class TradeInvoiceTitleController {
|
||||
constructor(
|
||||
private readonly titleSvc: InvoiceTitleService,
|
||||
private readonly tradeService: TradeService,
|
||||
) {}
|
||||
|
||||
@Get('invoice-titles')
|
||||
list(@CurrentUser() user: AuthUser): Promise<UserInvoiceTitleDto[]> {
|
||||
return this.titleSvc.listTitles(user.actorId);
|
||||
}
|
||||
|
||||
@Post('invoice-titles')
|
||||
create(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Body() body: UpsertInvoiceTitleRequest,
|
||||
): Promise<UserInvoiceTitleDto> {
|
||||
return this.titleSvc.createTitle(user.actorId, body);
|
||||
}
|
||||
|
||||
@Put('invoice-titles/:id')
|
||||
update(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() body: UpsertInvoiceTitleRequest,
|
||||
): Promise<UserInvoiceTitleDto> {
|
||||
return this.titleSvc.updateTitle(user.actorId, BigInt(id), body);
|
||||
}
|
||||
|
||||
@Delete('invoice-titles/:id')
|
||||
remove(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.titleSvc.deleteTitle(user.actorId, BigInt(id));
|
||||
}
|
||||
|
||||
/** 订单可用的发票抬头(用户全部抬头) */
|
||||
@Get('orders/:orderId/invoice-titles')
|
||||
orderTitles(@CurrentUser() user: AuthUser): Promise<UserInvoiceTitleDto[]> {
|
||||
return this.titleSvc.listTitles(user.actorId);
|
||||
}
|
||||
|
||||
/** 订单是否已申请发票 */
|
||||
@Get('orders/:orderId/invoice-status')
|
||||
orderInvoiceStatus(@CurrentUser() user: AuthUser, @Param('orderId') orderId: string) {
|
||||
return this.tradeService.getOrderInvoiceStatus(user.actorId, BigInt(orderId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
type UpsertInvoiceTitleRequest,
|
||||
type UserInvoiceTitleDto,
|
||||
} from '@dukang/shared-types';
|
||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||
|
||||
@Injectable()
|
||||
export class InvoiceTitleService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async listTitles(userId: bigint): Promise<UserInvoiceTitleDto[]> {
|
||||
const rows = await this.prisma.userInvoiceTitle.findMany({
|
||||
where: { userId },
|
||||
orderBy: [{ isDefault: 'desc' }, { createdAt: 'desc' }],
|
||||
});
|
||||
return rows.map((r) => serializeBigInt(this.toDto(r as unknown as Record<string, unknown>)));
|
||||
}
|
||||
|
||||
async createTitle(
|
||||
userId: bigint,
|
||||
body: UpsertInvoiceTitleRequest,
|
||||
): Promise<UserInvoiceTitleDto> {
|
||||
this.validate(body);
|
||||
if (body.isDefault) {
|
||||
await this.prisma.userInvoiceTitle.updateMany({
|
||||
where: { userId, isDefault: true },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
const created = await this.prisma.userInvoiceTitle.create({
|
||||
data: {
|
||||
userId,
|
||||
titleType: body.titleType,
|
||||
titleName: body.titleName.trim(),
|
||||
taxNo: body.taxNo?.trim() || null,
|
||||
email: body.email?.trim() || null,
|
||||
phone: body.phone?.trim() || null,
|
||||
addressPhone: body.addressPhone?.trim() || null,
|
||||
bankAccount: body.bankAccount?.trim() || null,
|
||||
isDefault: !!body.isDefault,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(this.toDto(created as unknown as Record<string, unknown>));
|
||||
}
|
||||
|
||||
async updateTitle(
|
||||
userId: bigint,
|
||||
id: bigint,
|
||||
body: UpsertInvoiceTitleRequest,
|
||||
): Promise<UserInvoiceTitleDto> {
|
||||
const existing = await this.prisma.userInvoiceTitle.findFirst({ where: { id, userId } });
|
||||
if (!existing) throw new NotFoundException('发票抬头不存在');
|
||||
this.validate(body, true);
|
||||
|
||||
const isDefault = body.isDefault ?? existing.isDefault;
|
||||
if (isDefault && !existing.isDefault) {
|
||||
await this.prisma.userInvoiceTitle.updateMany({
|
||||
where: { userId, isDefault: true },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
}
|
||||
|
||||
const updated = await this.prisma.userInvoiceTitle.update({
|
||||
where: { id },
|
||||
data: {
|
||||
titleType: body.titleType,
|
||||
titleName: body.titleName.trim(),
|
||||
taxNo: body.taxNo?.trim() || null,
|
||||
email: body.email?.trim() || null,
|
||||
phone: body.phone?.trim() || null,
|
||||
addressPhone: body.addressPhone?.trim() || null,
|
||||
bankAccount: body.bankAccount?.trim() || null,
|
||||
isDefault,
|
||||
},
|
||||
});
|
||||
return serializeBigInt(this.toDto(updated as unknown as Record<string, unknown>));
|
||||
}
|
||||
|
||||
async deleteTitle(userId: bigint, id: bigint): Promise<{ id: string }> {
|
||||
const existing = await this.prisma.userInvoiceTitle.findFirst({ where: { id, userId } });
|
||||
if (!existing) throw new NotFoundException('发票抬头不存在');
|
||||
const referenced = await this.prisma.userInvoice.findFirst({ where: { userId, titleName: existing.titleName } });
|
||||
if (referenced) {
|
||||
throw new ConflictException('该抬头已有发票申请记录,无法删除');
|
||||
}
|
||||
await this.prisma.userInvoiceTitle.delete({ where: { id } });
|
||||
return { id: id.toString() };
|
||||
}
|
||||
|
||||
private validate(body: UpsertInvoiceTitleRequest, isUpdate = false) {
|
||||
if (!body.titleName?.trim()) {
|
||||
throw new BadRequestException('请填写抬头名称');
|
||||
}
|
||||
if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) {
|
||||
throw new BadRequestException('企业抬头须填写税号');
|
||||
}
|
||||
if (isUpdate && body.titleType === undefined) {
|
||||
throw new BadRequestException('titleType 必填');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: Record<string, unknown>): UserInvoiceTitleDto {
|
||||
return {
|
||||
id: String(row.id),
|
||||
titleType: row.titleType as UserInvoiceTitleDto['titleType'],
|
||||
titleName: String(row.titleName),
|
||||
taxNo: (row.taxNo as string | null) ?? null,
|
||||
email: (row.email as string | null) ?? null,
|
||||
phone: (row.phone as string | null) ?? null,
|
||||
addressPhone: (row.addressPhone as string | null) ?? null,
|
||||
bankAccount: (row.bankAccount as string | null) ?? null,
|
||||
isDefault: !!row.isDefault,
|
||||
createdAt: (row.createdAt as Date).toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
TradePackageDisputeController,
|
||||
} from './trade.controller';
|
||||
import { TradeService } from './trade.service';
|
||||
import { TradeInvoiceTitleController } from './invoice-title.controller';
|
||||
import { InvoiceTitleService } from './invoice-title.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -39,8 +41,9 @@ import { TradeService } from './trade.service';
|
||||
PartnerOrderController,
|
||||
PartnerProxyOrderController,
|
||||
PartnerReshipmentController,
|
||||
TradeInvoiceTitleController,
|
||||
],
|
||||
providers: [TradeService],
|
||||
providers: [TradeService, InvoiceTitleService],
|
||||
exports: [TradeService],
|
||||
})
|
||||
export class TradeModule {}
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
@@ -29,7 +30,7 @@ import { IpGeoService } from '../../common/geo/ip-geo.service';
|
||||
import { buildOrderClientLocationSnapshot } from '../../common/geo/client-location.util';
|
||||
import { extractClientIp } from '../../common/geo/client-ip.util';
|
||||
import { buildOrderStatusEvent, orderStatusLogWhere } from '../../common/event/event.helpers';
|
||||
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
||||
import { mapOrderCompat, mapStatusLogCompat, mapUserOrderWithInvoice } from '../../common/compat/v31-compat';
|
||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||
import { WechatOrderShippingService } from '../../integrations/wechat/wechat-order-shipping.service';
|
||||
import { AlertService } from '../../common/alert/alert.service';
|
||||
@@ -57,6 +58,9 @@ export class TradeService {
|
||||
private readonly alert: AlertService,
|
||||
) {}
|
||||
|
||||
private readonly logger = new Logger(TradeService.name);
|
||||
|
||||
|
||||
async preview(
|
||||
userId: bigint,
|
||||
body: { productId: string; quantity: number; addressId?: string; onSitePickup?: boolean },
|
||||
@@ -753,14 +757,23 @@ export class TradeService {
|
||||
const [list, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
where,
|
||||
include: { benefitCoupon: true, imageResource: true },
|
||||
include: {
|
||||
benefitCoupon: true,
|
||||
imageResource: true,
|
||||
invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
]);
|
||||
return { list: serializeBigInt(list.map(mapOrderCompat)), total, page, pageSize };
|
||||
return {
|
||||
list: serializeBigInt(list.map(mapUserOrderWithInvoice)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async getOrder(userId: bigint, orderId: bigint) {
|
||||
@@ -772,6 +785,7 @@ export class TradeService {
|
||||
imageResource: true,
|
||||
product: true,
|
||||
fulfillmentWarehouse: { select: { id: true, name: true } },
|
||||
invoices: { select: { status: true }, orderBy: { createdAt: 'desc' }, take: 1 },
|
||||
},
|
||||
});
|
||||
if (!order) throw new NotFoundException('订单不存在');
|
||||
@@ -779,7 +793,10 @@ export class TradeService {
|
||||
where: orderStatusLogWhere(orderId),
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
const mapped = mapOrderCompat({ ...order, statusLogs: mapStatusLogCompat(statusLogs) });
|
||||
const mapped = mapUserOrderWithInvoice({
|
||||
...order,
|
||||
statusLogs: mapStatusLogCompat(statusLogs),
|
||||
});
|
||||
return serializeBigInt({
|
||||
...mapped,
|
||||
wechatConfirm: this.wechatOrderShipping.buildConfirmPayload(order),
|
||||
@@ -987,14 +1004,15 @@ export class TradeService {
|
||||
userId: bigint,
|
||||
orderId: bigint,
|
||||
body: {
|
||||
titleType: string;
|
||||
invoiceKind: string;
|
||||
titleName: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
bankAccount?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
titleId?: string;
|
||||
titleType?: string;
|
||||
invoiceKind?: string;
|
||||
titleName?: string;
|
||||
taxNo?: string | null;
|
||||
addressPhone?: string | null;
|
||||
bankAccount?: string | null;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
remark?: string;
|
||||
},
|
||||
) {
|
||||
@@ -1009,14 +1027,49 @@ export class TradeService {
|
||||
});
|
||||
if (existing) throw new BadRequestException('该订单已有进行中或已开具的发票申请');
|
||||
|
||||
if (body.titleType === 'ENTERPRISE' && !body.taxNo?.trim()) {
|
||||
let resolved = {
|
||||
titleType: body.titleType || '',
|
||||
titleName: body.titleName || '',
|
||||
taxNo: body.taxNo ?? null,
|
||||
addressPhone: body.addressPhone ?? null,
|
||||
bankAccount: body.bankAccount ?? null,
|
||||
email: body.email || '',
|
||||
phone: body.phone || '',
|
||||
};
|
||||
if (body.titleId) {
|
||||
const title = await this.prisma.userInvoiceTitle.findFirst({
|
||||
where: { id: BigInt(body.titleId), userId },
|
||||
});
|
||||
if (!title) throw new BadRequestException('发票抬头不存在');
|
||||
resolved = {
|
||||
titleType: title.titleType,
|
||||
titleName: title.titleName,
|
||||
taxNo: title.taxNo,
|
||||
addressPhone: title.addressPhone,
|
||||
bankAccount: title.bankAccount,
|
||||
email: title.email || body.email || '',
|
||||
phone: title.phone || body.phone || '',
|
||||
};
|
||||
}
|
||||
|
||||
if (!resolved.titleName.trim()) {
|
||||
throw new BadRequestException('请填写抬头名称');
|
||||
}
|
||||
if (!resolved.email.trim()) {
|
||||
throw new BadRequestException('请填写接收邮箱');
|
||||
}
|
||||
if (!resolved.phone.trim()) {
|
||||
throw new BadRequestException('请填写联系电话');
|
||||
}
|
||||
if (resolved.titleType === 'ENTERPRISE' && !resolved.taxNo?.trim()) {
|
||||
throw new BadRequestException('企业抬头须填写税号');
|
||||
}
|
||||
if (body.invoiceKind === 'SPECIAL') {
|
||||
if (body.titleType !== 'ENTERPRISE') {
|
||||
const invoiceKind = body.invoiceKind || 'NORMAL';
|
||||
if (invoiceKind === 'SPECIAL') {
|
||||
if (resolved.titleType !== 'ENTERPRISE') {
|
||||
throw new BadRequestException('专用发票仅支持企业抬头');
|
||||
}
|
||||
if (!body.taxNo?.trim() || !body.addressPhone?.trim() || !body.bankAccount?.trim()) {
|
||||
if (!resolved.taxNo?.trim() || !resolved.addressPhone?.trim() || !resolved.bankAccount?.trim()) {
|
||||
throw new BadRequestException('专用发票须填写税号、地址电话与开户行账号');
|
||||
}
|
||||
}
|
||||
@@ -1026,20 +1079,29 @@ export class TradeService {
|
||||
invoiceNo: this.generateInvoiceNo(),
|
||||
orderId,
|
||||
userId,
|
||||
titleType: body.titleType as never,
|
||||
invoiceKind: body.invoiceKind as never,
|
||||
titleName: body.titleName.trim(),
|
||||
taxNo: body.taxNo?.trim() || null,
|
||||
addressPhone: body.addressPhone?.trim() || null,
|
||||
bankAccount: body.bankAccount?.trim() || null,
|
||||
email: body.email.trim(),
|
||||
phone: body.phone.trim(),
|
||||
titleType: resolved.titleType as never,
|
||||
invoiceKind: invoiceKind as never,
|
||||
titleName: resolved.titleName.trim(),
|
||||
taxNo: resolved.taxNo?.trim() || null,
|
||||
addressPhone: resolved.addressPhone?.trim() || null,
|
||||
bankAccount: resolved.bankAccount?.trim() || null,
|
||||
email: resolved.email.trim(),
|
||||
phone: resolved.phone.trim(),
|
||||
remark: body.remark?.trim() || null,
|
||||
},
|
||||
});
|
||||
return serializeBigInt({ ...invoice, orderNo: order.orderNo });
|
||||
}
|
||||
|
||||
/** v3.5.1 #3:查询某订单是否已申请发票 */
|
||||
async getOrderInvoiceStatus(userId: bigint, orderId: bigint) {
|
||||
const inv = await this.prisma.userInvoice.findFirst({
|
||||
where: { orderId, userId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
return { exists: !!inv, status: inv?.status ?? null };
|
||||
}
|
||||
|
||||
async listInvoices(userId: bigint, page = 1, pageSize = 20) {
|
||||
const where = { userId };
|
||||
const [items, total] = await Promise.all([
|
||||
@@ -1096,14 +1158,15 @@ export class TradeService {
|
||||
async adminCreateInvoice(
|
||||
body: {
|
||||
orderNo: string;
|
||||
titleType: string;
|
||||
invoiceKind: string;
|
||||
titleName: string;
|
||||
titleId?: string;
|
||||
titleType?: string;
|
||||
invoiceKind?: string;
|
||||
titleName?: string;
|
||||
taxNo?: string;
|
||||
addressPhone?: string;
|
||||
bankAccount?: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
remark?: string;
|
||||
},
|
||||
) {
|
||||
@@ -1819,6 +1882,59 @@ export class TradeService {
|
||||
return serializeBigInt({ id: orderId.toString(), status: 'CANCELLED' });
|
||||
}
|
||||
|
||||
/**
|
||||
* v3.5.1 #8:订单 30 分钟未支付自动取消。
|
||||
* 下单时已写入 payExpireAt = now+30min;此处将已过期且仍为待支付的订单翻为 CANCELLED。
|
||||
* 建单/取消均无库存占用或权益发放,故仅翻状态 + 写状态日志,不回滚权益券。
|
||||
*/
|
||||
async cancelExpiredPendingOrders(limit = 200): Promise<number> {
|
||||
const now = new Date();
|
||||
const expired = await this.prisma.order.findMany({
|
||||
where: {
|
||||
status: 'PENDING_PAY',
|
||||
payStatus: 'UNPAID',
|
||||
payExpireAt: { lt: now },
|
||||
isTest: false,
|
||||
},
|
||||
take: limit,
|
||||
select: { id: true },
|
||||
orderBy: { payExpireAt: 'asc' },
|
||||
});
|
||||
if (expired.length === 0) return 0;
|
||||
|
||||
let cancelled = 0;
|
||||
for (const o of expired) {
|
||||
try {
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.order.updateMany({
|
||||
where: { id: o.id, status: 'PENDING_PAY' },
|
||||
data: { status: 'CANCELLED', cancelledAt: now },
|
||||
});
|
||||
if (updated.count === 0) return; // 已被并发处理
|
||||
await tx.commonEvent.create({
|
||||
data: buildOrderStatusEvent({
|
||||
orderId: o.id,
|
||||
fromStatus: 'PENDING_PAY',
|
||||
toStatus: 'CANCELLED',
|
||||
operator: 'SYSTEM_AUTO_EXPIRE',
|
||||
remark: '30 分钟未支付自动取消',
|
||||
}),
|
||||
});
|
||||
});
|
||||
cancelled++;
|
||||
} catch (e) {
|
||||
this.logger.error(
|
||||
`cancelExpiredPendingOrders failed for order ${o.id}`,
|
||||
e instanceof Error ? e.stack : e,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (cancelled > 0) {
|
||||
this.logger.log(`Auto-cancelled ${cancelled} expired pending orders`);
|
||||
}
|
||||
return cancelled;
|
||||
}
|
||||
|
||||
/** HQ 代下单:商品/推广码选项(运营侧可看白名单测试酒) */
|
||||
async getHqProxyOrderOptions() {
|
||||
const [products, promoCodes] = await Promise.all([
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "node16",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
|
||||
Reference in New Issue
Block a user