From 997b3c0cdb01b50c47fe427765007d570a8205c2 Mon Sep 17 00:00:00 2001 From: WangDL Date: Fri, 22 May 2026 15:40:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20admin=20cost=20management=20=E2=80=94?= =?UTF-8?q?=20CRUD=20+=20monthly=20summary=20+=20expiry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 16 ++++ prisma/schema.prisma | 17 ++++ src/app.module.ts | 2 + .../admin-costs/admin-costs.controller.ts | 20 +++++ src/modules/admin-costs/admin-costs.module.ts | 7 ++ .../admin-costs/admin-costs.service.ts | 83 +++++++++++++++++++ 6 files changed, 145 insertions(+) create mode 100644 prisma/migrations/20260522153624_add_admin_cost_item/migration.sql create mode 100644 src/modules/admin-costs/admin-costs.controller.ts create mode 100644 src/modules/admin-costs/admin-costs.module.ts create mode 100644 src/modules/admin-costs/admin-costs.service.ts diff --git a/prisma/migrations/20260522153624_add_admin_cost_item/migration.sql b/prisma/migrations/20260522153624_add_admin_cost_item/migration.sql new file mode 100644 index 0000000..41f137a --- /dev/null +++ b/prisma/migrations/20260522153624_add_admin_cost_item/migration.sql @@ -0,0 +1,16 @@ +CREATE TABLE `AdminCostItem` ( + `id` VARCHAR(191) NOT NULL, + `name` VARCHAR(100) NOT NULL, + `category` VARCHAR(32) NOT NULL DEFAULT 'other', + `amount` DOUBLE NOT NULL, + `currency` VARCHAR(8) NOT NULL DEFAULT 'CNY', + `purchaseDate` DATETIME(3) NOT NULL, + `expiryDate` DATETIME(3) NULL, + `billingCycle` VARCHAR(16) NOT NULL DEFAULT 'once', + `note` VARCHAR(255) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + INDEX `AdminCostItem_category_idx`(`category`), + INDEX `AdminCostItem_expiryDate_idx`(`expiryDate`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e8a07c3..101d3f6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -823,3 +823,20 @@ model AdminMessage { @@index([conversationId]) @@index([createdAt]) } + +model AdminCostItem { + id String @id @default(cuid()) + name String @db.VarChar(100) + category String @default("other") @db.VarChar(32) + amount Float + currency String @default("CNY") @db.VarChar(8) + purchaseDate DateTime + expiryDate DateTime? + billingCycle String @default("once") @db.VarChar(16) + note String? @db.VarChar(255) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([category]) + @@index([expiryDate]) +} diff --git a/src/app.module.ts b/src/app.module.ts index 5ab2834..69f840b 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -15,6 +15,7 @@ import { AuthModule } from './modules/auth/auth.module'; import { AdminAuthModule } from './modules/admin-auth/admin-auth.module'; import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module'; import { AdminUsersModule } from './modules/admin-users/admin-users.module'; +import { AdminCostsModule } from './modules/admin-costs/admin-costs.module'; import { AdminBillingModule } from './modules/admin-billing/admin-billing.module'; import { AdminServersModule } from './modules/admin-servers/admin-servers.module'; import { AdminConversationModule } from './modules/admin-conversation/admin-conversation.module'; @@ -91,6 +92,7 @@ import appleConfig from './config/apple.config'; AdminAuthModule, AdminDashboardModule, AdminUsersModule, + AdminCostsModule, AdminBillingModule, AdminServersModule, AdminConversationModule, diff --git a/src/modules/admin-costs/admin-costs.controller.ts b/src/modules/admin-costs/admin-costs.controller.ts new file mode 100644 index 0000000..a9dcf3d --- /dev/null +++ b/src/modules/admin-costs/admin-costs.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; +import { AdminCostsService } from './admin-costs.service'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; +import { AdminRoles } from '../../common/decorators/admin-roles.decorator'; +import type { AdminRole } from '../../common/types/admin-role.enum'; + +@ApiTags('admin-costs') +@Controller('admin-api/costs') +@UseGuards(AdminAuthGuard) +@ApiBearerAuth() +export class AdminCostsController { + constructor(private readonly svc: AdminCostsService) {} + + @Get() @AdminRoles('SUPER_ADMIN' as AdminRole) async list() { return this.svc.list(); } + @Get('summary') @AdminRoles('SUPER_ADMIN' as AdminRole) async summary() { return this.svc.summary(); } + @Post() @AdminRoles('SUPER_ADMIN' as AdminRole) async create(@Body() d: any) { return this.svc.create(d); } + @Patch(':id') @AdminRoles('SUPER_ADMIN' as AdminRole) async update(@Param('id') id: string, @Body() d: any) { return this.svc.update(id, d); } + @Delete(':id') @AdminRoles('SUPER_ADMIN' as AdminRole) async delete(@Param('id') id: string) { return this.svc.delete(id); } +} diff --git a/src/modules/admin-costs/admin-costs.module.ts b/src/modules/admin-costs/admin-costs.module.ts new file mode 100644 index 0000000..7c33760 --- /dev/null +++ b/src/modules/admin-costs/admin-costs.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { AdminCostsController } from './admin-costs.controller'; +import { AdminCostsService } from './admin-costs.service'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; +import { AdminAuthGuard } from '../../common/guards/admin-auth.guard'; +@Module({ controllers: [AdminCostsController], providers: [AdminCostsService, PrismaService, AdminAuthGuard] }) +export class AdminCostsModule {} diff --git a/src/modules/admin-costs/admin-costs.service.ts b/src/modules/admin-costs/admin-costs.service.ts new file mode 100644 index 0000000..70e8a2a --- /dev/null +++ b/src/modules/admin-costs/admin-costs.service.ts @@ -0,0 +1,83 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../infrastructure/database/prisma.service'; + +export interface CostSummary { + totalMonthly: number; + totalYearly: number; + totalOneTime: number; + items: any[]; + byMonth: { month: string; total: number; items: { name: string; amount: number }[] }[]; + expiringSoon: any[]; +} + +@Injectable() +export class AdminCostsService { + constructor(private readonly prisma: PrismaService) {} + + async list() { + return this.prisma.adminCostItem.findMany({ orderBy: { createdAt: 'desc' } }); + } + + async create(data: { name: string; category: string; amount: number; currency?: string; purchaseDate: string; expiryDate?: string; billingCycle?: string; note?: string }) { + return this.prisma.adminCostItem.create({ + data: { + name: data.name, category: data.category || 'other', amount: data.amount, + currency: data.currency || 'CNY', purchaseDate: new Date(data.purchaseDate), + expiryDate: data.expiryDate ? new Date(data.expiryDate) : null, + billingCycle: data.billingCycle || 'once', note: data.note, + }, + }); + } + + async update(id: string, data: any) { + return this.prisma.adminCostItem.update({ where: { id }, data }); + } + + async delete(id: string) { + return this.prisma.adminCostItem.delete({ where: { id } }); + } + + async summary(): Promise { + const items = await this.prisma.adminCostItem.findMany({ orderBy: { purchaseDate: 'desc' } }); + const now = new Date(); + + // Calculate monthly equivalent + let totalMonthly = 0, totalYearly = 0, totalOneTime = 0; + const byMonth: Record = {}; + const expiringSoon: any[] = []; + + for (const item of items) { + // Monthly cost + let monthly = 0; + if (item.billingCycle === 'monthly') monthly = item.amount; + else if (item.billingCycle === 'yearly') monthly = item.amount / 12; + else totalOneTime += item.amount; + + totalMonthly += monthly; + if (item.billingCycle === 'yearly') totalYearly += item.amount; + + // Group by purchase month + const month = item.purchaseDate.toISOString().slice(0, 7); + if (!byMonth[month]) byMonth[month] = { total: 0, items: [] }; + byMonth[month].total += item.amount; + byMonth[month].items.push({ name: item.name, amount: item.amount }); + + // Expiring within 30 days + if (item.expiryDate) { + const daysLeft = Math.ceil((new Date(item.expiryDate).getTime() - now.getTime()) / 86400000); + if (daysLeft <= 30 && daysLeft >= 0) { + expiringSoon.push({ ...item, daysLeft }); + } + } + } + + return { + totalMonthly: Math.round(totalMonthly * 100) / 100, + totalYearly: Math.round(totalYearly * 100) / 100, + totalOneTime: Math.round(totalOneTime * 100) / 100, + items, + byMonth: Object.entries(byMonth).map(([month, data]) => ({ month, ...data })).sort((a, b) => b.month.localeCompare(a.month)), + expiringSoon: expiringSoon.sort((a: any, b: any) => a.daysLeft - b.daysLeft), + }; + } +}