feat: admin cost management — CRUD + monthly summary + expiry
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 39s
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 39s
This commit is contained in:
parent
c6aa4cf88a
commit
997b3c0cdb
@ -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;
|
||||||
@ -823,3 +823,20 @@ model AdminMessage {
|
|||||||
@@index([conversationId])
|
@@index([conversationId])
|
||||||
@@index([createdAt])
|
@@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])
|
||||||
|
}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { AuthModule } from './modules/auth/auth.module';
|
|||||||
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
|
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
|
||||||
import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module';
|
import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module';
|
||||||
import { AdminUsersModule } from './modules/admin-users/admin-users.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 { AdminBillingModule } from './modules/admin-billing/admin-billing.module';
|
||||||
import { AdminServersModule } from './modules/admin-servers/admin-servers.module';
|
import { AdminServersModule } from './modules/admin-servers/admin-servers.module';
|
||||||
import { AdminConversationModule } from './modules/admin-conversation/admin-conversation.module';
|
import { AdminConversationModule } from './modules/admin-conversation/admin-conversation.module';
|
||||||
@ -91,6 +92,7 @@ import appleConfig from './config/apple.config';
|
|||||||
AdminAuthModule,
|
AdminAuthModule,
|
||||||
AdminDashboardModule,
|
AdminDashboardModule,
|
||||||
AdminUsersModule,
|
AdminUsersModule,
|
||||||
|
AdminCostsModule,
|
||||||
AdminBillingModule,
|
AdminBillingModule,
|
||||||
AdminServersModule,
|
AdminServersModule,
|
||||||
AdminConversationModule,
|
AdminConversationModule,
|
||||||
|
|||||||
20
src/modules/admin-costs/admin-costs.controller.ts
Normal file
20
src/modules/admin-costs/admin-costs.controller.ts
Normal file
@ -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); }
|
||||||
|
}
|
||||||
7
src/modules/admin-costs/admin-costs.module.ts
Normal file
7
src/modules/admin-costs/admin-costs.module.ts
Normal file
@ -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 {}
|
||||||
83
src/modules/admin-costs/admin-costs.service.ts
Normal file
83
src/modules/admin-costs/admin-costs.service.ts
Normal file
@ -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<CostSummary> {
|
||||||
|
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<string, { total: number; items: { name: string; amount: number }[] }> = {};
|
||||||
|
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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user