From 5836b421d86bd0b288c37637dde596f154833920 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 18 Jul 2026 15:33:41 +0800 Subject: [PATCH 1/2] fix: harden financial transaction boundaries --- apps/admin/src/pages/Bills/index.tsx | 4 +- apps/admin/src/pages/Wallets/index.tsx | 4 +- apps/admin/src/utils/operation-id.ts | 1 + apps/server/src/app.module.ts | 4 + apps/server/src/bills/bills.service.spec.ts | 4 +- apps/server/src/bills/bills.service.ts | 247 +++++++++--------- apps/server/src/bills/dto/bill.dto.ts | 10 + .../database/database-migrations.service.ts | 38 +++ apps/server/src/entities/bill-item.entity.ts | 6 + .../entities/financial-operation.entity.ts | 31 +++ apps/server/src/entities/index.ts | 1 + .../src/entities/room-expense.entity.ts | 5 + .../src/entities/wallet-transaction.entity.ts | 3 + apps/server/src/expenses/expenses.service.ts | 102 ++++---- .../financial-operations.module.ts | 12 + .../financial-operations.service.ts | 55 ++++ .../src/occupancies/occupancies.service.ts | 215 ++++++++------- apps/server/src/wallets/dto/wallet.dto.ts | 12 +- apps/server/src/wallets/wallets.service.ts | 74 ++++-- 19 files changed, 514 insertions(+), 314 deletions(-) create mode 100644 apps/admin/src/utils/operation-id.ts create mode 100644 apps/server/src/entities/financial-operation.entity.ts create mode 100644 apps/server/src/financial-operations/financial-operations.module.ts create mode 100644 apps/server/src/financial-operations/financial-operations.service.ts diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index a536da4..af2d466 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -25,6 +25,7 @@ import PermissionButton from '../../components/PermissionButton'; import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; import { buildBillPrintHtml, type BillPrintData } from './bill-print'; +import { newOperationId } from '../../utils/operation-id'; const statusMap: Record = { @@ -93,6 +94,7 @@ const BillsPage: React.FC = () => { const values = await generateForm.validateFields(); try { const res: any = await api.post('/bills/generate', { + operationId: newOperationId(), billingMonth: values.billingMonth.format('YYYY-MM'), }); message.success(res.message || '生成成功'); @@ -128,7 +130,7 @@ const BillsPage: React.FC = () => { okText: '确认取消', cancelText: '返回', onOk: async () => { if (!reason.trim()) { message.error('请输入取消原因'); throw new Error('reason required'); } - await api.post(`/bills/${id}/cancel`, { reason: reason.trim() }); + await api.post(`/bills/${id}/cancel`, { operationId: newOperationId(), reason: reason.trim() }); message.success('账单已取消,已扣余额已冲正退回'); fetchData(); }, diff --git a/apps/admin/src/pages/Wallets/index.tsx b/apps/admin/src/pages/Wallets/index.tsx index 9ac926f..9792c43 100644 --- a/apps/admin/src/pages/Wallets/index.tsx +++ b/apps/admin/src/pages/Wallets/index.tsx @@ -5,6 +5,7 @@ import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; +import { newOperationId } from '../../utils/operation-id'; interface WalletRow { studentId: number; @@ -59,7 +60,7 @@ const WalletsPage: React.FC = () => { const values = await form.validateFields(); setSaving(true); try { - const result: any = await api.post('/wallets/change-balance', { studentId: selected.studentId, ...values }); + const result: any = await api.post('/wallets/change-balance', { operationId: newOperationId(), studentId: selected.studentId, ...values }); const paid = (result.payments || []).reduce((sum: number, bill: any) => sum + Number(bill.paidAmount || 0), 0); message.success(paid > 0 ? `余额已更新,并自动补扣历史账单` : '余额已更新'); setSelected(null); @@ -73,6 +74,7 @@ const WalletsPage: React.FC = () => { setSaving(true); try { const result: any = await api.post('/wallets/batch-change-balance', { + operationId: newOperationId(), studentIds: selectedRowKeys, ...values, }); diff --git a/apps/admin/src/utils/operation-id.ts b/apps/admin/src/utils/operation-id.ts new file mode 100644 index 0000000..f92dbe4 --- /dev/null +++ b/apps/admin/src/utils/operation-id.ts @@ -0,0 +1 @@ +export const newOperationId = () => crypto.randomUUID(); diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index f3f34f1..b86de8d 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -46,6 +46,7 @@ import { AiConfig, StudentWallet, WalletTransaction, + FinancialOperation, } from './entities'; import { AuthModule } from './auth/auth.module'; import { AuthorizationModule } from './authorization'; @@ -76,6 +77,7 @@ import { DatabaseMigrationsModule } from './database/database-migrations.module' import { AgentToolsModule } from './agent-tools'; import { AiConfigModule } from './ai-config/ai-config.module'; import { WalletsModule } from './wallets/wallets.module'; +import { FinancialOperationsModule } from './financial-operations/financial-operations.module'; import { IntegrationConfig, @@ -143,6 +145,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; AiConfig, StudentWallet, WalletTransaction, + FinancialOperation, ]; if (dbType === 'mysql') { return { @@ -177,6 +180,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; OperationLogsModule, DepositsModule, WalletsModule, + FinancialOperationsModule, ClassroomsModule, AttendanceModule, AttendanceDevicesModule, diff --git a/apps/server/src/bills/bills.service.spec.ts b/apps/server/src/bills/bills.service.spec.ts index eee388a..3c38bb7 100644 --- a/apps/server/src/bills/bills.service.spec.ts +++ b/apps/server/src/bills/bills.service.spec.ts @@ -284,7 +284,7 @@ describe('BillsService — generateBills', () => { // Bug-exposing tests // ============================================================ - it.skip('BUG: long-term multi-month period → monthlyRate not multiplied by months', async () => { + it('long-term multi-month period multiplies and prorates monthly rent', async () => { // 3-month period: Jan–Mar 2026 const THREE_MONTHS = { periodStart: '2026-01-01', periodEnd: '2026-03-31' }; @@ -328,7 +328,7 @@ describe('BillsService — generateBills', () => { expect(actual).toBeCloseTo(expected, 0); }); - it.skip('BUG: long-term partial month → full monthlyRate charged instead of prorated', async () => { + it('long-term partial month prorates by calendar days', async () => { // Student occupies only Jun 15–30 (16 days out of 30), monthlyRate 600 (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( mockQueryBuilder([ diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts index 545949a..be1fb7f 100644 --- a/apps/server/src/bills/bills.service.ts +++ b/apps/server/src/bills/bills.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, DataSource, EntityManager } from 'typeorm'; import { Bill } from '../entities/bill.entity'; @@ -10,6 +10,7 @@ import { Room } from '../entities/room.entity'; import { StudentWallet } from '../entities/student-wallet.entity'; import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; import { WalletsService } from '../wallets/wallets.service'; +import { FinancialOperationsService } from '../financial-operations/financial-operations.service'; @Injectable() @@ -23,12 +24,22 @@ export class BillsService { @InjectRepository(Room) private roomRepo: Repository, private dataSource: DataSource, private walletsService: WalletsService, + @Optional() + private financialOperations?: FinancialOperationsService, ) {} /** * 核心计费引擎:按"人天数"加权分摊 */ async generateBills(dto: GenerateBillsDto) { + const { operationId, ...request } = dto; + const work = () => this.generateBillsOnce(request as GenerateBillsDto); + return this.financialOperations + ? this.financialOperations.run(operationId, 'bill.generate', work) + : work(); + } + + private async generateBillsOnce(dto: GenerateBillsDto) { const { periodStart, periodEnd } = dto.billingMonth ? this.resolveBillingPeriod(dto.billingMonth) : { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! }; @@ -37,55 +48,30 @@ export class BillsService { } const pStart = new Date(`${periodStart}T00:00:00Z`); const pEnd = new Date(`${periodEnd}T00:00:00Z`); - const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } }); if (existingBills.length > 0) { throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`); } - - const existingDrafts: Bill[] = []; - if (existingDrafts.length > 0) { - const draftIds = existingDrafts.map((b) => b.id); - await this.personalExpRepo - .createQueryBuilder() - .update() - .set({ billId: null }) - .where('billId IN (:...ids)', { ids: draftIds }) - .execute(); - await this.itemRepo - .createQueryBuilder() - .delete() - .where('billId IN (:...ids)', { ids: draftIds }) - .execute(); - await this.billRepo - .createQueryBuilder() - .delete() - .where('id IN (:...ids)', { ids: draftIds }) - .execute(); - } - - // 获取所有有费用的宿舍 const roomExpenses = await this.roomExpRepo .createQueryBuilder('e') - .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { - periodStart, - periodEnd, - }) + .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { periodStart, periodEnd }) .andWhere('e.status = :status', { status: 'active' }) .getMany(); - - // 按宿舍分组费用 + const longTermOccupancies: Occupancy[] = []; const roomExpMap = new Map(); - for (const exp of roomExpenses) { - if (!roomExpMap.has(exp.roomId)) roomExpMap.set(exp.roomId, []); - roomExpMap.get(exp.roomId)!.push(exp); + for (const expense of roomExpenses) { + const expenses = roomExpMap.get(expense.roomId) || []; + expenses.push(expense); + roomExpMap.set(expense.roomId, expenses); } + const roomIds = new Set([ + ...roomExpMap.keys(), + ...longTermOccupancies.filter((occupancy) => occupancy.stayType === 'long').map((occupancy) => occupancy.roomId), + ]); + const studentBillData = new Map> }>(); - // 计算每个学生的分摊费用 - const studentBillData = new Map(); - - for (const [roomId, expenses] of roomExpMap) { - // 获取该宿舍在此周期内的所有入住记录 + for (const roomId of roomIds) { + const expenses = roomExpMap.get(roomId) || []; const occupancies = await this.occRepo .createQueryBuilder('o') .leftJoinAndSelect('o.student', 'student') @@ -94,112 +80,92 @@ export class BillsService { .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) .getMany(); + const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long'); + const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long'); - - // 分离长租与短租入住记录 - const shortTermOccs = occupancies.filter((o) => o.stayType !== 'long'); - const longTermOccs = occupancies.filter((o) => o.stayType === 'long'); - - // 长租:按月租费独立计费,不参与人天数分摊 - for (const occ of longTermOccs) { - const monthlyRate = Number(occ.room?.monthlyRate || 0); - if (!studentBillData.has(occ.studentId)) { - studentBillData.set(occ.studentId, { shared: 0, items: [] }); - } - const data = studentBillData.get(occ.studentId)!; - data.shared += monthlyRate; + for (const occupancy of longTermOccs) { + const rent = this.calculateLongTermRent( + occupancy, + periodStart, + periodEnd, + Number(occupancy.room?.monthlyRate || 0), + ); + if (rent <= 0) continue; + const data = studentBillData.get(occupancy.studentId) || { shared: 0, items: [] }; + data.shared += rent; data.items.push({ roomId, expenseType: 'rent', - description: `长租月租费 (${occ.room?.roomNumber || '未知房间'})`, + description: `长租月租费 (${occupancy.room?.roomNumber || '未知房间'})`, days: 0, totalRoomDays: 0, - roomTotalAmount: monthlyRate, - studentAmount: monthlyRate, + roomTotalAmount: rent, + studentAmount: rent, }); + studentBillData.set(occupancy.studentId, data); } - // 短租:原人天数加权分摊逻辑 - if (shortTermOccs.length === 0) continue; - - // 计算每个学生的计费天数 - const studentDays: { studentId: number; days: number }[] = []; - let totalDays = 0; - - for (const occ of shortTermOccs) { - const start = new Date( - Math.max(new Date(occ.billingStartDate).getTime(), pStart.getTime()), - ); - const end = occ.billingEndDate - ? new Date(Math.min(new Date(occ.billingEndDate).getTime(), pEnd.getTime())) + const studentDays = shortTermOccs.map((occupancy) => { + const start = new Date(Math.max(new Date(occupancy.billingStartDate).getTime(), pStart.getTime())); + const end = occupancy.billingEndDate + ? new Date(Math.min(new Date(occupancy.billingEndDate).getTime(), pEnd.getTime())) : pEnd; - const days = Math.max( - 0, - Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1, - ); - studentDays.push({ studentId: occ.studentId, days }); - totalDays += days; - } - + const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1); + return { studentId: occupancy.studentId, days }; + }); + const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0); if (totalDays === 0) continue; - // 对每项费用进行分摊;最后一人承接舍入尾差,保证分摊合计与原费用一致。 for (const expense of expenses) { - const eligibleDays = studentDays.filter((sd) => sd.days > 0); + const eligibleDays = studentDays.filter((entry) => entry.days > 0); const expenseTotal = Number(Number(expense.amount).toFixed(2)); let allocated = 0; - for (const [index, sd] of eligibleDays.entries()) { + for (const [index, entry] of eligibleDays.entries()) { const amount = index === eligibleDays.length - 1 ? Number((expenseTotal - allocated).toFixed(2)) - : Number(((sd.days / totalDays) * expenseTotal).toFixed(2)); + : Number(((entry.days / totalDays) * expenseTotal).toFixed(2)); allocated = Number((allocated + amount).toFixed(2)); - if (!studentBillData.has(sd.studentId)) { - studentBillData.set(sd.studentId, { shared: 0, items: [] }); - } - const data = studentBillData.get(sd.studentId)!; + const data = studentBillData.get(entry.studentId) || { shared: 0, items: [] }; data.shared += amount; data.items.push({ + roomExpenseId: expense.id, roomId, expenseType: expense.expenseType, description: `${expense.expenseType} 分摊`, - days: sd.days, + days: entry.days, totalRoomDays: totalDays, roomTotalAmount: expense.amount, studentAmount: amount, }); + studentBillData.set(entry.studentId, data); } } } - // 获取个人附加费 const personalExps = await this.personalExpRepo .createQueryBuilder('pe') - .where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', { - periodStart, - periodEnd, - }) + .where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', { periodStart, periodEnd }) .andWhere('pe.status = :status', { status: 'active' }) .andWhere('pe.billId IS NULL') .getMany(); - const personalMap = new Map(); - const personalItems = new Map(); - for (const pe of personalExps) { - personalMap.set(pe.studentId, (personalMap.get(pe.studentId) || 0) + Number(pe.amount)); - if (!personalItems.has(pe.studentId)) personalItems.set(pe.studentId, []); - personalItems.get(pe.studentId)!.push({ - roomId: pe.roomId, - expenseType: pe.expenseType, - description: `个人费用: ${pe.description || pe.expenseType}`, + const personalItems = new Map>>(); + for (const expense of personalExps) { + personalMap.set(expense.studentId, (personalMap.get(expense.studentId) || 0) + Number(expense.amount)); + const items = personalItems.get(expense.studentId) || []; + items.push({ + personalExpenseId: expense.id, + roomId: expense.roomId, + expenseType: expense.expenseType, + description: `个人费用: ${expense.description || expense.expenseType}`, days: 0, totalRoomDays: 0, - roomTotalAmount: pe.amount, - studentAmount: pe.amount, + roomTotalAmount: expense.amount, + studentAmount: expense.amount, }); + personalItems.set(expense.studentId, items); } - - // 合并所有涉及的学生,并在同一个事务中生成整批账单,避免中途失败留下半批数据。 const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]); const bills = await this.dataSource.transaction(async (manager) => { const generated: Bill[] = []; @@ -207,31 +173,23 @@ export class BillsService { const shared = studentBillData.get(studentId)?.shared || 0; const personal = personalMap.get(studentId) || 0; const total = Number((shared + personal).toFixed(2)); - let bill = await manager.save( - manager.create(Bill, { - studentId, - periodStart, - periodEnd, - sharedAmount: Number(shared.toFixed(2)), - personalAmount: personal, - totalAmount: total, - source: 'batch', - paidAmount: 0, - outstandingAmount: total, - status: 'unpaid', - }), - ); - const items = [ - ...(studentBillData.get(studentId)?.items || []), - ...(personalItems.get(studentId) || []), - ]; - for (const item of items) { - await manager.save(manager.create(BillItem, { ...item, billId: bill.id })); - } + let bill = await manager.save(manager.create(Bill, { + studentId, + periodStart, + periodEnd, + sharedAmount: Number(shared.toFixed(2)), + personalAmount: personal, + totalAmount: total, + source: 'batch', + paidAmount: 0, + outstandingAmount: total, + status: 'unpaid', + })); + const items = [...(studentBillData.get(studentId)?.items || []), ...(personalItems.get(studentId) || [])]; + for (const item of items) await manager.save(manager.create(BillItem, { ...item, billId: bill.id })); const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId); if (includedPersonal.length) { - await manager - .createQueryBuilder() + await manager.createQueryBuilder() .update(PersonalExpense) .set({ billId: bill.id }) .where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) }) @@ -242,10 +200,31 @@ export class BillsService { } return generated; }); - return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd }; } + private calculateLongTermRent(occupancy: Occupancy, periodStart: string, periodEnd: string, monthlyRate: number) { + const activeStart = occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart; + const activeEnd = occupancy.billingEndDate && occupancy.billingEndDate < periodEnd + ? occupancy.billingEndDate + : periodEnd; + if (activeEnd < activeStart || monthlyRate <= 0) return 0; + const [startYear, startMonth] = activeStart.split('-').map(Number); + const [endYear, endMonth] = activeEnd.split('-').map(Number); + let total = 0; + for (let year = startYear, month = startMonth; year < endYear || (year === endYear && month <= endMonth);) { + const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate(); + const prefix = `${year}-${String(month).padStart(2, '0')}-`; + const overlapStart = activeStart > `${prefix}01` ? activeStart : `${prefix}01`; + const monthEnd = `${prefix}${String(daysInMonth).padStart(2, '0')}`; + const overlapEnd = activeEnd < monthEnd ? activeEnd : monthEnd; + const days = Math.floor((Date.parse(`${overlapEnd}T00:00:00Z`) - Date.parse(`${overlapStart}T00:00:00Z`)) / 86_400_000) + 1; + total += monthlyRate * days / daysInMonth; + if (++month > 12) { month = 1; year++; } + } + return Number(total.toFixed(2)); + } + private isValidDate(value: string) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; const date = new Date(`${value}T00:00:00Z`); @@ -275,6 +254,7 @@ export class BillsService { recordedBy?: number, ) { return this.dataSource.transaction(async (manager) => { + expense = await manager.save(manager.create(PersonalExpense, expense)); let bill = await manager.save( manager.create(Bill, { studentId: expense.studentId, @@ -292,6 +272,7 @@ export class BillsService { await manager.save( manager.create(BillItem, { billId: bill.id, + personalExpenseId: expense.id, roomId: expense.roomId, expenseType: expense.expenseType, description: expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'), @@ -304,7 +285,7 @@ export class BillsService { expense.billId = bill.id; await manager.save(expense); bill = await this.walletsService.debitBill(manager, bill, recordedBy); - return bill; + return { expense, bill }; }); } @@ -382,13 +363,19 @@ export class BillsService { async cancel(id: number, dto: CancelBillDto, recordedBy?: number) { const reason = dto.reason?.trim(); if (!reason) throw new BadRequestException('取消原因不能为空'); - return this.dataSource.transaction(async (manager) => { - const bill = await manager.findOne(Bill, { where: { id } }); + const work = () => this.dataSource.transaction(async (manager) => { + const bill = await manager.createQueryBuilder(Bill, 'bill') + .where('bill.id = :id', { id }) + .setLock('pessimistic_write') + .getOne(); if (!bill) throw new NotFoundException('账单不存在'); if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消'); await manager.update(PersonalExpense, { billId: id }, { billId: null }); return this.walletsService.refundBill(manager, bill, reason, recordedBy); }); + return this.financialOperations + ? this.financialOperations.run(dto.operationId, `bill.cancel:${id}`, work) + : work(); } async remove(id: number) { diff --git a/apps/server/src/bills/dto/bill.dto.ts b/apps/server/src/bills/dto/bill.dto.ts index c4355c1..7d3d67d 100644 --- a/apps/server/src/bills/dto/bill.dto.ts +++ b/apps/server/src/bills/dto/bill.dto.ts @@ -1,6 +1,11 @@ import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator'; export class GenerateBillsDto { + @IsOptional() + @IsString() + @Matches(/^[\w-]{8,64}$/) + operationId?: string; + @IsString() @Matches(/^\d{4}-\d{2}$/) billingMonth: string; @@ -20,6 +25,11 @@ export class UpdateBillStatusDto { } export class CancelBillDto { + @IsOptional() + @IsString() + @Matches(/^[\w-]{8,64}$/) + operationId?: string; + @IsString() @IsNotEmpty() @Matches(/\S/) diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index d181a09..9a3c6e4 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -117,6 +117,44 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { description VARCHAR(300), recorded_by INTEGER, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP )`); + await runner.query(`CREATE TABLE IF NOT EXISTS financial_operations ( + id ${pk}, operation_id VARCHAR(64) NOT NULL UNIQUE, type VARCHAR(64) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'running', result_json TEXT, error_message VARCHAR(500), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + )`); + const walletTransactions = await runner.getTable('wallet_transactions'); + if (walletTransactions) { + const columns = new Set(walletTransactions.columns.map((column) => column.name)); + if (!columns.has('operation_id')) { + await runner.query('ALTER TABLE wallet_transactions ADD COLUMN operation_id VARCHAR(64)'); + } + } + const billItems = await runner.getTable('bill_items'); + if (billItems) { + const columns = new Set(billItems.columns.map((column) => column.name)); + for (const [name, definition] of [ + ['room_expense_id', 'INTEGER'], + ['personal_expense_id', 'INTEGER'], + ]) { + if (!columns.has(name)) await runner.query(`ALTER TABLE bill_items ADD COLUMN ${name} ${definition}`); + } + } + const roomExpenses = await runner.getTable('room_expenses'); + if (roomExpenses) { + const columns = new Set(roomExpenses.columns.map((column) => column.name)); + if (!columns.has('import_key')) { + await runner.query('ALTER TABLE room_expenses ADD COLUMN import_key VARCHAR(120)'); + } + const refreshedRoomExpenses = await runner.getTable('room_expenses'); + const hasImportKey = refreshedRoomExpenses?.indices.some((index) => + index.isUnique && index.columnNames.length === 1 && index.columnNames[0] === 'import_key'); + if (!hasImportKey) { + await runner.query(isMySQL + ? 'CREATE UNIQUE INDEX idx_room_expenses_import_key ON room_expenses (import_key)' + : 'CREATE UNIQUE INDEX IF NOT EXISTS idx_room_expenses_import_key ON room_expenses (import_key)'); + } + } const bills = await runner.getTable('bills'); if (bills) { const columns = new Set(bills.columns.map((column) => column.name)); diff --git a/apps/server/src/entities/bill-item.entity.ts b/apps/server/src/entities/bill-item.entity.ts index 2e92d60..0bb2b9d 100644 --- a/apps/server/src/entities/bill-item.entity.ts +++ b/apps/server/src/entities/bill-item.entity.ts @@ -9,6 +9,12 @@ export class BillItem { @Column({ name: 'bill_id' }) billId: number; + @Column({ name: 'room_expense_id', type: 'integer', nullable: true }) + roomExpenseId: number | null; + + @Column({ name: 'personal_expense_id', type: 'integer', nullable: true }) + personalExpenseId: number | null; + @Column({ name: 'room_id', nullable: true }) roomId: number; diff --git a/apps/server/src/entities/financial-operation.entity.ts b/apps/server/src/entities/financial-operation.entity.ts new file mode 100644 index 0000000..ba3d8b5 --- /dev/null +++ b/apps/server/src/entities/financial-operation.entity.ts @@ -0,0 +1,31 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +export type FinancialOperationStatus = 'running' | 'completed' | 'failed'; + +@Entity('financial_operations') +@Index(['operationId'], { unique: true }) +export class FinancialOperation { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'operation_id', type: 'varchar', length: 64, unique: true }) + operationId: string; + + @Column({ type: 'varchar', length: 64 }) + type: string; + + @Column({ type: 'varchar', length: 20, default: 'running' }) + status: FinancialOperationStatus; + + @Column({ name: 'result_json', type: 'text', nullable: true }) + resultJson: string | null; + + @Column({ name: 'error_message', type: 'varchar', length: 500, nullable: true }) + errorMessage: string | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index 8d21c6a..b0e6d31 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -39,3 +39,4 @@ export { AiConfig } from '../ai-config/ai-config.entity'; export * from './student-wallet.entity'; export * from './wallet-transaction.entity'; +export * from './financial-operation.entity'; diff --git a/apps/server/src/entities/room-expense.entity.ts b/apps/server/src/entities/room-expense.entity.ts index 2d41b16..4870466 100644 --- a/apps/server/src/entities/room-expense.entity.ts +++ b/apps/server/src/entities/room-expense.entity.ts @@ -5,10 +5,12 @@ import { CreateDateColumn, ManyToOne, JoinColumn, + Index, } from 'typeorm'; import { Room } from './room.entity'; @Entity('room_expenses') +@Index(['importKey'], { unique: true }) export class RoomExpense { @PrimaryGeneratedColumn() id: number; @@ -34,6 +36,9 @@ export class RoomExpense { @Column({ name: 'recorded_by', nullable: true }) recordedBy: number; + @Column({ name: 'import_key', type: 'varchar', length: 120, nullable: true, unique: true }) + importKey: string | null; + @Column({ type: 'varchar', length: 20, default: 'active' }) status: 'active' | 'archived'; diff --git a/apps/server/src/entities/wallet-transaction.entity.ts b/apps/server/src/entities/wallet-transaction.entity.ts index ab81c9d..067448c 100644 --- a/apps/server/src/entities/wallet-transaction.entity.ts +++ b/apps/server/src/entities/wallet-transaction.entity.ts @@ -12,6 +12,9 @@ export class WalletTransaction { @Column({ name: 'bill_id', type: 'integer', nullable: true }) billId: number | null; + @Column({ name: 'operation_id', type: 'varchar', length: 64, nullable: true }) + operationId: string | null; + @Column({ type: 'varchar', length: 30 }) type: 'recharge' | 'adjustment' | 'bill_payment' | 'bill_refund'; diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts index f4a8e10..d818e33 100644 --- a/apps/server/src/expenses/expenses.service.ts +++ b/apps/server/src/expenses/expenses.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, In } from 'typeorm'; +import { DataSource, In, Repository } from 'typeorm'; import { RoomExpense } from '../entities/room-expense.entity'; import { PersonalExpense } from '../entities/personal-expense.entity'; import { Room } from '../entities/room.entity'; @@ -23,6 +23,7 @@ export class ExpensesService { @InjectRepository(Room) private roomRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, private billsService: BillsService, + private dataSource: DataSource, ) {} async getFormLookups() { @@ -87,6 +88,8 @@ export class ExpensesService { async deleteRoomExpense(id: number) { const e = await this.roomExpRepo.findOne({ where: { id } }); if (!e) throw new NotFoundException('费用记录不存在'); + const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } }); + if (billed) throw new BadRequestException('已计入账单的宿舍费用不能归档,请先取消账单'); if (e.status === 'archived') throw new BadRequestException('费用记录已归档'); await this.roomExpRepo.update(id, { status: 'archived' }); return { message: '已归档' }; @@ -96,6 +99,8 @@ export class ExpensesService { const uniqueIds = [...new Set(ids || [])]; if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录'); const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) }, select: ['id'] }); + const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: In(uniqueIds) } }); + if (billed) throw new BadRequestException('选中记录包含已计入账单的宿舍费用'); if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在'); const result = await this.roomExpRepo .createQueryBuilder() @@ -108,6 +113,8 @@ export class ExpensesService { async updateRoomExpense(id: number, dto: Partial) { const e = await this.roomExpRepo.findOne({ where: { id } }); + const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } }); + if (billed) throw new BadRequestException('已计入账单的宿舍费用不能修改,请先取消账单'); if (!e) throw new NotFoundException('费用记录不存在'); const periodStart = dto.periodStart ?? e.periodStart; const periodEnd = dto.periodEnd ?? e.periodEnd; @@ -145,24 +152,16 @@ export class ExpensesService { this.assertPositiveAmount(dto.amount); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); if (!student) throw new NotFoundException('学生不存在'); - const expense = await this.personalExpRepo.save( - this.personalExpRepo.create({ - studentId: dto.studentId, - expenseType: dto.expenseType, - amount: dto.amount, - expenseDate: dto.periodEnd, - description: dto.description || (dto.expenseType === 'water' ? '学生水费' : '学生电费'), - recordedBy: userId, - billId: null, - }), - ); - try { - const bill = await this.billsService.createImmediatePersonalBill(expense, dto.periodStart, dto.periodEnd, userId); - return { expense, bill }; - } catch (error) { - await this.personalExpRepo.delete(expense.id); - throw error; - } + const expense = { + studentId: dto.studentId, + expenseType: dto.expenseType, + amount: dto.amount, + expenseDate: dto.periodEnd, + description: dto.description || (dto.expenseType === 'water' ? '学生水费' : '学生电费'), + recordedBy: userId, + billId: null, + } as PersonalExpense; + return this.billsService.createImmediatePersonalBill(expense, dto.periodStart, dto.periodEnd, userId); } // 个人附加费 @@ -303,45 +302,50 @@ export class ExpensesService { continue; } - // 幂等:先删除该房间在同一周期已有的水/电费用记录,避免重复导入产生脏数据 - await this.roomExpRepo - .createQueryBuilder() - .delete() - .where('roomId = :roomId', { roomId: room.id }) - .andWhere('periodStart = :ps AND periodEnd = :pe', { ps: periodStart, pe: periodEnd }) - .andWhere('expenseType IN (:...types)', { types: ['water', 'electricity'] }) - .execute(); + const existing = await this.roomExpRepo.find({ + where: [ + { importKey: `${room.id}:${periodStart}:${periodEnd}:electricity` }, + { importKey: `${room.id}:${periodStart}:${periodEnd}:water` }, + ], + }); + const byType = new Map(existing.map((expense) => [expense.expenseType, expense])); let savedAny = false; // 导入电费 if (row.electricityFee > 0) { - await this.roomExpRepo.save( - this.roomExpRepo.create({ - roomId: room.id, - expenseType: 'electricity', - amount: row.electricityFee, - periodStart, - periodEnd, - description: `电量${row.electricityAmount}kWh`, - recordedBy: userId, - }), - ); + const expense = byType.get('electricity') || this.roomExpRepo.create({ + roomId: room.id, + expenseType: 'electricity', + periodStart, + periodEnd, + importKey: `${room.id}:${periodStart}:${periodEnd}:electricity`, + }); + if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) { + throw new BadRequestException('该周期电费已计入账单,不能覆盖'); + } + expense.amount = row.electricityFee; + expense.description = `电量${row.electricityAmount}kWh`; + expense.recordedBy = userId!; + await this.roomExpRepo.save(expense); savedAny = true; } // 导入水费 if (row.waterFee > 0) { - await this.roomExpRepo.save( - this.roomExpRepo.create({ - roomId: room.id, - expenseType: 'water', - amount: row.waterFee, - periodStart, - periodEnd, - description: `用水${row.waterAmount}吨`, - recordedBy: userId, - }), - ); + const expense = byType.get('water') || this.roomExpRepo.create({ + roomId: room.id, + expenseType: 'water', + periodStart, + periodEnd, + importKey: `${room.id}:${periodStart}:${periodEnd}:water`, + }); + if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) { + throw new BadRequestException('该周期水费已计入账单,不能覆盖'); + } + expense.amount = row.waterFee; + expense.description = `用水${row.waterAmount}吨`; + expense.recordedBy = userId!; + await this.roomExpRepo.save(expense); savedAny = true; } diff --git a/apps/server/src/financial-operations/financial-operations.module.ts b/apps/server/src/financial-operations/financial-operations.module.ts new file mode 100644 index 0000000..35e6522 --- /dev/null +++ b/apps/server/src/financial-operations/financial-operations.module.ts @@ -0,0 +1,12 @@ +import { Global, Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FinancialOperation } from '../entities/financial-operation.entity'; +import { FinancialOperationsService } from './financial-operations.service'; + +@Global() +@Module({ + imports: [TypeOrmModule.forFeature([FinancialOperation])], + providers: [FinancialOperationsService], + exports: [FinancialOperationsService], +}) +export class FinancialOperationsModule {} diff --git a/apps/server/src/financial-operations/financial-operations.service.ts b/apps/server/src/financial-operations/financial-operations.service.ts new file mode 100644 index 0000000..99c5964 --- /dev/null +++ b/apps/server/src/financial-operations/financial-operations.service.ts @@ -0,0 +1,55 @@ +import { ConflictException, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { FinancialOperation } from '../entities/financial-operation.entity'; + +@Injectable() +export class FinancialOperationsService { + constructor( + @InjectRepository(FinancialOperation) + private readonly repo: Repository, + ) {} + + async run(operationId: string | undefined, type: string, work: () => Promise): Promise { + if (!operationId) return work(); + if (!/^[\w-]{8,64}$/.test(operationId)) throw new ConflictException('operationId 格式无效'); + + const existing = await this.repo.findOne({ where: { operationId } }); + if (existing) { + if (existing.type !== type) throw new ConflictException('operationId 已用于其他操作'); + if (existing.status === 'completed' && existing.resultJson) return JSON.parse(existing.resultJson) as T; + if (existing.status === 'running') throw new ConflictException('该操作正在处理中,请勿重复提交'); + } + + let operation = existing; + if (!operation) { + try { + operation = await this.repo.save(this.repo.create({ operationId, type, status: 'running' })); + } catch (error) { + const concurrent = await this.repo.findOne({ where: { operationId } }); + if (concurrent?.status === 'completed' && concurrent.resultJson) { + return JSON.parse(concurrent.resultJson) as T; + } + throw new ConflictException('该操作正在处理中,请勿重复提交', { cause: error }); + } + } else { + operation.status = 'running'; + operation.errorMessage = null; + operation.resultJson = null; + await this.repo.save(operation); + } + + try { + const result = await work(); + operation.status = 'completed'; + operation.resultJson = JSON.stringify(result); + await this.repo.save(operation); + return result; + } catch (error) { + operation.status = 'failed'; + operation.errorMessage = error instanceof Error ? error.message.slice(0, 500) : '未知错误'; + await this.repo.save(operation); + throw error; + } + } +} diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index 9c95640..cb6c339 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -49,125 +49,106 @@ export class OccupanciesService { async checkIn(dto: CheckInDto, userId?: number) { this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期'); + return this.dataSource.transaction(async (manager) => { + const existing = await manager.createQueryBuilder(Occupancy, 'occupancy') + .where('occupancy.studentId = :studentId', { studentId: dto.studentId }) + .andWhere('occupancy.checkOutDate IS NULL') + .setLock('pessimistic_write') + .getOne(); + if (existing) throw new BadRequestException('该学生已有在住记录,请先办理退宿'); - // 检查学生是否已有活跃入住 - const existing = await this.repo.findOne({ - where: { studentId: dto.studentId, checkOutDate: IsNull() }, - }); - if (existing) throw new BadRequestException('该学生已有在住记录,请先办理退宿'); + const room = await manager.createQueryBuilder(Room, 'room') + .where('room.id = :roomId', { roomId: dto.roomId }) + .setLock('pessimistic_write') + .getOne(); + if (!room) throw new NotFoundException('宿舍不存在'); + if (room.status === 'archived' || room.status === 'maintenance') { + throw new BadRequestException('该宿舍当前不可入住'); + } + const count = await manager.count(Occupancy, { where: { roomId: dto.roomId, checkOutDate: IsNull() } }); + if (count >= room.capacity) throw new BadRequestException('宿舍已满'); + const student = await manager.findOne(Student, { where: { id: dto.studentId } }); + if (!student) throw new NotFoundException('学生不存在'); - // 检查宿舍容量 - const room = await this.roomRepo.findOne({ where: { id: dto.roomId } }); - if (!room) throw new NotFoundException('宿舍不存在'); - if (room.status === 'archived' || room.status === 'maintenance') { - throw new BadRequestException('该宿舍当前不可入住'); - } - const count = await this.repo.count({ where: { roomId: dto.roomId, checkOutDate: IsNull() } }); - if (count >= room.capacity) throw new BadRequestException('宿舍已满'); + if (dto.bedId) { + const bed = await manager.createQueryBuilder(Bed, 'bed') + .where('bed.id = :bedId AND bed.roomId = :roomId', { bedId: dto.bedId, roomId: dto.roomId }) + .setLock('pessimistic_write') + .getOne(); + if (!bed) throw new BadRequestException('床位不存在或不属于该宿舍'); + if (bed.status !== 'available') throw new BadRequestException('该床位已被占用或维修中'); + } + if (dto.lockerId) { + const locker = await manager.createQueryBuilder(Locker, 'locker') + .where('locker.id = :lockerId AND locker.roomId = :roomId', { lockerId: dto.lockerId, roomId: dto.roomId }) + .setLock('pessimistic_write') + .getOne(); + if (!locker) throw new BadRequestException('柜子不存在或不属于该宿舍'); + if (locker.status !== 'available') throw new BadRequestException('柜子已被占用或维修中'); + } - const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); - if (!student) throw new NotFoundException('学生不存在'); + const saved = await manager.save(manager.create(Occupancy, { + studentId: dto.studentId, + roomId: dto.roomId, + checkInDate: dto.checkInDate, + billingStartDate: dto.billingStartDate || dto.checkInDate, + stayType: dto.stayType, + responsibleOrganizationId: student.organizationId, + notes: dto.notes, + bedId: dto.bedId, + lockerId: dto.lockerId, + })); + if (dto.bedId) await manager.update(Bed, dto.bedId, { status: 'occupied' }); + if (dto.lockerId) await manager.update(Locker, dto.lockerId, { status: 'occupied' }); + if (count + 1 >= room.capacity) await manager.update(Room, room.id, { status: 'full' }); - // 床位校验 - if (dto.bedId) { - const bed = await this.bedRepo.findOne({ where: { id: dto.bedId, roomId: dto.roomId } }); - if (!bed) throw new BadRequestException('床位不存在或不属于该宿舍'); - if (bed.status !== 'available') throw new BadRequestException('该床位已被占用或维修中'); - } - - // 柜子校验 - if (dto.lockerId) { - const locker = await this.lockerRepo.findOne({ - where: { id: dto.lockerId, roomId: dto.roomId }, - }); - if (!locker) throw new BadRequestException('柜子不存在或不属于该宿舍'); - if (locker.status !== 'available') throw new BadRequestException('该柜子已被占用或维修中'); - } - - const occ = this.repo.create({ - studentId: dto.studentId, - roomId: dto.roomId, - checkInDate: dto.checkInDate, - billingStartDate: dto.billingStartDate || dto.checkInDate, - stayType: dto.stayType, - responsibleOrganizationId: student.organizationId, - notes: dto.notes, - bedId: dto.bedId, - lockerId: dto.lockerId, - }); - const saved = await this.repo.save(occ); - - // 更新床位/柜子状态 - if (dto.bedId) { - await this.bedRepo.update(dto.bedId, { status: 'occupied' }); - } - if (dto.lockerId) { - await this.lockerRepo.update(dto.lockerId, { status: 'occupied' }); - } - - // 更新宿舍状态 - if (count + 1 >= room.capacity) { - await this.roomRepo.update(room.id, { status: 'full' }); - } - - if (dto.collectDeposit) { - const existingDeposit = await this.depositRepo.findOne({ - where: { studentId: dto.studentId }, - }); - if (existingDeposit) { - existingDeposit.amount = Number( - (Number(existingDeposit.amount || 0) + Number(dto.depositAmount ?? 500)).toFixed(2), - ); - existingDeposit.status = 'paid'; - existingDeposit.paidDate = dto.checkInDate; - existingDeposit.recordedBy = userId ?? null; - existingDeposit.notes = '入住登记自动收取'; - await this.depositRepo.save(existingDeposit); - } else { - await this.depositRepo.save( - this.depositRepo.create({ + if (dto.collectDeposit) { + let deposit = await manager.findOne(Deposit, { where: { studentId: dto.studentId } }); + if (deposit) { + deposit.amount = Number((Number(deposit.amount || 0) + Number(dto.depositAmount ?? 500)).toFixed(2)); + deposit.status = 'paid'; + deposit.paidDate = dto.checkInDate; + deposit.recordedBy = userId ?? null; + deposit.notes = '入住登记自动收取'; + } else { + deposit = manager.create(Deposit, { studentId: dto.studentId, amount: dto.depositAmount ?? 500, paidDate: dto.checkInDate, status: 'paid', recordedBy: userId, notes: '入住登记自动收取', - }), - ); + }); + } + await manager.save(deposit); } - } - - return saved; + return saved; + }); } async checkOut(occupancyId: number, dto: CheckOutDto) { - const occ = await this.repo.findOne({ where: { id: occupancyId } }); - if (!occ) throw new NotFoundException('入住记录不存在'); - if (occ.checkOutDate) throw new BadRequestException('该记录已退宿'); - this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); - this.assertDateOrder( - occ.billingStartDate || occ.checkInDate, - dto.billingEndDate || dto.checkOutDate, - '计费截止日不能早于计费起始日', - ); - - occ.checkOutDate = dto.checkOutDate; - occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; - occ.checkOutReason = dto.checkOutReason || ''; - await this.repo.save(occ); - - // 释放床位/柜子 - if (occ.bedId) { - await this.bedRepo.update(occ.bedId, { status: 'available' }); - } - if (occ.lockerId) { - await this.lockerRepo.update(occ.lockerId, { status: 'available' }); - } - - // 更新宿舍状态 - await this.roomRepo.update(occ.roomId, { status: 'available' }); - - return occ; + return this.dataSource.transaction(async (manager) => { + const occ = await manager.createQueryBuilder(Occupancy, 'occupancy') + .where('occupancy.id = :id', { id: occupancyId }) + .setLock('pessimistic_write') + .getOne(); + if (!occ) throw new NotFoundException('入住记录不存在'); + if (occ.checkOutDate) throw new BadRequestException('该记录已退宿'); + this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); + this.assertDateOrder( + occ.billingStartDate || occ.checkInDate, + dto.billingEndDate || dto.checkOutDate, + '计费截止日不能早于计费起始日', + ); + occ.checkOutDate = dto.checkOutDate; + occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; + occ.checkOutReason = dto.checkOutReason || ''; + await manager.save(occ); + if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' }); + if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' }); + await manager.update(Room, occ.roomId, { status: 'available' }); + return occ; + }); } async transferRoom(occupancyId: number, dto: TransferRoomDto) { @@ -175,7 +156,10 @@ export class OccupanciesService { await runner.connect(); await runner.startTransaction(); try { - const oldOcc = await runner.manager.findOne(Occupancy, { where: { id: occupancyId } }); + const oldOcc = await runner.manager.createQueryBuilder(Occupancy, 'occupancy') + .where('occupancy.id = :id', { id: occupancyId }) + .setLock('pessimistic_write') + .getOne(); if (!oldOcc) throw new NotFoundException('入住记录不存在'); if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿'); if (oldOcc.roomId === dto.newRoomId) @@ -201,7 +185,10 @@ export class OccupanciesService { } await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); // 检查新房容量 - const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } }); + const newRoom = await runner.manager.createQueryBuilder(Room, 'room') + .where('room.id = :roomId', { roomId: dto.newRoomId }) + .setLock('pessimistic_write') + .getOne(); if (!newRoom) throw new NotFoundException('目标宿舍不存在'); if (newRoom.status === 'archived' || newRoom.status === 'maintenance') { throw new BadRequestException('目标宿舍当前不可入住'); @@ -213,16 +200,18 @@ export class OccupanciesService { // 新床位校验 if (dto.newBedId) { - const newBed = await runner.manager.findOne(Bed, { - where: { id: dto.newBedId, roomId: dto.newRoomId }, - }); + const newBed = await runner.manager.createQueryBuilder(Bed, 'bed') + .where('bed.id = :bedId AND bed.roomId = :roomId', { bedId: dto.newBedId, roomId: dto.newRoomId }) + .setLock('pessimistic_write') + .getOne(); if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍'); if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用'); } if (dto.newLockerId) { - const newLocker = await runner.manager.findOne(Locker, { - where: { id: dto.newLockerId, roomId: dto.newRoomId }, - }); + const newLocker = await runner.manager.createQueryBuilder(Locker, 'locker') + .where('locker.id = :lockerId AND locker.roomId = :roomId', { lockerId: dto.newLockerId, roomId: dto.newRoomId }) + .setLock('pessimistic_write') + .getOne(); if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍'); if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用'); } diff --git a/apps/server/src/wallets/dto/wallet.dto.ts b/apps/server/src/wallets/dto/wallet.dto.ts index f15557c..97a1973 100644 --- a/apps/server/src/wallets/dto/wallet.dto.ts +++ b/apps/server/src/wallets/dto/wallet.dto.ts @@ -1,7 +1,12 @@ import { Type } from 'class-transformer'; -import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNumber, IsOptional, IsString, MaxLength, NotEquals } from 'class-validator'; +import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNumber, IsOptional, IsString, Matches, MaxLength, NotEquals } from 'class-validator'; export class ChangeWalletBalanceDto { + @IsOptional() + @IsString() + @Matches(/^[\w-]{8,64}$/) + operationId?: string; + @IsInt() studentId: number; @@ -20,6 +25,11 @@ export class ChangeWalletBalanceDto { export class BatchChangeWalletBalanceDto { + @IsOptional() + @IsString() + @Matches(/^[\w-]{8,64}$/) + operationId?: string; + @IsArray() @ArrayNotEmpty() @IsInt({ each: true }) diff --git a/apps/server/src/wallets/wallets.service.ts b/apps/server/src/wallets/wallets.service.ts index 489c230..1523fb8 100644 --- a/apps/server/src/wallets/wallets.service.ts +++ b/apps/server/src/wallets/wallets.service.ts @@ -7,6 +7,7 @@ import { StudentWallet } from '../entities/student-wallet.entity'; import { WalletTransaction } from '../entities/wallet-transaction.entity'; import { In } from 'typeorm'; import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto'; +import { FinancialOperationsService } from '../financial-operations/financial-operations.service'; const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2)); @@ -17,6 +18,7 @@ export class WalletsService { @InjectRepository(WalletTransaction) private transactionRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, private dataSource: DataSource, + private financialOperations?: FinancialOperationsService, ) {} async findAll(query?: { keyword?: string; debtOnly?: boolean }) { @@ -61,6 +63,18 @@ export class WalletsService { } async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) { + const { operationId, ...change } = dto; + return this.financialOperations + ? this.financialOperations.run(operationId, 'wallet.change_balance', () => this.changeBalanceOnce(change, recordedBy, operationId)) + : this.changeBalanceOnce(change, recordedBy, operationId); + } + + private async changeBalanceOnce( + dto: Omit, + recordedBy?: number, + operationId?: string, + transactionManager?: EntityManager, + ) { const amount = money(dto.amount); if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) { throw new BadRequestException('调账金额最多保留两位小数'); @@ -69,8 +83,8 @@ export class WalletsService { if (dto.type === 'recharge' && amount <= 0) throw new BadRequestException('充值金额必须大于 0'); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); if (!student) throw new NotFoundException('学生不存在'); - return this.dataSource.transaction(async (manager) => { - const wallet = await this.getOrCreateWallet(manager, dto.studentId); + const work = async (manager: EntityManager) => { + const wallet = await this.getOrCreateWallet(manager, dto.studentId, true); const nextBalance = money(Number(wallet.balance) + amount); if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0'); wallet.balance = nextBalance; @@ -79,6 +93,7 @@ export class WalletsService { manager.create(WalletTransaction, { studentId: dto.studentId, billId: null, + operationId: operationId ?? null, type: dto.type, amount, balanceAfter: nextBalance, @@ -89,21 +104,28 @@ export class WalletsService { const payments = amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : []; const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId }); return { wallet: finalWallet, payments }; - }); + }; + return transactionManager ? work(transactionManager) : this.dataSource.transaction(work); } async batchChangeBalance(dto: BatchChangeWalletBalanceDto, recordedBy?: number) { - const uniqueStudentIds = Array.from(new Set(dto.studentIds)); - const results: Awaited>[] = []; - for (const studentId of uniqueStudentIds) { - results.push(await this.changeBalance({ - studentId, - amount: dto.amount, - type: dto.type, - description: dto.description, - }, recordedBy)); - } - return { count: uniqueStudentIds.length, results }; + const { operationId, ...batch } = dto; + const work = () => this.dataSource.transaction(async (manager) => { + const uniqueStudentIds = Array.from(new Set(batch.studentIds)); + const results: Array<{ wallet: StudentWallet; payments: Bill[] }> = []; + for (const studentId of uniqueStudentIds) { + results.push(await this.changeBalanceOnce({ + studentId, + amount: batch.amount, + type: batch.type, + description: batch.description, + }, recordedBy, operationId ? `${operationId}:${studentId}` : undefined, manager)); + } + return { count: uniqueStudentIds.length, results }; + }); + return this.financialOperations + ? this.financialOperations.run(operationId, 'wallet.batch_change_balance', work) + : work(); } async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) { @@ -194,9 +216,27 @@ export class WalletsService { return settled; } - private async getOrCreateWallet(manager: EntityManager, studentId: number) { - let wallet = await manager.findOne(StudentWallet, { where: { studentId } }); - if (!wallet) wallet = await manager.save(manager.create(StudentWallet, { studentId, balance: 0 })); + private async getOrCreateWallet(manager: EntityManager, studentId: number, lock = false) { + const find = async () => { + if (!lock || !manager.createQueryBuilder) { + return manager.findOne(StudentWallet, { where: { studentId } }); + } + return manager.createQueryBuilder(StudentWallet, 'wallet') + .where('wallet.studentId = :studentId', { studentId }) + .setLock('pessimistic_write') + .getOne(); + }; + let wallet = await find(); + if (!wallet) { + try { + if (manager.insert) await manager.insert(StudentWallet, { studentId, balance: 0 }); + else wallet = await manager.save(manager.create(StudentWallet, { studentId, balance: 0 })); + } catch { + // A concurrent request may have inserted the one wallet row. + } + wallet ||= await find(); + } + if (!wallet) throw new NotFoundException('学生钱包创建失败'); return wallet; } } From 98d335b8897f190012c3dbe6949b6b942d9acd55 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 18 Jul 2026 15:34:02 +0800 Subject: [PATCH 2/2] feat: redesign teacher attendance workspace --- .../admin/src/pages/Attendance/attendance.css | 615 ++++++++++++++++- apps/admin/src/pages/Attendance/index.tsx | 643 +++++++++++++----- 2 files changed, 1076 insertions(+), 182 deletions(-) diff --git a/apps/admin/src/pages/Attendance/attendance.css b/apps/admin/src/pages/Attendance/attendance.css index f80a2de..03befa0 100644 --- a/apps/admin/src/pages/Attendance/attendance.css +++ b/apps/admin/src/pages/Attendance/attendance.css @@ -66,6 +66,102 @@ opacity: 0.72; } +.teacher-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 14px; + background: #fff; + box-shadow: 0 5px 18px rgb(23 32 51 / 4%); +} + +.teacher-topbar-select { + min-width: 160px; +} + +.teacher-workspace-layout { + display: grid; + grid-template-columns: 168px minmax(0, 1fr); + gap: 16px; +} + +.teacher-filter-rail { + position: sticky; + top: 16px; + align-self: start; + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px; + border: 1px solid var(--line); + border-radius: 14px; + background: #fff; + box-shadow: 0 5px 18px rgb(23 32 51 / 4%); +} + +.teacher-filter-title { + display: flex; + align-items: center; + gap: 7px; + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.teacher-filter-select { + width: 100%; +} + +.teacher-filter-hint { + color: var(--muted); + font-size: 12px; + line-height: 1.6; +} + +.teacher-main-panel { + min-width: 0; +} + +.current-lesson-card { + margin-bottom: 18px; + border: 1px solid #d8e6fb; + border-radius: 14px; + background: linear-gradient(120deg, #f7fbff 0%, #fff 70%); +} + +.current-lesson-card .ant-card-body { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; +} + +.current-lesson-copy > span { + color: var(--muted); + font-size: 12px; +} + +.current-lesson-copy h2 { + margin: 4px 0; + font-size: 22px; +} + +.current-lesson-copy p { + margin: 0; + color: var(--muted); +} + +.current-lesson-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + .teacher-overview { margin-bottom: 30px; } @@ -458,7 +554,8 @@ @media (max-width: 900px) { .attendance-hero, - .archive-toolbar { + .archive-toolbar, + .teacher-topbar { align-items: flex-start; flex-direction: column; } @@ -494,6 +591,23 @@ grid-column: 2 / -1; padding: 14px 0 0; } + + .teacher-workspace-layout { + grid-template-columns: 1fr; + } + + .teacher-filter-rail { + position: static; + } + + .current-lesson-card .ant-card-body { + align-items: flex-start; + flex-direction: column; + } + + .current-lesson-actions { + justify-content: flex-start; + } } @media (max-width: 576px) { @@ -566,3 +680,502 @@ color: #8c8c8c; font-size: 12px; } + +/* Student attendance center — adapted from the provided class overview reference */ +.student-attendance-center { + --student-bg: #f4f7f7; + --student-surface: #ffffff; + --student-soft: #f7faf9; + --student-ink: #17201e; + --student-muted: #66736f; + --student-quiet: #8a9692; + --student-line: #e1e8e5; + --student-primary: #157a65; + --student-primary-soft: #e8f4f0; + min-height: calc(100vh - 64px); + margin: -24px; + padding: 0 24px 28px; + background: var(--student-bg); + color: var(--student-ink); +} + +.student-center-topbar { + position: sticky; + top: 0; + z-index: 12; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + min-height: 60px; + margin: 0 -24px 18px; + padding: 0 24px; + border-bottom: 1px solid var(--student-line); + background: rgb(255 255 255 / 96%); + backdrop-filter: blur(10px); +} + +.student-center-title { + display: flex; + align-items: baseline; + gap: 12px; +} + +.student-center-title h1 { + margin: 0; + font-size: 18px; + letter-spacing: 0; +} + +.student-center-title span, +.student-sync-status { + color: var(--student-quiet); + font-size: 12px; +} + +.student-center-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.student-sync-status { + display: inline-flex; + align-items: center; + gap: 6px; + margin-right: 6px; +} + +.student-sync-status i { + width: 7px; + height: 7px; + border-radius: 50%; + background: #2f9c78; +} + +.student-filter-panel { + display: grid; + grid-template-columns: minmax(260px, 1.4fr) repeat(4, minmax(150px, 1fr)) auto; + gap: 12px; + align-items: end; + margin-bottom: 16px; + padding: 16px; + border: 1px solid var(--student-line); + border-radius: 10px; + background: var(--student-surface); +} + +.student-filter-field { + display: grid; + gap: 6px; + min-width: 0; +} + +.student-filter-field label { + color: var(--student-muted); + font-size: 12px; +} + +.student-filter-field .ant-picker, +.student-filter-field .ant-select { + width: 100%; +} + +.student-filter-actions { + display: flex; + gap: 8px; +} + +.student-class-overview { + display: grid; + grid-template-columns: minmax(320px, .9fr) minmax(0, 1.1fr); + gap: 16px; + margin-bottom: 16px; +} + +.student-class-identity, +.student-metric-strip, +.student-workspace, +.student-record-card { + border: 1px solid var(--student-line); + border-radius: 10px; + background: var(--student-surface); +} + +.student-class-identity { + display: flex; + flex-direction: column; + justify-content: space-between; + min-height: 156px; + padding: 18px; +} + +.student-class-identity h2 { + margin: 0 0 5px; + font-size: 22px; +} + +.student-class-identity span { + color: var(--student-muted); + font-size: 12px; +} + +.student-teacher-list { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 20px; +} + +.student-teacher-item { + display: flex; + align-items: center; + gap: 9px; + min-width: 150px; + padding: 9px; + border-radius: 8px; + background: var(--student-soft); +} + +.student-teacher-item .ant-avatar { + color: var(--student-primary); + background: var(--student-primary-soft); +} + +.student-teacher-item strong, +.student-teacher-item span { + display: block; +} + +.student-teacher-item strong { + margin-top: 2px; + font-size: 13px; +} + +.student-metric-strip { + display: grid; + grid-template-columns: repeat(6, minmax(82px, 1fr)); + overflow: hidden; +} + +.student-metric-card { + appearance: none; + display: grid; + align-content: center; + gap: 6px; + min-height: 156px; + padding: 16px 12px; + border: 0; + border-right: 1px solid var(--student-line); + background: #fff; + color: inherit; + text-align: left; + cursor: pointer; +} + +.student-metric-card:last-child { + border-right: 0; +} + +.student-metric-card:hover, +.student-metric-card.active { + background: var(--student-primary-soft); +} + +.student-metric-icon { + display: inline-grid; + width: max-content; + min-width: 34px; + height: 26px; + place-items: center; + padding: 0 7px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; +} + +.student-metric-card strong { + font-size: 24px; + line-height: 1; +} + +.student-metric-card small { + color: var(--student-muted); +} + +.student-workspace { + margin-bottom: 16px; + padding: 0 18px 20px; +} + +.student-workspace-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + min-height: 70px; + border-bottom: 1px solid var(--student-line); +} + +.student-workspace-header h3 { + margin: 0 0 4px; + font-size: 17px; +} + +.student-workspace-header span { + color: var(--student-muted); + font-size: 12px; +} + +.student-workspace-tools { + display: flex; + align-items: center; + gap: 8px; +} + +.student-workspace-tools .ant-input-search { + width: 240px; +} + +.student-legend { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 0; + color: var(--student-muted); + font-size: 12px; +} + +.student-legend span { + display: inline-flex; + align-items: center; + gap: 6px; + margin-right: 12px; +} + +.student-legend i { + width: 9px; + height: 9px; + border-radius: 2px; +} + +.student-legend i.is-present { background: #2f9c78; } +.student-legend i.is-late { background: #d78a18; } +.student-legend i.is-leave { background: #397bbf; } +.student-legend i.is-absent { background: #d44d4d; } +.student-legend i.is-pending { background: #8a9692; } + +.student-legend em { + color: var(--student-quiet); + font-style: normal; +} + +.student-card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(184px, 1fr)); + gap: 10px; +} + +.student-attendance-card { + appearance: none; + display: grid; + gap: 12px; + min-height: 96px; + padding: 13px; + border: 1px solid var(--student-line); + border-radius: 8px; + background: #fff; + color: inherit; + text-align: left; + cursor: pointer; + transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease; +} + +.student-attendance-card:hover, +.student-attendance-card.selected { + transform: translateY(-1px); + border-color: #91c8b9; + box-shadow: 0 8px 20px rgb(24 44 38 / 8%); +} + +.student-attendance-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.student-attendance-head strong { + font-size: 15px; +} + +.student-attendance-head small { + color: var(--student-quiet); +} + +.student-status-blocks { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 6px; +} + +.student-status-block { + display: grid; + min-height: 28px; + place-items: center; + border-radius: 5px; + font-size: 12px; + font-weight: 700; +} + +.student-record-card { + overflow: hidden; + margin-bottom: 16px; +} + +.student-record-card .ant-card-body { + padding: 0 18px 14px; +} + +.student-detail-panel { + display: grid; + gap: 18px; +} + +.student-detail-profile { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 12px; + align-items: center; + padding-bottom: 18px; + border-bottom: 1px solid var(--student-line); +} + +.student-detail-profile h4 { + margin: 0 0 4px; + font-size: 18px; +} + +.student-detail-profile p, +.student-detail-rate span { + margin: 0; + color: var(--student-muted); + font-size: 12px; +} + +.student-detail-rate { + text-align: right; +} + +.student-detail-rate strong { + display: block; + color: var(--student-primary); + font-size: 24px; +} + +.student-detail-rates { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 8px; +} + +.student-detail-rates > div { + padding: 12px 8px; + border-radius: 8px; + background: var(--student-soft); + text-align: center; +} + +.student-detail-rates strong, +.student-detail-rates span { + display: block; +} + +.student-detail-rates span { + margin-top: 4px; + color: var(--student-muted); + font-size: 12px; +} + +.student-detail-section h5 { + margin: 0 0 10px; + font-size: 14px; +} + +.student-detail-timeline { + display: grid; + gap: 8px; +} + +.student-detail-timeline > div { + display: grid; + grid-template-columns: 64px 96px 1fr auto; + gap: 8px; + align-items: center; + padding: 10px; + border: 1px solid var(--student-line); + border-radius: 8px; +} + +.student-detail-timeline > div > span:first-child { + color: var(--student-muted); +} + +.student-trend-bars { + display: flex; + align-items: end; + gap: 8px; + height: 110px; + padding: 12px; + border-radius: 8px; + background: var(--student-soft); +} + +.student-trend-bars i { + flex: 1; + min-height: 18px; + border-radius: 5px 5px 0 0; + background: linear-gradient(180deg, #56bea3, #157a65); +} + +@media (max-width: 1180px) { + .student-filter-panel, + .student-class-overview { + grid-template-columns: 1fr 1fr; + } + + .student-filter-actions { + grid-column: 1 / -1; + justify-content: flex-end; + } + + .student-metric-strip { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 760px) { + .student-attendance-center { + margin: -16px; + padding: 0 16px 22px; + } + + .student-center-topbar, + .student-workspace-header, + .student-center-actions, + .student-legend { + align-items: flex-start; + flex-direction: column; + } + + .student-filter-panel, + .student-class-overview { + grid-template-columns: 1fr; + } + + .student-workspace-tools, + .student-workspace-tools .ant-input-search { + width: 100%; + } +} diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index 3c43313..a047553 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -27,7 +27,9 @@ import { ClockCircleOutlined, EditOutlined, ExportOutlined, + CalendarOutlined, FileSearchOutlined, + FilterOutlined, ReloadOutlined, ScheduleOutlined, TeamOutlined, @@ -183,45 +185,6 @@ function AttendanceStatusTag({ status }: { status: string }) { ); } -function SummaryStrip({ summary }: { summary: AttendanceSummary }) { - const rate = summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0; - return ( -
-
- -
- 出勤率 - {summary.total} 条记录 -
-
- {[ - ['present', summary.present], - ['late', summary.late], - ['absent', summary.absent], - ['leave', summary.leave], - ].map(([status, value]) => { - const meta = STATUS_META[String(status)]; - return ( -
- {meta.short} -
- {value} - {meta.label} -
-
- ); - })} -
- ); -} - function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRecordItem[] }) { const summary = summarizeLessonCheckins(records); const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0; @@ -265,6 +228,8 @@ const TeacherAttendanceWorkspace: React.FC = () => { const [drawerOpen, setDrawerOpen] = useState(false); const [studentKeyword, setStudentKeyword] = useState(''); const [checkinFilter, setCheckinFilter] = useState('all'); + const [selectedClassId, setSelectedClassId] = useState('all'); + const [phaseFilter, setPhaseFilter] = useState('all'); const loadWorkspace = useCallback(async () => { setLoading(true); @@ -286,6 +251,17 @@ const TeacherAttendanceWorkspace: React.FC = () => { [workspace], ); + const classFilterOptions = useMemo( + () => [ + { value: 'all' as const, label: '全部教学班' }, + ...(workspace?.assignedClasses.map((item) => ({ + value: item.classId, + label: item.className, + })) ?? []), + ], + [workspace], + ); + const openAttendance = useCallback(async (schedule: TodaySchedule) => { setStudentKeyword(''); @@ -324,7 +300,7 @@ const TeacherAttendanceWorkspace: React.FC = () => { setLessonRecords((items) => items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)), ); - message.error((error as { message?: string })?.message || '更新考勤失败'); + message.error((error as { message?: string })?.message || '更新课堂考勤失败'); } }, [], @@ -339,6 +315,16 @@ const TeacherAttendanceWorkspace: React.FC = () => { const nextSchedule = schedules.find( (item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended', ); + const filteredSchedules = schedules.filter((item) => { + const phase = getSchedulePhase(item.startTime, item.endTime, now); + const matchesClass = selectedClassId === 'all' || item.classId === selectedClassId; + const matchesPhase = phaseFilter === 'all' || phase === phaseFilter; + return matchesClass && matchesPhase; + }); + const currentFocusSchedule = nextSchedule ?? schedules.at(-1) ?? null; + const currentFocusClassName = currentFocusSchedule + ? classNameById.get(currentFocusSchedule.classId) || `班级 ${currentFocusSchedule.classId}` + : '暂无教学班'; const isAttendanceCompleted = lessonSession?.status === 'completed'; const filteredLessonRecords = useMemo( () => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter), @@ -347,72 +333,135 @@ const TeacherAttendanceWorkspace: React.FC = () => { return (
+
+ + + + value={selectedClassId} + onChange={setSelectedClassId} + options={classFilterOptions} + className="teacher-topbar-select" + /> + + + + + + +
+
- TEACHING DAY · {dayjs().format('MM月DD日 dddd')} -

今天,从课程开始

-

课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。

+ LESSON ATTENDANCE · {dayjs().format('MM月DD日 dddd')} +

任课老师课堂考勤

+

围绕“我的课程、本节课、教学班级”查看学生签到,课程开始后可拉取最新结果并处理未签到学生。

-
-
今日课程{schedules.length}
+
今日课程{schedules.length}节待查看
-
已开始{startedCount}节,可查看考勤
+
已开始课程{startedCount}节,可处理课堂考勤
-
下一节{nextSchedule ? nextSchedule.startTime : '—'}{nextSchedule?.subject || '今天没有更多课程'}
+
当前/下一节{nextSchedule ? nextSchedule.startTime : '—'}{nextSchedule?.subject || '今天没有更多课程'}
-
-
今日教学节奏

我的课程

- 课程开始后可拉取钉钉考勤记录 -
+
+ - - {schedules.length === 0 ? ( - - 今天还没有课程

请联系教务管理员安排课程。

} - /> +
+ +
+ 主页主功能区 +

{currentFocusSchedule?.subject || '暂无待处理课程'}

+

+ {currentFocusSchedule + ? `${currentFocusClassName} · ${currentFocusSchedule.startTime}-${currentFocusSchedule.endTime} · 教室 ${currentFocusSchedule.classroomId}` + : '今天没有课程,可切换日期查看其他课堂考勤。'} +

+
+
+ + +
- ) : ( -
- {schedules.map((schedule, index) => { - const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now); - return ( - void openAttendance(schedule)} - /> - ); - })} + +
+
课堂考勤列表

我的课程

+ 课程开始后可查看本节课学生签到和异常
- )} - + + + {schedules.length === 0 ? ( + + 今天还没有课程

请联系教务管理员安排课程。

} + /> + + ) : filteredSchedules.length === 0 ? ( + + 没有符合筛选条件的课程

请调整左侧教学班级或课程状态。

} + /> + + ) : ( +
+ {filteredSchedules.map((schedule, index) => { + const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now); + return ( + void openAttendance(schedule)} + /> + ); + })} +
+ )} + + + setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
LESSON ATTENDANCE -

{selectedSchedule?.subject || '课程考勤'}

-

{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}

+

{selectedSchedule?.subject || '本节课考勤'}

+

{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · 本节课 {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}

{lessonSession && ( )} @@ -430,8 +479,8 @@ const TeacherAttendanceWorkspace: React.FC = () => { onChange={setCheckinFilter} options={[ { value: 'all', label: '全部学生' }, - { value: 'checked_in', label: '已打卡' }, - { value: 'not_checked_in', label: '未打卡' }, + { value: 'checked_in', label: '已到学生' }, + { value: 'not_checked_in', label: '未签到/旷课' }, ]} className="lesson-record-filter-select" /> @@ -447,7 +496,7 @@ const TeacherAttendanceWorkspace: React.FC = () => { locale={{ emptyText: , }} columns={[ @@ -456,7 +505,7 @@ const TeacherAttendanceWorkspace: React.FC = () => { render: (name: string) =>
{name?.slice(0, 1)}{name || '-'}
, }, { - title: '考勤结果', dataIndex: 'status', width: 230, + title: '课堂考勤操作', dataIndex: 'status', width: 250, render: (value: string, record: AttendanceRecordItem) => { const checkedIn = value === 'present' || value === 'late'; return ( @@ -466,7 +515,7 @@ const TeacherAttendanceWorkspace: React.FC = () => { type={checkedIn ? 'primary' : 'default'} onClick={() => void updateLessonRecord(record, 'present')} > - 已打卡 + 标记已到 ); }, }, - { title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => }, + { title: '当前状态', dataIndex: 'status', width: 118, render: (value: string) => }, { - title: '打卡设备', + title: '签到来源', width: 220, render: (_: unknown, record: AttendanceRecordItem) => { const info = getPunchDisplayInfo(record); @@ -496,7 +545,7 @@ const TeacherAttendanceWorkspace: React.FC = () => { ); }, }, - { title: '备注', dataIndex: 'remark', render: (value: string | null) => value || }, + { title: '备注/异常处理', dataIndex: 'remark', render: (value: string | null) => value || 暂无备注 }, ]} />
@@ -530,7 +579,7 @@ const LessonCard: React.FC<{ ) : ( )} @@ -539,6 +588,71 @@ const LessonCard: React.FC<{ }; +interface AdminStudentPanel { + key: string; + studentId: number; + studentName: string; + className: string; + records: AttendanceRecordItem[]; + statusBySession: Partial>; + primaryStatus: string; + rate: number; + latestDate: string; +} + +const ADMIN_PERIODS = [ + { key: 'morning_reading', label: '早读' }, + { key: 'morning', label: '上午' }, + { key: 'afternoon', label: '下午' }, + { key: 'evening_study', label: '晚自习' }, +]; + +const ADMIN_METRIC_META = [ + { key: 'all', label: '出勤率', short: '率' }, + { key: 'present', label: '正常', short: '正常' }, + { key: 'late', label: '迟到', short: '迟到' }, + { key: 'leave', label: '请假', short: '请假' }, + { key: 'absent', label: '缺勤', short: '缺勤' }, + { key: 'pending', label: '未打卡', short: '待核' }, +]; + +function pickPrimaryStatus(records: AttendanceRecordItem[]) { + const priority = ['absent', 'late', 'leave', 'pending', 'present']; + return priority.find((item) => records.some((record) => record.status === item)) || 'pending'; +} + +function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] { + const map = new Map(); + for (const record of records) { + const current = map.get(record.studentId) ?? { + key: String(record.studentId), + studentId: record.studentId, + studentName: record.student?.name || '未知学生', + className: record.class?.name || '未关联班级', + records: [], + statusBySession: {}, + primaryStatus: 'pending', + rate: 0, + latestDate: record.attendanceDate, + }; + current.records.push(record); + if (!current.statusBySession[record.session]) current.statusBySession[record.session] = record; + if (dayjs(record.attendanceDate).isAfter(dayjs(current.latestDate))) { + current.latestDate = record.attendanceDate; + } + map.set(record.studentId, current); + } + + return Array.from(map.values()).map((item) => { + const checked = item.records.filter((record) => record.status === 'present' || record.status === 'late').length; + return { + ...item, + primaryStatus: pickPrimaryStatus(item.records), + rate: item.records.length > 0 ? Math.round((checked / item.records.length) * 100) : 0, + }; + }); +} + const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => { const [records, setRecords] = useState([]); const [summary, setSummary] = useState(EMPTY_SUMMARY); @@ -546,15 +660,15 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => const [classOptions, setClassOptions] = useState([]); const [loading, setLoading] = useState(true); const [page, setPage] = useState(1); - const [pageSize, setPageSize] = useState(20); + const [pageSize, setPageSize] = useState(48); const [total, setTotal] = useState(0); const [classId, setClassId] = useState(); - const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>([ - dayjs().subtract(30, 'day'), - dayjs(), - ]); + const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>([dayjs(), dayjs()]); const [status, setStatus] = useState(); const [session, setSession] = useState(); + const [metricFilter, setMetricFilter] = useState('all'); + const [studentSearch, setStudentSearch] = useState(''); + const [selectedStudent, setSelectedStudent] = useState(null); const [editRecord, setEditRecord] = useState(null); const [editForm] = Form.useForm(); @@ -587,7 +701,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => setTotal(recordData.total); setSummary({ ...EMPTY_SUMMARY, ...summaryData }); } catch (error: unknown) { - message.error((error as { message?: string })?.message || '加载历史考勤失败'); + message.error((error as { message?: string })?.message || '加载学生考勤失败'); } finally { setLoading(false); } @@ -606,9 +720,11 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => const resetFilters = () => { setClassId(undefined); - setDateRange([dayjs().subtract(30, 'day'), dayjs()]); + setDateRange([dayjs(), dayjs()]); setStatus(undefined); setSession(undefined); + setMetricFilter('all'); + setStudentSearch(''); setPage(1); }; @@ -627,7 +743,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => const url = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = url; - anchor.download = `历史考勤-${dayjs().format('YYYYMMDD')}.xlsx`; + anchor.download = `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`; anchor.click(); URL.revokeObjectURL(url); }) @@ -648,6 +764,28 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => } }; + const studentPanels = useMemo(() => buildAdminStudentPanels(records), [records]); + const visibleStudents = useMemo(() => { + const query = studentSearch.trim().toLocaleLowerCase('zh-CN'); + return studentPanels.filter((student) => { + const matchesQuery = + !query || + student.studentName.toLocaleLowerCase('zh-CN').includes(query) || + String(student.studentId).includes(query); + const matchesMetric = + metricFilter === 'all' || student.records.some((record) => record.status === metricFilter); + return matchesQuery && matchesMetric; + }); + }, [metricFilter, studentPanels, studentSearch]); + + const selectedClass = classId + ? classOptions.find((item) => item.classId === classId)?.className || `班级 ${classId}` + : '全部班级'; + const attendanceRate = summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0; + const dateLabel = dateRange?.[0]?.isSame(dateRange?.[1], 'day') + ? dateRange?.[0]?.format('YYYY-MM-DD') + : `${dateRange?.[0]?.format('YYYY-MM-DD') || '开始日期'} 至 ${dateRange?.[1]?.format('YYYY-MM-DD') || '结束日期'}`; + const columns: ColumnsType = [ { title: '学生', @@ -672,19 +810,13 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => render: (value: string) => SESSION_MAP[value] || value, }, { - title: '结果', + title: '状态', dataIndex: 'status', - width: 110, + width: 105, render: (value: string) => , }, { - title: '记录来源', - dataIndex: 'source', - width: 110, - render: (value: string) => (value === 'dingtalk' ? '钉钉同步' : value === 'schedule' ? '课程生成' : value === 'lesson' ? '课堂点名' : '人工记录'), - }, - { - title: '打卡设备', + title: '签到来源', width: 220, render: (_: unknown, record: AttendanceRecordItem) => { const info = getPunchDisplayInfo(record); @@ -704,12 +836,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => ellipsis: true, render: (value: string | null) => value || , }, - { - title: '归档时间', - dataIndex: 'createdAt', - width: 165, - render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm'), - }, ...(canEdit ? [ { @@ -735,24 +861,109 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => ]; return ( -
-
-
- ATTENDANCE ARCHIVE -

历史考勤档案

-

面向管理人员的历史检索、异常追踪与数据归档,不承载实时上课操作。

+
+
+
+

学生考勤中心

+ 班级考勤总览 +
+
+ 数据已更新 {dayjs().format('HH:mm')} + + } onClick={handleExport}> + 导出当前报表 + +
+
+ +
+
+ + { + setDateRange(value as [Dayjs, Dayjs] | null); + setPage(1); + }} + /> +
+
+ + +
+
+ + { + setSession(value); + setPage(1); + }} + options={SESSION_OPTIONS} + /> +
+
+ +
- } - size="large" - onClick={handleExport} - > - 导出当前结果 -
- +
+
+
+

{selectedClass}

+ {dateLabel} · 当前展示 {visibleStudents.length} 名学生 / {total} 条记录 +
+
+
班主任按班级筛选后查看
+
生活老师暂未接入
+
当前任课{session ? SESSION_MAP[session] : '全部时段'}
+
+
+
+ {ADMIN_METRIC_META.map((metric) => { + const value = metric.key === 'all' + ? `${attendanceRate}%` + : summary[metric.key as keyof AttendanceSummary] ?? 0; + const meta = STATUS_META[metric.key] ?? { className: 'is-present' }; + return ( + + ); + })} +
+
{alerts.length > 0 && (
@@ -767,70 +978,76 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
)} - -
-
- -
- 档案检索 - 默认查看最近 30 天 -
+
+
+
+

班级学生考勤

+ {metricFilter === 'all' ? `显示全部 ${visibleStudents.length} 名学生` : `筛出 ${ADMIN_METRIC_META.find((item) => item.key === metricFilter)?.label || ''}相关 ${visibleStudents.length} 名学生`}
- - { - setStatus(value); - setPage(1); - }} - options={STATUS_OPTIONS} - style={{ width: 130 }} - /> -