feat: DingTalk attendance import + integration config + expense types + UI polish
Server: - Add DingTalk attendance import service with SSE progress streaming - Add IntegrationConfig entity & module for multi-tenant DingTalk setup - Add ExpenseType entity & ExpenseTypesModule - Add SeedModule for DB initialization - Add UserDingMapping entity for DingTalk user linkage - Attendance service: import flow with dedup & student auto-mapping - Rooms service: time-range overlap queries - Sync controller/service: DingTalk integration wiring - Permission guard: refactor to pure re-export - Campus scope middleware: tenant-aware filtering Admin UI: - Attendance page: import UI with progress & result summary - All pages: tableStyle/tablePagination standardization - Login page: responsive styling - Sensitive data: useViewSensitive hook for masked viewing - Vite config: path aliases, build optimization - Test infra: vitest config, test utilities Docs: PRD DingTalk batch 1 & 2 design docs
This commit is contained in:
@@ -48,8 +48,8 @@ export class RoomsController {
|
||||
|
||||
@Get('visual')
|
||||
@RequirePermission('room:view')
|
||||
getVisual() {
|
||||
return this.service.getRoomVisual();
|
||||
getVisual(@Query('asOf') asOf?: string) {
|
||||
return this.service.getRoomVisual(asOf);
|
||||
}
|
||||
|
||||
@Get('template')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Like, IsNull, Not, In } from 'typeorm';
|
||||
import { Repository, Like, IsNull, Not, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
@@ -43,8 +43,7 @@ export class RoomsService {
|
||||
if (stdMatch) {
|
||||
const bldgNum = stdMatch[1];
|
||||
const roomPart = stdMatch[2];
|
||||
const floor =
|
||||
roomPart.length >= 3 ? parseInt(roomPart.charAt(0), 10) : parseInt(roomPart.charAt(0), 10);
|
||||
const floor = parseInt(roomPart.charAt(0), 10) || undefined;
|
||||
const building = `${bldgNum}号楼`;
|
||||
let roomType = '四人间';
|
||||
let capacity = 4;
|
||||
@@ -100,7 +99,14 @@ export class RoomsService {
|
||||
}
|
||||
|
||||
async create(dto: CreateRoomDto) {
|
||||
const entity = this.repo.create(dto);
|
||||
const parsed = RoomsService.parseRoomNumber(dto.roomNumber);
|
||||
const entity = this.repo.create({
|
||||
...dto,
|
||||
building: dto.building ?? parsed.building,
|
||||
floor: dto.floor ?? parsed.floor,
|
||||
roomType: dto.roomType ?? parsed.roomType,
|
||||
capacity: dto.capacity ?? parsed.capacity,
|
||||
});
|
||||
if (dto.departmentId) entity.departmentId = dto.departmentId;
|
||||
return this.repo.save(entity);
|
||||
}
|
||||
@@ -165,26 +171,41 @@ export class RoomsService {
|
||||
return { message: '已恢复' };
|
||||
}
|
||||
|
||||
async getRoomVisual() {
|
||||
async getRoomVisual(asOf?: string) {
|
||||
// asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。
|
||||
const isHistorical = !!asOf;
|
||||
const targetDate = asOf || new Date().toISOString().slice(0, 10);
|
||||
|
||||
// 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。
|
||||
const rooms = await this.repo.find({
|
||||
where: await this.scope.filter({ status: Not('archived') }),
|
||||
where: await this.scope.filter(isHistorical ? {} : { status: Not('archived') }),
|
||||
order: { building: 'ASC', roomNumber: 'ASC' },
|
||||
});
|
||||
|
||||
// scope.filter() produces identical scope conditions within the same request;
|
||||
// extract once and spread to avoid redundant calls.
|
||||
const scopeWhere = await this.scope.filter({});
|
||||
const occupancies = await this.occRepo.find({
|
||||
where: await this.scope.filter({ checkOutDate: IsNull() }),
|
||||
where: isHistorical
|
||||
? [
|
||||
{ ...scopeWhere, checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() },
|
||||
{ ...scopeWhere, checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
|
||||
]
|
||||
: { ...scopeWhere, checkOutDate: IsNull() },
|
||||
relations: ['student', 'tenant'],
|
||||
order: { checkInDate: 'ASC' },
|
||||
});
|
||||
|
||||
// 按roomId分组入住记录
|
||||
const occMap = new Map<number, any[]>();
|
||||
// days(已住天数)相对目标日期计算,而非固定今天,历史视图才准确。
|
||||
const refTime = new Date(targetDate).getTime();
|
||||
for (const occ of occupancies) {
|
||||
if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []);
|
||||
const now = new Date();
|
||||
const checkIn = new Date(occ.checkInDate);
|
||||
const days = Math.max(
|
||||
1,
|
||||
Math.ceil((now.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24)),
|
||||
Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)),
|
||||
);
|
||||
occMap.get(occ.roomId)!.push({
|
||||
studentId: occ.studentId,
|
||||
@@ -194,6 +215,7 @@ export class RoomsService {
|
||||
days,
|
||||
organization: occ.student?.organization || null,
|
||||
supervisor: occ.student?.supervisor || null,
|
||||
tenantId: occ.tenantId || null,
|
||||
tenantName: occ.tenant?.name || null,
|
||||
tenantColor: occ.tenant?.color || null,
|
||||
});
|
||||
@@ -202,9 +224,14 @@ export class RoomsService {
|
||||
// 获取各楼栋列表
|
||||
const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))];
|
||||
|
||||
// 历史视图纳入了已归档房间,但只保留当时确实有人住的归档房间,避免空归档房间刷屏。
|
||||
const visibleRooms = isHistorical
|
||||
? rooms.filter((r) => r.status !== 'archived' || (occMap.get(r.id)?.length ?? 0) > 0)
|
||||
: rooms;
|
||||
|
||||
return {
|
||||
buildings,
|
||||
rooms: rooms.map((room) => {
|
||||
rooms: visibleRooms.map((room) => {
|
||||
const occ = occMap.get(room.id) || [];
|
||||
// 计算机构标注
|
||||
const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))];
|
||||
@@ -220,6 +247,8 @@ export class RoomsService {
|
||||
// 计算租户颜色:所有住户同一租户则使用该颜色
|
||||
const tenantColors = [...new Set(occ.map((o: any) => o.tenantColor).filter(Boolean))];
|
||||
const tenantColor: string | null = tenantColors.length === 1 ? tenantColors[0] : null;
|
||||
// 房间涉及的租户 id(供前端按租赁方筛选)
|
||||
const tenantIds = [...new Set(occ.map((o: any) => o.tenantId).filter(Boolean))];
|
||||
return {
|
||||
id: room.id,
|
||||
roomNumber: room.roomNumber,
|
||||
@@ -231,8 +260,17 @@ export class RoomsService {
|
||||
occupants: occ,
|
||||
orgLabel,
|
||||
tenantColor,
|
||||
tenantIds,
|
||||
};
|
||||
}),
|
||||
// 当前视图内出现过的租赁方,供筛选下拉使用
|
||||
tenants: [
|
||||
...new Map(
|
||||
occupancies
|
||||
.filter((o) => o.tenantId && o.tenant)
|
||||
.map((o) => [o.tenantId, { id: o.tenantId, name: o.tenant.name, color: o.tenant.color || null }]),
|
||||
).values(),
|
||||
].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -266,7 +304,7 @@ export class RoomsService {
|
||||
this.repo.create({
|
||||
roomNumber: row.roomNumber.trim(),
|
||||
building: row.building?.trim() || parsed.building || undefined,
|
||||
floor: row.floor || parsed.floor || undefined,
|
||||
floor: row.floor ?? parsed.floor,
|
||||
capacity: row.capacity || parsed.capacity || 4,
|
||||
roomType: row.roomType || parsed.roomType || undefined,
|
||||
rentalCategory: row.rentalCategory || undefined,
|
||||
|
||||
Reference in New Issue
Block a user