diff --git a/apps/admin-web/src/pages/BenefitCouponsPage.tsx b/apps/admin-web/src/pages/BenefitCouponsPage.tsx index 87e0f88..6f98e87 100644 --- a/apps/admin-web/src/pages/BenefitCouponsPage.tsx +++ b/apps/admin-web/src/pages/BenefitCouponsPage.tsx @@ -1,20 +1,44 @@ import { useState } from 'react'; -import { Button, Descriptions, Drawer, Form, Input, Popconfirm, Select, Table, Tag, Typography, message } from 'antd'; +import { + Button, + Descriptions, + Drawer, + Form, + Input, + InputNumber, + Modal, + Popconfirm, + Select, + Table, + Tag, + Typography, + message, +} from 'antd'; import type { ColumnsType } from 'antd/es/table'; +import type { AdminBenefitGrantRequest } from '@dukang/shared-types'; import { request } from '../lib/api'; import { COUPON_STATUS_LABELS, fmtTime } from '../lib/constants'; import { useAdminList } from '../lib/useAdminList'; type Row = { - id: string; couponNo: string; totalAmount: number; balance: number; usedAmount: number; - status: string; sourceProduct: string; createdAt: string; + id: string; + couponNo: string; + totalAmount: number; + balance: number; + usedAmount: number; + status: string; + sourceProduct: string; + createdAt: string; user?: { userNo: string; phone: string | null }; - order?: { orderNo: string }; + order?: { orderNo: string } | null; }; export default function BenefitCouponsPage() { const [form] = Form.useForm(); + const [grantForm] = Form.useForm(); const [filters, setFilters] = useState>({}); + const [grantOpen, setGrantOpen] = useState(false); + const [granting, setGranting] = useState(false); const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList( '/admin/benefit/coupons', () => { @@ -32,52 +56,184 @@ export default function BenefitCouponsPage() { const columns: ColumnsType = [ { title: '券号', dataIndex: 'couponNo', width: 200, ellipsis: false }, { title: '用户', dataIndex: ['user', 'userNo'], width: 120, ellipsis: false }, - { title: '订单', dataIndex: ['order', 'orderNo'], width: 180, ellipsis: false }, + { title: '手机号', dataIndex: ['user', 'phone'], width: 120, render: (v) => v || '—' }, + { + title: '订单', + dataIndex: ['order', 'orderNo'], + width: 180, + ellipsis: false, + render: (v) => v || '—', + }, { title: '总额', dataIndex: 'totalAmount', width: 80, render: (v) => `¥${v}` }, { title: '余额', dataIndex: 'balance', width: 80, render: (v) => `¥${v}` }, { title: '状态', dataIndex: 'status', width: 90, render: (s) => {COUPON_STATUS_LABELS[s] || s} }, { title: '来源', dataIndex: 'sourceProduct', ellipsis: true }, { title: '创建', dataIndex: 'createdAt', width: 160, render: fmtTime }, { - title: '操作', width: 80, + title: '操作', + width: 80, render: (_, row) => ( - + ), }, ]; + async function handleGrant(values: AdminBenefitGrantRequest) { + setGranting(true); + try { + await request('/admin/benefit/coupons/grant', { + method: 'POST', + body: JSON.stringify({ + phone: values.phone.trim(), + amount: values.amount, + remark: values.remark?.trim() || undefined, + }), + }); + message.success('权益已发放'); + setGrantOpen(false); + grantForm.resetFields(); + void reload(); + } catch (e) { + message.error(e instanceof Error ? e.message : '发放失败'); + } finally { + setGranting(false); + } + } + return (
- 好客权益券 +
+ + 好客权益券 + + +
+
{ setFilters(v); setPage(1); }}> - - - + + + + + + + + + + +
+ + + setDrawerOpen(false)} + extra={ + detail && + detail.status !== 'VOID' && ( + { + await request(`/admin/benefit/coupons/${detail.id}/void`, { method: 'POST' }); + message.success('已作废'); + setDrawerOpen(false); + void reload(); + }} + > + + + ) + } + > {detail && ( {String(detail.couponNo)} + + {String((detail.user as { userNo?: string } | undefined)?.userNo ?? '—')} + + + {String((detail.user as { phone?: string } | undefined)?.phone ?? '—')} + + + {String((detail.order as { orderNo?: string } | null | undefined)?.orderNo ?? '—')} + ¥{String(detail.totalAmount)} ¥{String(detail.balance)} - {COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)} + + {COUPON_STATUS_LABELS[String(detail.status)] || String(detail.status)} + {String(detail.sourceProduct)} )} diff --git a/packages/shared-types/src/benefit.ts b/packages/shared-types/src/benefit.ts index 633550d..a41b5e8 100644 --- a/packages/shared-types/src/benefit.ts +++ b/packages/shared-types/src/benefit.ts @@ -19,3 +19,10 @@ export interface BenefitLedgerDto { balanceAfter: number; createdAt: string; } + +/** HQ 手动发放权益 */ +export interface AdminBenefitGrantRequest { + phone: string; + amount: number; + remark?: string; +} diff --git a/server/dukang-api/prisma/schema.prisma b/server/dukang-api/prisma/schema.prisma index d69e349..013c58c 100644 --- a/server/dukang-api/prisma/schema.prisma +++ b/server/dukang-api/prisma/schema.prisma @@ -876,7 +876,7 @@ model BenefitCoupon { id BigInt @id @default(autoincrement()) @db.UnsignedBigInt couponNo String @unique @map("coupon_no") @db.VarChar(32) userId BigInt @map("user_id") @db.UnsignedBigInt - orderId BigInt @unique @map("order_id") @db.UnsignedBigInt + orderId BigInt? @unique @map("order_id") @db.UnsignedBigInt totalAmount Decimal @map("total_amount") @db.Decimal(10, 2) usedAmount Decimal @default(0) @map("used_amount") @db.Decimal(10, 2) balance Decimal @db.Decimal(10, 2) @@ -887,7 +887,7 @@ model BenefitCoupon { updatedAt DateTime @updatedAt @map("updated_at") @db.DateTime(3) user User @relation(fields: [userId], references: [id], onDelete: Restrict) - order Order @relation(fields: [orderId], references: [id], onDelete: Restrict) + order Order? @relation(fields: [orderId], references: [id], onDelete: Restrict) redeemRecords RedeemRecord[] @@index([userId, status]) diff --git a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts index a7b1981..5f2d9ad 100644 --- a/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts +++ b/server/dukang-api/src/common/hq-operation/hq-operation.constants.ts @@ -37,6 +37,7 @@ export const HqOperationAction = { PRODUCT_TEMPLATE_CREATE: 'PRODUCT_TEMPLATE_CREATE', PRODUCT_TEMPLATE_UPDATE: 'PRODUCT_TEMPLATE_UPDATE', BENEFIT_COUPON_VOID: 'BENEFIT_COUPON_VOID', + BENEFIT_COUPON_GRANT: 'BENEFIT_COUPON_GRANT', DELIVERY_UPDATE: 'DELIVERY_UPDATE', TICKET_APPROVE: 'TICKET_APPROVE', TICKET_REJECT: 'TICKET_REJECT', @@ -95,6 +96,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record = { [HqOperationAction.PRODUCT_TEMPLATE_CREATE]: '新增详情模板', [HqOperationAction.PRODUCT_TEMPLATE_UPDATE]: '编辑详情模板', [HqOperationAction.BENEFIT_COUPON_VOID]: '作废权益券', + [HqOperationAction.BENEFIT_COUPON_GRANT]: '手动发放权益', [HqOperationAction.DELIVERY_UPDATE]: '编辑配送单', [HqOperationAction.TICKET_APPROVE]: '工单通过', [HqOperationAction.TICKET_REJECT]: '工单驳回', diff --git a/server/dukang-api/src/modules/benefit/benefit.service.ts b/server/dukang-api/src/modules/benefit/benefit.service.ts index ea6c92a..50b5a79 100644 --- a/server/dukang-api/src/modules/benefit/benefit.service.ts +++ b/server/dukang-api/src/modules/benefit/benefit.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { BadRequestException, Injectable } from '@nestjs/common'; import type { Prisma } from '@prisma/client'; import { calcBenefitAmount, calcBenefitSummary, generateCouponNo } from '@dukang/domain'; import { PrismaService } from '../../common/prisma/prisma.module'; @@ -50,6 +50,51 @@ export class BenefitService { return serializeBigInt(coupon); } + /** HQ 手动发放权益(无关联订单) */ + async grantManual(params: { + userId: bigint; + amount: number; + remark?: string; + sourceProduct?: string; + }) { + const amount = Number(params.amount); + if (!Number.isFinite(amount) || amount <= 0) { + throw new BadRequestException('权益金额须大于 0'); + } + if (amount > 999_999.99) { + throw new BadRequestException('权益金额超出上限'); + } + + const sourceProduct = params.sourceProduct?.trim() || '总部手动发放'; + const remark = params.remark?.trim() || '总部手动发放'; + + const coupon = await this.prisma.$transaction(async (tx) => { + const created = await tx.benefitCoupon.create({ + data: { + couponNo: generateCouponNo(), + userId: params.userId, + totalAmount: amount, + balance: amount, + sourceProduct, + }, + }); + await tx.commonEvent.create({ + data: buildBenefitLedgerEvent({ + userId: params.userId, + couponId: created.id, + type: 'GRANT', + amount, + balanceAfter: amount, + refType: 'ADMIN_GRANT', + remark, + }), + }); + return created; + }); + + return serializeBigInt(coupon); + } + async listCoupons(userId: bigint) { const list = await this.prisma.benefitCoupon.findMany({ where: { userId, status: { in: ['ACTIVE', 'USED_UP'] } }, diff --git a/server/dukang-api/src/modules/ops/admin-benefit.controller.ts b/server/dukang-api/src/modules/ops/admin-benefit.controller.ts index 167fb2f..3a18470 100644 --- a/server/dukang-api/src/modules/ops/admin-benefit.controller.ts +++ b/server/dukang-api/src/modules/ops/admin-benefit.controller.ts @@ -1,9 +1,10 @@ -import { Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common'; import { HqAuthGuard } from '../../common/guards/hq-auth.guard'; import { HqOperation } from '../../common/hq-operation/hq-operation.decorator'; import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants'; import { AdminBenefitService } from './admin-benefit.service'; import { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto'; +import { AdminBenefitGrantDto } from './dto/admin-mutate.dto'; @Controller('admin/benefit/coupons') @UseGuards(HqAuthGuard) @@ -15,6 +16,17 @@ export class AdminBenefitCouponsController { return this.service.listCoupons(query); } + @Post('grant') + @HqOperation({ + action: HqOperationAction.BENEFIT_COUPON_GRANT, + refType: 'BENEFIT_COUPON', + refIdField: 'id', + includeBody: true, + }) + grant(@Body() dto: AdminBenefitGrantDto) { + return this.service.grantCoupon(dto); + } + @Get(':id') detail(@Param('id') id: string) { return this.service.detailCoupon(BigInt(id)); diff --git a/server/dukang-api/src/modules/ops/admin-benefit.service.ts b/server/dukang-api/src/modules/ops/admin-benefit.service.ts index 4a7cf80..a23e00a 100644 --- a/server/dukang-api/src/modules/ops/admin-benefit.service.ts +++ b/server/dukang-api/src/modules/ops/admin-benefit.service.ts @@ -4,11 +4,16 @@ import { PrismaService } from '../../common/prisma/prisma.module'; import { serializeBigInt } from '../../common/decorators/current-user.decorator'; import { buildBenefitLedgerEvent, benefitLedgerWhere } from '../../common/event/event.helpers'; import { mapBenefitLedgerCompat } from '../../common/compat/v31-compat'; +import { BenefitService } from '../benefit/benefit.service'; import type { AdminBenefitCouponsQueryDto, AdminBenefitLedgersQueryDto } from './dto/admin-query.dto'; +import type { AdminBenefitGrantDto } from './dto/admin-mutate.dto'; @Injectable() export class AdminBenefitService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly benefitService: BenefitService, + ) {} async listCoupons(query: AdminBenefitCouponsQueryDto) { const page = query.page ?? 1; @@ -80,6 +85,32 @@ export class AdminBenefitService { return serializeBigInt(updated); } + async grantCoupon(dto: AdminBenefitGrantDto) { + const phone = dto.phone.trim(); + if (!/^1[3-9]\d{9}$/.test(phone)) { + throw new BadRequestException('请输入正确的用户手机号'); + } + + const user = await this.prisma.user.findFirst({ + where: { phone, status: 1, mergedIntoUserId: null }, + select: { id: true, userNo: true, phone: true, nickname: true }, + }); + if (!user) { + throw new NotFoundException('未找到该手机号对应的用户'); + } + + const coupon = await this.benefitService.grantManual({ + userId: user.id, + amount: dto.amount, + remark: dto.remark, + }); + + return serializeBigInt({ + ...coupon, + user, + }); + } + async listLedgers(query: AdminBenefitLedgersQueryDto) { const page = query.page ?? 1; const pageSize = query.pageSize ?? 20; diff --git a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts index 1d92cda..1064ab8 100644 --- a/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts +++ b/server/dukang-api/src/modules/ops/dto/admin-mutate.dto.ts @@ -993,3 +993,19 @@ export class UpdatePromoCodeStatusDto { @IsIn(['ACTIVE', 'DISABLED']) status: 'ACTIVE' | 'DISABLED'; } + +export class AdminBenefitGrantDto { + @IsString() + @IsNotEmpty() + phone: string; + + @Type(() => Number) + @IsNumber() + @Min(0.01) + @Max(999999.99) + amount: number; + + @IsOptional() + @IsString() + remark?: string; +}