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([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 { 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,
|
||||
|
||||
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