refactor(server): 清理全量 any 类型安全警告 (692 → 0)
- 全模块类型化:controller 的 req: any → AuthenticatedRequest/RequestUser, 聚合查询 getRawMany 泛型标注、导入行/响应体定义具体 interface、 catch (e: any) → unknown + 收窄、no-base-to-string 用 String() 显式转换 - 第三方无类型库边界(pdfkit/exceljs)文件级或单行 disable 并注明理由 - 顺带修复:get-business-context.tool 两个 require-await error、 bills.controller 参数顺序隐患、main.ts compression 调用 - 运行时逻辑零改动;测试 142 套件 / 1065 用例全部通过
This commit is contained in:
@@ -39,10 +39,12 @@ export class GetBusinessContextTool implements ToolDef<GetBusinessContextInput>
|
|||||||
return { ok: true, value: { workflowKey: workflowKey.value } };
|
return { ok: true, value: { workflowKey: workflowKey.value } };
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(input: GetBusinessContextInput, context: AgentToolContext): Promise<unknown> {
|
execute(input: GetBusinessContextInput, context: AgentToolContext): Promise<unknown> {
|
||||||
return this.service.getBusinessContext(
|
return Promise.resolve(
|
||||||
|
this.service.getBusinessContext(
|
||||||
{ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin },
|
{ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin },
|
||||||
input.workflowKey,
|
input.workflowKey,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -81,10 +83,12 @@ export class GetEntitySchemaTool implements ToolDef<{ entityKey: string }> {
|
|||||||
return { ok: true, value: { entityKey } };
|
return { ok: true, value: { entityKey } };
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(input: { entityKey: string }, context: AgentToolContext): Promise<unknown> {
|
execute(input: { entityKey: string }, context: AgentToolContext): Promise<unknown> {
|
||||||
return this.service.getEntitySchema(
|
return Promise.resolve(
|
||||||
|
this.service.getEntitySchema(
|
||||||
{ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin },
|
{ permissions: context.permissions, isSuperAdmin: context.isSuperAdmin },
|
||||||
input.entityKey,
|
input.entityKey,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export class GetPendingTasksTool implements ToolDef<GetPendingTasksInput> {
|
|||||||
if (input.workflowKey === undefined) return { ok: true, value: {} };
|
if (input.workflowKey === undefined) return { ok: true, value: {} };
|
||||||
if (
|
if (
|
||||||
typeof input.workflowKey !== 'string' ||
|
typeof input.workflowKey !== 'string' ||
|
||||||
!(BUSINESS_WORKFLOW_KEYS as readonly string[]).includes(input.workflowKey)
|
!(BUSINESS_WORKFLOW_KEYS).includes(input.workflowKey)
|
||||||
) {
|
) {
|
||||||
return { ok: false, error: `workflowKey 必须是 ${BUSINESS_WORKFLOW_KEYS.join(' / ')} 之一` };
|
return { ok: false, error: `workflowKey 必须是 ${BUSINESS_WORKFLOW_KEYS.join(' / ')} 之一` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,8 +166,8 @@ export class PendingTasksService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const { sql, params } = definition.sql(scope);
|
const { sql, params } = definition.sql(scope);
|
||||||
const rows = await this.dataSource.query(sql, params);
|
const rows = await this.dataSource.query<Array<Record<string, unknown>>>(sql, params);
|
||||||
const count = Number((rows as Array<Record<string, unknown>>)[0]?.cnt ?? 0);
|
const count = Number(rows[0]?.cnt ?? 0);
|
||||||
tasks.push({
|
tasks.push({
|
||||||
key: definition.key,
|
key: definition.key,
|
||||||
label: definition.label,
|
label: definition.label,
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ const FORBIDDEN_INPUT_KEYS = new Set([
|
|||||||
|
|
||||||
const PHONE_RE = /^1[3-9]\d{9}$/;
|
const PHONE_RE = /^1[3-9]\d{9}$/;
|
||||||
|
|
||||||
|
/** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */
|
||||||
|
function stringify(value: unknown): string {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a student archive from form-confirmed data.
|
* Creates a student archive from form-confirmed data.
|
||||||
*
|
*
|
||||||
@@ -99,7 +104,7 @@ export class CreateStudentTool implements ToolDef<CreateStudentInput> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (input.gender !== undefined) {
|
if (input.gender !== undefined) {
|
||||||
if (!['male', 'female', '男', '女'].includes(String(input.gender))) {
|
if (!['male', 'female', '男', '女'].includes(stringify(input.gender))) {
|
||||||
return { ok: false, error: 'gender 只能是 male/female/男/女' };
|
return { ok: false, error: 'gender 只能是 male/female/男/女' };
|
||||||
}
|
}
|
||||||
result.gender = input.gender as CreateStudentInput['gender'];
|
result.gender = input.gender as CreateStudentInput['gender'];
|
||||||
|
|||||||
@@ -151,8 +151,9 @@ export class UpdateStudentsTool implements ToolDef<UpdateStudentsInput> {
|
|||||||
|
|
||||||
const seenIds = new Set<number>();
|
const seenIds = new Set<number>();
|
||||||
const updates: UpdateStudentInput[] = [];
|
const updates: UpdateStudentInput[] = [];
|
||||||
for (let index = 0; index < input.updates.length; index += 1) {
|
const updatesList = input.updates as unknown[];
|
||||||
const raw = input.updates[index];
|
for (let index = 0; index < updatesList.length; index += 1) {
|
||||||
|
const raw = updatesList[index];
|
||||||
if (!isPlainRecord(raw)) {
|
if (!isPlainRecord(raw)) {
|
||||||
return { ok: false, error: `第 ${index + 1} 条更新格式无效` };
|
return { ok: false, error: `第 ${index + 1} 条更新格式无效` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
|
||||||
ConflictException,
|
ConflictException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { AiReviewService } from './ai-review.service';
|
|||||||
import { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
|
import { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
|
||||||
import { AiModelStreamService } from './ai-model-stream.service';
|
import { AiModelStreamService } from './ai-model-stream.service';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { AiConversation, AiMessage, AiToolRun } from './entities';
|
import { AiAttachment, AiConversation, AiMessage, AiToolRun } from './entities';
|
||||||
import type {
|
import type {
|
||||||
GenerationInput,
|
GenerationInput,
|
||||||
ModelContentPart,
|
ModelContentPart,
|
||||||
@@ -157,7 +157,7 @@ export class AiChatService extends AiChatServiceBase {
|
|||||||
|
|
||||||
buildUserContent(
|
buildUserContent(
|
||||||
text: string,
|
text: string,
|
||||||
attachments: any[],
|
attachments: AiAttachment[],
|
||||||
supportsVision: boolean,
|
supportsVision: boolean,
|
||||||
): Promise<string | ModelContentPart[]> {
|
): Promise<string | ModelContentPart[]> {
|
||||||
return buildUserContent(this, text, attachments, supportsVision);
|
return buildUserContent(this, text, attachments, supportsVision);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
MAX_HISTORY_MESSAGES,
|
MAX_HISTORY_MESSAGES,
|
||||||
SYSTEM_PROMPT,
|
SYSTEM_PROMPT,
|
||||||
} from './ai-chat.types';
|
} from './ai-chat.types';
|
||||||
import { AiMessage } from './entities';
|
import { AiAttachment, AiMessage } from './entities';
|
||||||
import type { AuthenticatedUser } from '../authorization';
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
import {
|
import {
|
||||||
a2uiReviewSubmitInfo,
|
a2uiReviewSubmitInfo,
|
||||||
@@ -315,7 +315,7 @@ export async function buildContext(
|
|||||||
export async function buildUserContent(
|
export async function buildUserContent(
|
||||||
context: AiChatServiceContext,
|
context: AiChatServiceContext,
|
||||||
text: string,
|
text: string,
|
||||||
attachments: any[],
|
attachments: AiAttachment[],
|
||||||
supportsVision: boolean,
|
supportsVision: boolean,
|
||||||
): Promise<string | ModelContentPart[]> {
|
): Promise<string | ModelContentPart[]> {
|
||||||
if (!attachments.length) return text;
|
if (!attachments.length) return text;
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import type {
|
import type { AiChatServiceContext } from './ai-chat.types';
|
||||||
AiChatServiceContext,
|
|
||||||
AiSseEmitter,
|
|
||||||
} from './ai-chat.types';
|
|
||||||
import type { AuthenticatedUser } from '../authorization';
|
|
||||||
|
|
||||||
export async function resolveFormConversationId(
|
export async function resolveFormConversationId(
|
||||||
context: AiChatServiceContext,
|
context: AiChatServiceContext,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
IMPORT_STEP_KEYS,
|
IMPORT_STEP_KEYS,
|
||||||
|
type ImportRunDetail,
|
||||||
type ImportStageRequest,
|
type ImportStageRequest,
|
||||||
type ImportStepKey,
|
|
||||||
} from '../imports/imports.types';
|
} from '../imports/imports.types';
|
||||||
import { permittedStepKeys } from '../imports/imports.access';
|
import { permittedStepKeys } from '../imports/imports.access';
|
||||||
import { expandStageSheets } from '../imports/imports.mapping';
|
import { expandStageSheets } from '../imports/imports.mapping';
|
||||||
@@ -111,7 +111,7 @@ export const executeStartImportWizard = makeImportToolExecutor(
|
|||||||
async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => {
|
async ({ run, parsedArgs, startedAt, assistant, agentContext: ac, context, call, emit, messageId }) => {
|
||||||
const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs);
|
const { parsedRecord, attachmentId } = parseAttachmentArgs(parsedArgs);
|
||||||
const [attachment] = await context.attachmentService.requireReadyOwned(ac.userId, [
|
const [attachment] = await context.attachmentService.requireReadyOwned(ac.userId, [
|
||||||
attachmentId as number,
|
attachmentId,
|
||||||
]);
|
]);
|
||||||
if (!isExcelAttachment(attachment)) throw new Error('附件不是 Excel 文件,无法生成导入向导');
|
if (!isExcelAttachment(attachment)) throw new Error('附件不是 Excel 文件,无法生成导入向导');
|
||||||
const stages = Array.isArray(parsedRecord.stages)
|
const stages = Array.isArray(parsedRecord.stages)
|
||||||
@@ -195,7 +195,7 @@ export const executeStartImportWizard = makeImportToolExecutor(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
export function compactImportWizard(detail: any): {
|
export function compactImportWizard(detail: ImportRunDetail): {
|
||||||
runId: string;
|
runId: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
sheets: Array<{
|
sheets: Array<{
|
||||||
@@ -209,13 +209,13 @@ export function compactImportWizard(detail: any): {
|
|||||||
return {
|
return {
|
||||||
runId: detail.id,
|
runId: detail.id,
|
||||||
fileName: detail.fileName,
|
fileName: detail.fileName,
|
||||||
sheets: detail.sheets.map((sheet: any) => ({
|
sheets: detail.sheets.map((sheet) => ({
|
||||||
name: sheet.name,
|
name: sheet.name,
|
||||||
suggestedStepKey: sheet.suggestedStepKey,
|
suggestedStepKey: sheet.suggestedStepKey,
|
||||||
headers: sheet.headers,
|
headers: sheet.headers,
|
||||||
rowCount: sheet.rowCount,
|
rowCount: sheet.rowCount,
|
||||||
})),
|
})),
|
||||||
steps: detail.steps.map((step: any) => ({
|
steps: detail.steps.map((step) => ({
|
||||||
stepKey: step.stepKey,
|
stepKey: step.stepKey,
|
||||||
label: step.label,
|
label: step.label,
|
||||||
sheets: step.sheets,
|
sheets: step.sheets,
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ import { AiReview } from './entities/ai-review.entity';
|
|||||||
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
import type { AiChatServiceContext, AiSseEmitter, ModelToolCall } from './ai-chat.types';
|
||||||
import { buildA2uiArtifact } from './ai-a2ui.artifact';
|
import { buildA2uiArtifact } from './ai-a2ui.artifact';
|
||||||
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
import { finishToolRun, startToolRun } from './ai-chat.tools';
|
||||||
|
|
||||||
|
/** Array.isArray 的类型守卫:把 unknown 收窄为 unknown[] 而非 any[]。 */
|
||||||
|
function isUnknownArray(value: unknown): value is unknown[] {
|
||||||
|
return Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
export async function executeRenderForm(
|
export async function executeRenderForm(
|
||||||
context: AiChatServiceContext,
|
context: AiChatServiceContext,
|
||||||
messageId: number,
|
messageId: number,
|
||||||
@@ -229,7 +235,7 @@ export async function executeRenderChart(
|
|||||||
if (!assistant) throw new Error('assistant message missing');
|
if (!assistant) throw new Error('assistant message missing');
|
||||||
const chart = context.chartService.createChart(parsedArgs);
|
const chart = context.chartService.createChart(parsedArgs);
|
||||||
const existingCharts = assistant.metadata?.a2uiChart;
|
const existingCharts = assistant.metadata?.a2uiChart;
|
||||||
const charts = Array.isArray(existingCharts)
|
const charts = isUnknownArray(existingCharts)
|
||||||
? [...existingCharts]
|
? [...existingCharts]
|
||||||
: existingCharts
|
: existingCharts
|
||||||
? [existingCharts]
|
? [existingCharts]
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type { A2uiSubmissionsService } from './ai-a2ui-submissions.service';
|
|||||||
import { AiModelStreamService } from './ai-model-stream.service';
|
import { AiModelStreamService } from './ai-model-stream.service';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import {
|
import {
|
||||||
|
AiAttachment,
|
||||||
AiConversation,
|
AiConversation,
|
||||||
AiMessage,
|
AiMessage,
|
||||||
AiReview,
|
AiReview,
|
||||||
@@ -150,7 +151,7 @@ export interface AiChatServiceContext {
|
|||||||
): Promise<ModelMessage[]>;
|
): Promise<ModelMessage[]>;
|
||||||
buildUserContent(
|
buildUserContent(
|
||||||
text: string,
|
text: string,
|
||||||
attachments: any[],
|
attachments: AiAttachment[],
|
||||||
supportsVision: boolean,
|
supportsVision: boolean,
|
||||||
): Promise<string | ModelContentPart[]>;
|
): Promise<string | ModelContentPart[]>;
|
||||||
truncateText(value: string, max: number): string;
|
truncateText(value: string, max: number): string;
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { extractRequestInfo } from '../common/request-utils';
|
|||||||
import { AttendanceDevicesService } from './attendance-devices.service';
|
import { AttendanceDevicesService } from './attendance-devices.service';
|
||||||
import { CreateAttendanceDeviceDto, UpdateAttendanceDeviceDto } from './dto/attendance-device.dto';
|
import { CreateAttendanceDeviceDto, UpdateAttendanceDeviceDto } from './dto/attendance-device.dto';
|
||||||
import { AttendanceDeviceStatus } from '../entities';
|
import { AttendanceDeviceStatus } from '../entities';
|
||||||
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user: AuthenticatedUser;
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
connection?: { remoteAddress?: string };
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('attendance-devices')
|
@Controller('attendance-devices')
|
||||||
@@ -29,7 +36,7 @@ export class AttendanceDevicesController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission('classroom:edit')
|
@RequirePermission('classroom:edit')
|
||||||
async create(@Body() dto: CreateAttendanceDeviceDto, @Request() req: any) {
|
async create(@Body() dto: CreateAttendanceDeviceDto, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.create(dto);
|
const result = await this.service.create(dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
@@ -48,7 +55,7 @@ export class AttendanceDevicesController {
|
|||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequirePermission('classroom:edit')
|
@RequirePermission('classroom:edit')
|
||||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateAttendanceDeviceDto, @Request() req: any) {
|
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateAttendanceDeviceDto, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.update(id, dto);
|
const result = await this.service.update(id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
@@ -67,7 +74,7 @@ export class AttendanceDevicesController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('classroom:edit')
|
@RequirePermission('classroom:edit')
|
||||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.remove(id);
|
const result = await this.service.remove(id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { AttendanceImportService } from './attendance-import.service';
|
|||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { AuthorizationService } from '../authorization';
|
import { AuthorizationService } from '../authorization';
|
||||||
import { logAudit } from '../common/with-audit-log';
|
import { logAudit } from '../common/with-audit-log';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo, type RequestInfoSource } from '../common/request-utils';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import { DingTalkImportDto } from './dto/dingtalk-import.dto';
|
import { DingTalkImportDto } from './dto/dingtalk-import.dto';
|
||||||
import { QueryDingRawDto, MatchDingRecordDto } from './dto/attendance.dto';
|
import { QueryDingRawDto, MatchDingRecordDto } from './dto/attendance.dto';
|
||||||
@@ -35,7 +35,7 @@ export class AttendanceImportController extends AttendanceControllerBase {
|
|||||||
async matchDingRecord(
|
async matchDingRecord(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: MatchDingRecordDto,
|
@Body() dto: MatchDingRecordDto,
|
||||||
@Request() req: any,
|
@Request() req: { user: RequestUser },
|
||||||
) {
|
) {
|
||||||
const result = await this.service.matchDingRecord(id, dto);
|
const result = await this.service.matchDingRecord(id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
@@ -69,7 +69,10 @@ export class AttendanceImportController extends AttendanceControllerBase {
|
|||||||
*/
|
*/
|
||||||
@Post('attendance-records/import/dingtalk')
|
@Post('attendance-records/import/dingtalk')
|
||||||
@RequirePermission('attendance:create')
|
@RequirePermission('attendance:create')
|
||||||
async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) {
|
async importFromDingTalk(
|
||||||
|
@Body() dto: DingTalkImportDto,
|
||||||
|
@Request() req: { user: RequestUser } & RequestInfoSource,
|
||||||
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const canManageAll = this.canManageAllAttendance(req);
|
const canManageAll = this.canManageAllAttendance(req);
|
||||||
let userIds: string[];
|
let userIds: string[];
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { AttendanceImportService } from './attendance-import.service';
|
|||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { AuthorizationService } from '../authorization';
|
import { AuthorizationService } from '../authorization';
|
||||||
import { logAudit } from '../common/with-audit-log';
|
import { logAudit } from '../common/with-audit-log';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo, type RequestInfoSource } from '../common/request-utils';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import {
|
import {
|
||||||
BatchCreateAttendanceDto,
|
BatchCreateAttendanceDto,
|
||||||
@@ -114,7 +114,10 @@ export class AttendanceRecordsController extends AttendanceControllerBase {
|
|||||||
// ── Batch create attendance records ──
|
// ── Batch create attendance records ──
|
||||||
@Post('attendance-records/batch')
|
@Post('attendance-records/batch')
|
||||||
@RequirePermission('attendance:create')
|
@RequirePermission('attendance:create')
|
||||||
async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) {
|
async batchCreate(
|
||||||
|
@Body() dto: BatchCreateAttendanceDto,
|
||||||
|
@Request() req: { user: RequestUser } & RequestInfoSource,
|
||||||
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const canManageAll = this.canManageAllAttendance(req);
|
const canManageAll = this.canManageAllAttendance(req);
|
||||||
if (!canManageAll && dto.records.some((record) => record.classId == null)) {
|
if (!canManageAll && dto.records.some((record) => record.classId == null)) {
|
||||||
@@ -144,7 +147,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase {
|
|||||||
// ── Generate attendance records from schedules (with optional date range) ──
|
// ── Generate attendance records from schedules (with optional date range) ──
|
||||||
@Post('attendance-records/generate-from-schedules')
|
@Post('attendance-records/generate-from-schedules')
|
||||||
@RequirePermission('attendance:create')
|
@RequirePermission('attendance:create')
|
||||||
async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) {
|
async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: { user: RequestUser }) {
|
||||||
await this.assertClassAccess(req, dto.classId);
|
await this.assertClassAccess(req, dto.classId);
|
||||||
const result = await this.service.generateFromSchedules(dto);
|
const result = await this.service.generateFromSchedules(dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
@@ -239,7 +242,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase {
|
|||||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||||
async batchUpdateStatus(
|
async batchUpdateStatus(
|
||||||
@Body() dto: BatchUpdateAttendanceStatusDto,
|
@Body() dto: BatchUpdateAttendanceStatusDto,
|
||||||
@Request() req: any,
|
@Request() req: { user: RequestUser },
|
||||||
) {
|
) {
|
||||||
const failedIds: number[] = [];
|
const failedIds: number[] = [];
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
@@ -269,7 +272,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase {
|
|||||||
async update(
|
async update(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateAttendanceRecordDto,
|
@Body() dto: UpdateAttendanceRecordDto,
|
||||||
@Request() req: any,
|
@Request() req: { user: RequestUser },
|
||||||
) {
|
) {
|
||||||
const existing = await this.service.findAttendanceRecord(id);
|
const existing = await this.service.findAttendanceRecord(id);
|
||||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||||
@@ -286,7 +289,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase {
|
|||||||
// ── Delete a single attendance record ──
|
// ── Delete a single attendance record ──
|
||||||
@Delete('attendance-records/:id')
|
@Delete('attendance-records/:id')
|
||||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: { user: RequestUser }) {
|
||||||
const existing = await this.service.findAttendanceRecord(id);
|
const existing = await this.service.findAttendanceRecord(id);
|
||||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||||
throw new ForbiddenException('无权删除未关联班级的考勤记录');
|
throw new ForbiddenException('无权删除未关联班级的考勤记录');
|
||||||
@@ -334,7 +337,7 @@ export class AttendanceRecordsController extends AttendanceControllerBase {
|
|||||||
async exportReport(
|
async exportReport(
|
||||||
@Query() query: AttendanceReportQueryDto,
|
@Query() query: AttendanceReportQueryDto,
|
||||||
@Res() res: Response,
|
@Res() res: Response,
|
||||||
@Request() req: any,
|
@Request() req: { user: RequestUser },
|
||||||
) {
|
) {
|
||||||
if (query.classId) await this.assertClassAccess(req, query.classId);
|
if (query.classId) await this.assertClassAccess(req, query.classId);
|
||||||
const reportData = await this.service.getReport(query, await this.getAccessibleClassIds(req));
|
const reportData = await this.service.getReport(query, await this.getAccessibleClassIds(req));
|
||||||
|
|||||||
@@ -2,10 +2,15 @@ import { Controller, Post, Body, Get, Request, Req } from '@nestjs/common';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { LoginDto } from './dto/auth.dto';
|
import { LoginDto } from './dto/auth.dto';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo, type RequestInfoSource } from '../common/request-utils';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
import { Throttle } from '@nestjs/throttler';
|
||||||
import { Public } from './decorators/public.decorator';
|
import { Public } from './decorators/public.decorator';
|
||||||
import { Authenticated } from './decorators/authenticated.decorator';
|
import { Authenticated } from './decorators/authenticated.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest extends RequestInfoSource {
|
||||||
|
user: AuthenticatedUser;
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -17,7 +22,7 @@ export class AuthController {
|
|||||||
@Public()
|
@Public()
|
||||||
@Post('login')
|
@Post('login')
|
||||||
@Throttle({ default: { ttl: 60000, limit: 5 } })
|
@Throttle({ default: { ttl: 60000, limit: 5 } })
|
||||||
async login(@Body() dto: LoginDto, @Req() req: any) {
|
async login(@Body() dto: LoginDto, @Req() req: RequestInfoSource) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
try {
|
try {
|
||||||
const result = await this.authService.login(dto, ipAddress);
|
const result = await this.authService.login(dto, ipAddress);
|
||||||
@@ -31,12 +36,12 @@ export class AuthController {
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
username: dto.username,
|
username: dto.username,
|
||||||
module: '认证',
|
module: '认证',
|
||||||
action: '登录失败',
|
action: '登录失败',
|
||||||
detail: e.message || '密码错误',
|
detail: e instanceof Error ? e.message || '密码错误' : '密码错误',
|
||||||
ipAddress,
|
ipAddress,
|
||||||
userAgent,
|
userAgent,
|
||||||
status: 'fail',
|
status: 'fail',
|
||||||
@@ -47,7 +52,7 @@ export class AuthController {
|
|||||||
|
|
||||||
@Authenticated()
|
@Authenticated()
|
||||||
@Get('profile')
|
@Get('profile')
|
||||||
getProfile(@Request() req: any) {
|
getProfile(@Request() req: AuthenticatedRequest) {
|
||||||
return req.user;
|
return req.user;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export class AuthService {
|
|||||||
loginAttempts.set(key, attempt);
|
loginAttempts.set(key, attempt);
|
||||||
}
|
}
|
||||||
|
|
||||||
async validateUser(payload: any) {
|
async validateUser(payload: { sub?: number }) {
|
||||||
return this.userRepo.findOne({ where: { id: payload.sub } });
|
return this.userRepo.findOne({ where: { id: payload.sub } });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call -- pdfkit/exceljs 无完整 TS 类型,属第三方库边界 */
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
|||||||
import { logAudit } from '../common/with-audit-log';
|
import { logAudit } from '../common/with-audit-log';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import type { Response } from 'express';
|
import type { Response } from 'express';
|
||||||
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user: AuthenticatedUser;
|
||||||
|
ip?: string;
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('bills')
|
@Controller('bills')
|
||||||
@@ -42,7 +49,7 @@ export class BillsController {
|
|||||||
|
|
||||||
@Post('generate')
|
@Post('generate')
|
||||||
@RequirePermission('bill:generate')
|
@RequirePermission('bill:generate')
|
||||||
async generateBills(@Body() dto: GenerateBillsDto, @Request() req: any) {
|
async generateBills(@Body() dto: GenerateBillsDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.generateBills(dto);
|
const result = await this.service.generateBills(dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '账单管理', action: '生成账单', detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count} 条`,
|
module: '账单管理', action: '生成账单', detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count} 条`,
|
||||||
@@ -91,7 +98,7 @@ export class BillsController {
|
|||||||
async updateStatus(
|
async updateStatus(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateBillStatusDto,
|
@Body() dto: UpdateBillStatusDto,
|
||||||
@Request() req: any,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const result = await this.service.updateStatus(id, dto);
|
const result = await this.service.updateStatus(id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
@@ -114,7 +121,7 @@ export class BillsController {
|
|||||||
|
|
||||||
@Put('batch/status')
|
@Put('batch/status')
|
||||||
@RequirePermission('bill:confirm')
|
@RequirePermission('bill:confirm')
|
||||||
async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: any) {
|
async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchUpdateStatus(body.ids, body.status);
|
const result = await this.service.batchUpdateStatus(body.ids, body.status);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '账单管理', action: '确认账单', detail: `IDs: ${body.ids.join(',')}`,
|
module: '账单管理', action: '确认账单', detail: `IDs: ${body.ids.join(',')}`,
|
||||||
@@ -139,7 +146,7 @@ export class BillsController {
|
|||||||
|
|
||||||
@Post(':id/cancel')
|
@Post(':id/cancel')
|
||||||
@RequirePermission('bill:delete')
|
@RequirePermission('bill:delete')
|
||||||
async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: any) {
|
async cancel(@Param('id', ParseIntPipe) id: number, @Body() dto: CancelBillDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.cancel(id, dto, req.user?.id);
|
const result = await this.service.cancel(id, dto, req.user?.id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', detail: dto.reason,
|
module: '账单管理', action: '取消账单并冲正', targetId: id, targetType: 'bill', detail: dto.reason,
|
||||||
@@ -149,7 +156,7 @@ export class BillsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('bill:delete')
|
@RequirePermission('bill:delete')
|
||||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.remove(id);
|
const result = await this.service.remove(id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill',
|
module: '账单管理', action: '归档账单', targetId: id, targetType: 'bill',
|
||||||
@@ -159,7 +166,7 @@ export class BillsController {
|
|||||||
|
|
||||||
@Delete(':id/permanent')
|
@Delete(':id/permanent')
|
||||||
@RequirePermission('bill:purge')
|
@RequirePermission('bill:purge')
|
||||||
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.purge(id);
|
const result = await this.service.purge(id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复',
|
module: '账单管理', action: '永久删除账单', targetId: id, targetType: 'bill', detail: '物理删除,不可恢复',
|
||||||
@@ -169,7 +176,7 @@ export class BillsController {
|
|||||||
|
|
||||||
@Post('batch-permanent-delete')
|
@Post('batch-permanent-delete')
|
||||||
@RequirePermission('bill:purge')
|
@RequirePermission('bill:purge')
|
||||||
async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) {
|
async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchPurge(body.ids || []);
|
const result = await this.service.batchPurge(body.ids || []);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`,
|
module: '账单管理', action: '批量永久删除账单', detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||||
@@ -179,7 +186,7 @@ export class BillsController {
|
|||||||
|
|
||||||
@Post('batch/delete')
|
@Post('batch/delete')
|
||||||
@RequirePermission('bill:delete')
|
@RequirePermission('bill:delete')
|
||||||
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
|
async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchRemove(body.ids);
|
const result = await this.service.batchRemove(body.ids);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '账单管理', action: '批量归档账单', detail: `IDs: ${body.ids.join(',')}`,
|
module: '账单管理', action: '批量归档账单', detail: `IDs: ${body.ids.join(',')}`,
|
||||||
@@ -190,12 +197,12 @@ export class BillsController {
|
|||||||
@Get('export/excel')
|
@Get('export/excel')
|
||||||
@RequirePermission('bill:export-excel')
|
@RequirePermission('bill:export-excel')
|
||||||
async exportExcel(
|
async exportExcel(
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
@Query('periodStart') periodStart?: string,
|
@Query('periodStart') periodStart?: string,
|
||||||
@Query('periodEnd') periodEnd?: string,
|
@Query('periodEnd') periodEnd?: string,
|
||||||
@Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
|
@Query('studentId', new ParseIntPipe({ optional: true })) studentId?: number,
|
||||||
@Query('status') status?: string,
|
@Query('status') status?: string,
|
||||||
@Res() res?: Response,
|
@Res() res?: Response,
|
||||||
@Req() req?: any,
|
|
||||||
) {
|
) {
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '账单管理', action: '导出账单', detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`,
|
module: '账单管理', action: '导出账单', detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`,
|
||||||
@@ -213,7 +220,7 @@ export class BillsController {
|
|||||||
|
|
||||||
@Get('export/pdf/:id')
|
@Get('export/pdf/:id')
|
||||||
@RequirePermission('bill:export-pdf')
|
@RequirePermission('bill:export-pdf')
|
||||||
async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) {
|
async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: AuthenticatedRequest) {
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '账单管理', action: '导出账单', targetId: id, targetType: 'bill',
|
module: '账单管理', action: '导出账单', targetId: id, targetType: 'bill',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -176,8 +176,10 @@ export class BillsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 查询时附加钱包余额和实际支付数据。 */
|
/** 查询时附加钱包余额和实际支付数据。 */
|
||||||
private async attachDepositInfo(bills: Bill[]): Promise<any[]> {
|
private async attachDepositInfo(
|
||||||
if (!bills?.length) return bills;
|
bills: Bill[],
|
||||||
|
): Promise<Array<Bill & { walletBalance: number }>> {
|
||||||
|
if (!bills?.length) return bills as Array<Bill & { walletBalance: number }>;
|
||||||
const studentIds = Array.from(new Set(bills.map((bill) => bill.studentId)));
|
const studentIds = Array.from(new Set(bills.map((bill) => bill.studentId)));
|
||||||
const wallets = await this.dataSource
|
const wallets = await this.dataSource
|
||||||
.getRepository(StudentWallet)
|
.getRepository(StudentWallet)
|
||||||
@@ -185,7 +187,7 @@ export class BillsService {
|
|||||||
.where('wallet.studentId IN (:...ids)', { ids: studentIds })
|
.where('wallet.studentId IN (:...ids)', { ids: studentIds })
|
||||||
.getMany();
|
.getMany();
|
||||||
const balanceMap = new Map(
|
const balanceMap = new Map(
|
||||||
wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]),
|
wallets.map((wallet) => [wallet.studentId, Number(wallet.balance || 0)]),
|
||||||
);
|
);
|
||||||
return bills.map((bill) => ({
|
return bills.map((bill) => ({
|
||||||
...bill,
|
...bill,
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ export class ClassesController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission('class:create')
|
@RequirePermission('class:create')
|
||||||
async create(@Body() dto: CreateClassDto, @Request() req: any) {
|
async create(@Body() dto: CreateClassDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.create(dto);
|
const result = await this.service.create(dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '班级管理', action: '创建班级', targetId: result.id, targetType: 'class', detail: `班级${result.code} ${result.name}`,
|
module: '班级管理', action: '创建班级', targetId: result.id, targetType: 'class', detail: `班级${result.code} ${result.name}`,
|
||||||
@@ -146,7 +146,7 @@ export class ClassesController {
|
|||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequirePermission('class:edit')
|
@RequirePermission('class:edit')
|
||||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: any) {
|
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateClassDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.update(+id, dto);
|
const result = await this.service.update(+id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto),
|
module: '班级管理', action: '编辑班级', targetId: +id, targetType: 'class', detail: JSON.stringify(dto),
|
||||||
@@ -156,7 +156,7 @@ export class ClassesController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('class:delete')
|
@RequirePermission('class:delete')
|
||||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.remove(+id);
|
const result = await this.service.remove(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class',
|
module: '班级管理', action: '归档班级', targetId: +id, targetType: 'class',
|
||||||
@@ -166,7 +166,7 @@ export class ClassesController {
|
|||||||
|
|
||||||
@Delete(':id/permanent')
|
@Delete(':id/permanent')
|
||||||
@RequirePermission('class:purge')
|
@RequirePermission('class:purge')
|
||||||
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.purge(+id);
|
const result = await this.service.purge(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复',
|
module: '班级管理', action: '永久删除班级', targetId: +id, targetType: 'class', detail: '物理删除,不可恢复',
|
||||||
@@ -226,7 +226,7 @@ export class ClassesController {
|
|||||||
|
|
||||||
@Post(':id/students')
|
@Post(':id/students')
|
||||||
@RequirePermission('class:edit')
|
@RequirePermission('class:edit')
|
||||||
async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: any) {
|
async addStudents(@Param('id', ParseIntPipe) id: number, @Body() dto: AddStudentsDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.addStudents(+id, dto.studentIds);
|
const result = await this.service.addStudents(+id, dto.studentIds);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`,
|
module: '班级管理', action: '添加学生', targetId: +id, targetType: 'class', detail: `新增${result.added}名学生`,
|
||||||
@@ -252,7 +252,7 @@ export class ClassesController {
|
|||||||
async removeStudent(
|
async removeStudent(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Param('studentId', ParseIntPipe) studentId: number,
|
@Param('studentId', ParseIntPipe) studentId: number,
|
||||||
@Request() req: any,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const result = await this.service.removeStudent(+id, +studentId);
|
const result = await this.service.removeStudent(+id, +studentId);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
@@ -270,7 +270,7 @@ export class ClassesController {
|
|||||||
|
|
||||||
@Post(':id/teachers')
|
@Post(':id/teachers')
|
||||||
@RequirePermission('class:edit')
|
@RequirePermission('class:edit')
|
||||||
async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: any) {
|
async addTeacher(@Param('id', ParseIntPipe) id: number, @Body() dto: AddTeacherDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.addTeacher(+id, dto);
|
const result = await this.service.addTeacher(+id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`,
|
module: '班级管理', action: '添加教师', targetId: +id, targetType: 'class', detail: `教师${dto.userId} 角色${dto.roleType}`,
|
||||||
@@ -293,7 +293,7 @@ export class ClassesController {
|
|||||||
async removeTeacherAssignment(
|
async removeTeacherAssignment(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Param('assignmentId', ParseIntPipe) assignmentId: number,
|
@Param('assignmentId', ParseIntPipe) assignmentId: number,
|
||||||
@Request() req: any,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const result = await this.service.removeTeacherAssignment(+id, +assignmentId);
|
const result = await this.service.removeTeacherAssignment(+id, +assignmentId);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
@@ -307,7 +307,7 @@ export class ClassesController {
|
|||||||
async removeTeacher(
|
async removeTeacher(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Param('userId', ParseIntPipe) userId: number,
|
@Param('userId', ParseIntPipe) userId: number,
|
||||||
@Request() req: any,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const result = await this.service.removeTeacher(+id, +userId);
|
const result = await this.service.removeTeacher(+id, +userId);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ export class QueryClassDto {
|
|||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
if (value === 'true' || value === '1') return true;
|
if (value === 'true' || value === '1') return true;
|
||||||
if (value === 'false' || value === '0') return false;
|
if (value === 'false' || value === '0') return false;
|
||||||
return value;
|
return value as boolean;
|
||||||
})
|
})
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
|||||||
import { logAudit } from '../common/with-audit-log';
|
import { logAudit } from '../common/with-audit-log';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user?: { id: number; username: string };
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
connection?: { remoteAddress?: string };
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('classroom-rentals')
|
@Controller('classroom-rentals')
|
||||||
export class ClassroomRentalsController {
|
export class ClassroomRentalsController {
|
||||||
@@ -102,7 +108,7 @@ export class ClassroomRentalsController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission('rental:create')
|
@RequirePermission('rental:create')
|
||||||
async create(@Body() dto: CreateRentalDto, @Request() req: any) {
|
async create(@Body() dto: CreateRentalDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.create(dto, req.user?.id);
|
const result = await this.service.create(dto, req.user?.id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '教室租赁', action: '新增租赁', targetId: result.id, targetType: 'classroom-rental', detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`,
|
module: '教室租赁', action: '新增租赁', targetId: result.id, targetType: 'classroom-rental', detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`,
|
||||||
@@ -112,7 +118,7 @@ export class ClassroomRentalsController {
|
|||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequirePermission('rental:edit')
|
@RequirePermission('rental:edit')
|
||||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRentalDto, @Request() req: any) {
|
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRentalDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.update(+id, dto);
|
const result = await this.service.update(+id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '教室租赁', action: '编辑租赁', targetId: +id, targetType: 'classroom-rental', detail: JSON.stringify(dto),
|
module: '教室租赁', action: '编辑租赁', targetId: +id, targetType: 'classroom-rental', detail: JSON.stringify(dto),
|
||||||
@@ -122,7 +128,7 @@ export class ClassroomRentalsController {
|
|||||||
|
|
||||||
@Put(':id/cancel')
|
@Put(':id/cancel')
|
||||||
@RequirePermission('rental:edit')
|
@RequirePermission('rental:edit')
|
||||||
async cancel(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async cancel(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.cancel(+id);
|
const result = await this.service.cancel(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '教室租赁', action: '取消租赁', targetId: +id, targetType: 'classroom-rental',
|
module: '教室租赁', action: '取消租赁', targetId: +id, targetType: 'classroom-rental',
|
||||||
@@ -132,7 +138,7 @@ export class ClassroomRentalsController {
|
|||||||
|
|
||||||
@Put(':id/end')
|
@Put(':id/end')
|
||||||
@RequirePermission('rental:edit')
|
@RequirePermission('rental:edit')
|
||||||
async end(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async end(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.end(+id);
|
const result = await this.service.end(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '教室租赁', action: '结束租赁', targetId: +id, targetType: 'classroom-rental',
|
module: '教室租赁', action: '结束租赁', targetId: +id, targetType: 'classroom-rental',
|
||||||
@@ -142,7 +148,7 @@ export class ClassroomRentalsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('rental:delete')
|
@RequirePermission('rental:delete')
|
||||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.remove(+id);
|
const result = await this.service.remove(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '教室租赁', action: '归档租赁', targetId: +id, targetType: 'classroom-rental',
|
module: '教室租赁', action: '归档租赁', targetId: +id, targetType: 'classroom-rental',
|
||||||
@@ -152,7 +158,7 @@ export class ClassroomRentalsController {
|
|||||||
|
|
||||||
@Delete(':id/permanent')
|
@Delete(':id/permanent')
|
||||||
@RequirePermission('rental:purge')
|
@RequirePermission('rental:purge')
|
||||||
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.purge(+id);
|
const result = await this.service.purge(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '教室租赁', action: '永久删除租赁订单', targetId: +id, targetType: 'classroom-rental', detail: '物理删除,不可恢复',
|
module: '教室租赁', action: '永久删除租赁订单', targetId: +id, targetType: 'classroom-rental', detail: '物理删除,不可恢复',
|
||||||
@@ -177,7 +183,7 @@ export class ClassroomRentalsController {
|
|||||||
async uploadContract(
|
async uploadContract(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@Request() req: any,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
if (!file) throw new BadRequestException('请上传合同文件');
|
if (!file) throw new BadRequestException('请上传合同文件');
|
||||||
const result = await this.service.attachContract(+id, file);
|
const result = await this.service.attachContract(+id, file);
|
||||||
@@ -202,7 +208,7 @@ export class ClassroomRentalsController {
|
|||||||
|
|
||||||
@Delete(':id/contract')
|
@Delete(':id/contract')
|
||||||
@RequirePermission('rental:edit')
|
@RequirePermission('rental:edit')
|
||||||
async deleteContract(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async deleteContract(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.removeContract(+id);
|
const result = await this.service.removeContract(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '教室租赁', action: '移除合同', targetId: +id, targetType: 'classroom-rental',
|
module: '教室租赁', action: '移除合同', targetId: +id, targetType: 'classroom-rental',
|
||||||
|
|||||||
@@ -19,6 +19,19 @@ import * as path from 'path';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
/** getRawMany 原始行:驱动可能返回 string/number,date 列可能是 string 或 Date */
|
||||||
|
interface AgentRentalRawRow {
|
||||||
|
id: string | number;
|
||||||
|
classroomName: string | null;
|
||||||
|
lesseeOrganizationName: string | null;
|
||||||
|
startDate: string | Date;
|
||||||
|
endDate: string | Date;
|
||||||
|
dailyRate: string | number | null;
|
||||||
|
totalAmount: string | number | null;
|
||||||
|
status: string;
|
||||||
|
contractName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
// 预设色板(与 organizations.service 保持一致,作为颜色兜底)
|
// 预设色板(与 organizations.service 保持一致,作为颜色兜底)
|
||||||
function rentalConflictError(
|
function rentalConflictError(
|
||||||
message: string,
|
message: string,
|
||||||
@@ -142,7 +155,7 @@ export class ClassroomRentalsService {
|
|||||||
const rows = await qb
|
const rows = await qb
|
||||||
.orderBy('r.startDate', 'DESC')
|
.orderBy('r.startDate', 'DESC')
|
||||||
.limit(Math.max(1, Math.min(query?.limit ?? 20, 50)))
|
.limit(Math.max(1, Math.min(query?.limit ?? 20, 50)))
|
||||||
.getRawMany<Record<string, unknown>>();
|
.getRawMany<AgentRentalRawRow>();
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
classroomName: row.classroomName == null ? '' : String(row.classroomName),
|
classroomName: row.classroomName == null ? '' : String(row.classroomName),
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
|
|||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
import { CLASSROOM_TEMPLATE_COLUMNS } from './classroom-template';
|
import { CLASSROOM_TEMPLATE_COLUMNS } from './classroom-template';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user?: { id: number; username: string };
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
connection?: { remoteAddress?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* exceljs 单元格标量文本。保持原 `String(value || '')` 语义。
|
||||||
|
* CellValue 联合类型含富文本/超链接对象,实际导入数据均为标量,故在辅助函数内局部豁免。
|
||||||
|
*/
|
||||||
|
function cellValueText(value: unknown): string {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- exceljs CellValue 联合类型含富文本对象,实际导入数据为标量
|
||||||
|
return String(value || '');
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('classrooms')
|
@Controller('classrooms')
|
||||||
export class ClassroomsController {
|
export class ClassroomsController {
|
||||||
@@ -104,7 +119,7 @@ export class ClassroomsController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission('classroom:create')
|
@RequirePermission('classroom:create')
|
||||||
async create(@Body() dto: CreateClassroomDto, @Request() req: any) {
|
async create(@Body() dto: CreateClassroomDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.create(dto);
|
const result = await this.service.create(dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '教室', action: '新增教室', targetId: result.id, targetType: 'classroom', detail: dto.name,
|
module: '教室', action: '新增教室', targetId: result.id, targetType: 'classroom', detail: dto.name,
|
||||||
@@ -114,7 +129,7 @@ export class ClassroomsController {
|
|||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequirePermission('classroom:edit')
|
@RequirePermission('classroom:edit')
|
||||||
async update(@Param('id') id: string, @Body() dto: UpdateClassroomDto, @Request() req: any) {
|
async update(@Param('id') id: string, @Body() dto: UpdateClassroomDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.update(+id, dto);
|
const result = await this.service.update(+id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '教室', action: '编辑教室', targetId: +id, targetType: 'classroom', detail: JSON.stringify(dto),
|
module: '教室', action: '编辑教室', targetId: +id, targetType: 'classroom', detail: JSON.stringify(dto),
|
||||||
@@ -124,7 +139,7 @@ export class ClassroomsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('classroom:delete')
|
@RequirePermission('classroom:delete')
|
||||||
async remove(@Param('id') id: string, @Request() req: any) {
|
async remove(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.remove(+id);
|
const result = await this.service.remove(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '教室', action: '归档教室', targetId: +id, targetType: 'classroom',
|
module: '教室', action: '归档教室', targetId: +id, targetType: 'classroom',
|
||||||
@@ -134,7 +149,7 @@ export class ClassroomsController {
|
|||||||
|
|
||||||
@Delete(':id/permanent')
|
@Delete(':id/permanent')
|
||||||
@RequirePermission('classroom:purge')
|
@RequirePermission('classroom:purge')
|
||||||
async purge(@Param('id') id: string, @Request() req: any) {
|
async purge(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.purge(+id);
|
const result = await this.service.purge(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '教室', action: '永久删除教室', targetId: +id, targetType: 'classroom', detail: '物理删除,不可恢复',
|
module: '教室', action: '永久删除教室', targetId: +id, targetType: 'classroom', detail: '物理删除,不可恢复',
|
||||||
@@ -144,7 +159,7 @@ export class ClassroomsController {
|
|||||||
|
|
||||||
@Put(':id/restore')
|
@Put(':id/restore')
|
||||||
@RequirePermission('classroom:edit')
|
@RequirePermission('classroom:edit')
|
||||||
async restore(@Param('id') id: string, @Request() req: any) {
|
async restore(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.restore(+id);
|
const result = await this.service.restore(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '教室', action: '恢复教室', targetId: +id, targetType: 'classroom',
|
module: '教室', action: '恢复教室', targetId: +id, targetType: 'classroom',
|
||||||
@@ -155,19 +170,25 @@ export class ClassroomsController {
|
|||||||
@Post('import')
|
@Post('import')
|
||||||
@RequirePermission('classroom:create')
|
@RequirePermission('classroom:create')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||||
const ws = workbook.worksheets[0];
|
const ws = workbook.worksheets[0];
|
||||||
const rows: any[] = [];
|
const rows: {
|
||||||
|
name: string;
|
||||||
|
building?: string;
|
||||||
|
floor?: number;
|
||||||
|
roomType?: string;
|
||||||
|
capacity?: number;
|
||||||
|
}[] = [];
|
||||||
ws.eachRow((row, idx) => {
|
ws.eachRow((row, idx) => {
|
||||||
if (idx === 1) return;
|
if (idx === 1) return;
|
||||||
rows.push({
|
rows.push({
|
||||||
name: String(row.getCell(1).value || ''),
|
name: cellValueText(row.getCell(1).value),
|
||||||
building: String(row.getCell(2).value || '') || undefined,
|
building: cellValueText(row.getCell(2).value) || undefined,
|
||||||
floor: Number(row.getCell(3).value) || undefined,
|
floor: Number(row.getCell(3).value) || undefined,
|
||||||
roomType: String(row.getCell(4).value || '') || undefined,
|
roomType: cellValueText(row.getCell(4).value) || undefined,
|
||||||
capacity: Number(row.getCell(5).value) || undefined,
|
capacity: Number(row.getCell(5).value) || undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,25 @@ import { ClassSchedule } from '../entities/class-schedule.entity';
|
|||||||
import { AttendanceDevice } from '../entities/attendance-device.entity';
|
import { AttendanceDevice } from '../entities/attendance-device.entity';
|
||||||
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
|
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
|
||||||
|
|
||||||
|
/** getRawMany 原始行:驱动可能返回 string/number,date 列可能是 string 或 Date */
|
||||||
|
interface ScheduleUsageRawRow {
|
||||||
|
classroomId: string | number;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
startDate: string | Date;
|
||||||
|
endDate: string | Date;
|
||||||
|
weekDay: string | number;
|
||||||
|
subject: string | null;
|
||||||
|
className: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RentalUsageRawRow {
|
||||||
|
classroomId: string | number;
|
||||||
|
startDate: string | Date;
|
||||||
|
endDate: string | Date;
|
||||||
|
tenantName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ClassroomsService {
|
export class ClassroomsService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -255,7 +274,7 @@ export class ClassroomsService {
|
|||||||
.andWhere('s.status = :active', { active: 'active' })
|
.andWhere('s.status = :active', { active: 'active' })
|
||||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||||
.andWhere('s.endDate >= :today', { today: todayStr })
|
.andWhere('s.endDate >= :today', { today: todayStr })
|
||||||
.getRawMany();
|
.getRawMany<ScheduleUsageRawRow>();
|
||||||
|
|
||||||
for (const schedule of schedules) {
|
for (const schedule of schedules) {
|
||||||
const classroomId = Number(schedule.classroomId);
|
const classroomId = Number(schedule.classroomId);
|
||||||
@@ -291,7 +310,7 @@ export class ClassroomsService {
|
|||||||
.where('r.classroomId IN (:...ids)', { ids: classroomIds })
|
.where('r.classroomId IN (:...ids)', { ids: classroomIds })
|
||||||
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
|
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
|
||||||
.andWhere('r.endDate >= :today', { today: todayStr })
|
.andWhere('r.endDate >= :today', { today: todayStr })
|
||||||
.getRawMany();
|
.getRawMany<RentalUsageRawRow>();
|
||||||
|
|
||||||
for (const rental of rentals) {
|
for (const rental of rentals) {
|
||||||
const classroomId = Number(rental.classroomId);
|
const classroomId = Number(rental.classroomId);
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
|
/** 从请求对象提取 IP / UA 所需的最小结构。 */
|
||||||
|
export interface RequestInfoSource {
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
connection?: { remoteAddress?: string };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从请求对象中提取客户端 IP 和 UserAgent
|
* 从请求对象中提取客户端 IP 和 UserAgent
|
||||||
*/
|
*/
|
||||||
export function extractRequestInfo(req: any): { ipAddress: string; userAgent: string } {
|
export function extractRequestInfo(req: RequestInfoSource): { ipAddress: string; userAgent: string } {
|
||||||
const forwarded =
|
const forwarded =
|
||||||
req.headers?.['x-forwarded-for'] ||
|
req.headers?.['x-forwarded-for'] ||
|
||||||
req.headers?.['x-real-ip'] ||
|
req.headers?.['x-real-ip'] ||
|
||||||
req.connection?.remoteAddress ||
|
req.connection?.remoteAddress ||
|
||||||
'';
|
'';
|
||||||
const ipAddress = String(forwarded).split(',')[0].trim() || 'unknown';
|
const ipAddress = String(forwarded).split(',')[0].trim() || 'unknown';
|
||||||
const userAgent = (req.headers?.['user-agent'] || '').substring(0, 500);
|
const userAgent = String(req.headers?.['user-agent'] || '').substring(0, 500);
|
||||||
return { ipAddress, userAgent };
|
return { ipAddress, userAgent };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,12 +57,12 @@ async getAttendanceTrend(
|
|||||||
.groupBy('a.attendanceDate')
|
.groupBy('a.attendanceDate')
|
||||||
.addGroupBy('a.status')
|
.addGroupBy('a.status')
|
||||||
.orderBy('a.attendanceDate', 'ASC')
|
.orderBy('a.attendanceDate', 'ASC')
|
||||||
.getRawMany();
|
.getRawMany<{ date: string; status: string; count: string | number }>();
|
||||||
|
|
||||||
const dayMap = new Map<string, { total: number; present: number }>();
|
const dayMap = new Map<string, { total: number; present: number }>();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const d = dayMap.get(row.date) || { total: 0, present: 0 };
|
const d = dayMap.get(row.date) || { total: 0, present: 0 };
|
||||||
const cnt = parseInt(row.count, 10);
|
const cnt = parseInt(String(row.count), 10);
|
||||||
d.total += cnt;
|
d.total += cnt;
|
||||||
if (row.status === 'present') d.present += cnt;
|
if (row.status === 'present') d.present += cnt;
|
||||||
dayMap.set(row.date, d);
|
dayMap.set(row.date, d);
|
||||||
@@ -92,11 +92,11 @@ async getIncomeTrend(
|
|||||||
.where('b.status = :paid', { paid: 'paid' })
|
.where('b.status = :paid', { paid: 'paid' })
|
||||||
.andWhere('b.periodStart >= :start', { start: `${m}-01` })
|
.andWhere('b.periodStart >= :start', { start: `${m}-01` })
|
||||||
.andWhere('b.periodStart < :end', { end: nextMonth(m) })
|
.andWhere('b.periodStart < :end', { end: nextMonth(m) })
|
||||||
.getRawOne();
|
.getRawOne<{ total: string | number | null }>();
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
month: m,
|
month: m,
|
||||||
amount: parseFloat(row?.total || '0'),
|
amount: parseFloat(String(row?.total || '0')),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,15 +216,21 @@ async getClassAttendanceRanking(
|
|||||||
.addSelect('COUNT(*)', 'count');
|
.addSelect('COUNT(*)', 'count');
|
||||||
applyClassScope(qb, 'a', accessibleClassIds);
|
applyClassScope(qb, 'a', accessibleClassIds);
|
||||||
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
|
qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('a.status');
|
||||||
const raw = await qb.getRawMany();
|
const raw = await qb.getRawMany<{
|
||||||
|
classId: string | number | null;
|
||||||
|
className: string | null;
|
||||||
|
status: string;
|
||||||
|
count: string | number;
|
||||||
|
}>();
|
||||||
|
|
||||||
const classMap = new Map<number, { className: string; present: number; total: number }>();
|
const classMap = new Map<number, { className: string; present: number; total: number }>();
|
||||||
for (const r of raw) {
|
for (const r of raw) {
|
||||||
if (!r.classId) continue;
|
if (!r.classId) continue;
|
||||||
if (!classMap.has(Number(r.classId)))
|
const classId = Number(r.classId);
|
||||||
classMap.set(Number(r.classId), { className: r.className, present: 0, total: 0 });
|
if (!classMap.has(classId))
|
||||||
const entry = classMap.get(Number(r.classId))!;
|
classMap.set(classId, { className: r.className ?? '', present: 0, total: 0 });
|
||||||
const n = parseInt(r.count, 10);
|
const entry = classMap.get(classId)!;
|
||||||
|
const n = parseInt(String(r.count), 10);
|
||||||
entry.total += n;
|
entry.total += n;
|
||||||
if (r.status === 'present') entry.present += n;
|
if (r.status === 'present') entry.present += n;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ export class DashboardService {
|
|||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.select('SUM(r.capacity)', 'total')
|
.select('SUM(r.capacity)', 'total')
|
||||||
.where('r.status != :archived', { archived: 'archived' });
|
.where('r.status != :archived', { archived: 'archived' });
|
||||||
const totalCapacity = await capQb.getRawOne();
|
const totalCapacity = await capQb.getRawOne<{ total: string | number | null }>();
|
||||||
// MySQL 的 SUM() 聚合默认以字符串返回,需显式转成 number
|
// MySQL 的 SUM() 聚合默认以字符串返回,需显式转成 number
|
||||||
const cap = Number(totalCapacity?.total ?? 0) || 0;
|
const cap = Number(totalCapacity?.total ?? 0) || 0;
|
||||||
const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : 0;
|
const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : 0;
|
||||||
@@ -111,7 +111,11 @@ export class DashboardService {
|
|||||||
.addSelect('COUNT(*)', 'count')
|
.addSelect('COUNT(*)', 'count')
|
||||||
.addSelect('SUM(b.totalAmount)', 'total')
|
.addSelect('SUM(b.totalAmount)', 'total')
|
||||||
.groupBy('b.status');
|
.groupBy('b.status');
|
||||||
const billStats = await billStatsQb.getRawMany();
|
const billStats = await billStatsQb.getRawMany<{
|
||||||
|
status: string;
|
||||||
|
count: string | number;
|
||||||
|
total: string | number;
|
||||||
|
}>();
|
||||||
|
|
||||||
// New fields
|
// New fields
|
||||||
const classroomCount = await this.classroomRepo.count({ where: {} });
|
const classroomCount = await this.classroomRepo.count({ where: {} });
|
||||||
@@ -122,8 +126,8 @@ export class DashboardService {
|
|||||||
.where('s.status = :active', { active: 'active' })
|
.where('s.status = :active', { active: 'active' })
|
||||||
.andWhere('s.startDate <= :today', { today: todayStr })
|
.andWhere('s.startDate <= :today', { today: todayStr })
|
||||||
.andWhere('s.endDate >= :today', { today: todayStr });
|
.andWhere('s.endDate >= :today', { today: todayStr });
|
||||||
const occResult = await occQb.getRawOne();
|
const occResult = await occQb.getRawOne<{ cnt: string | number | null }>();
|
||||||
const occupiedClassrooms = parseInt(occResult?.cnt || '0', 10);
|
const occupiedClassrooms = parseInt(String(occResult?.cnt || '0'), 10);
|
||||||
const classroomOccupancyRate =
|
const classroomOccupancyRate =
|
||||||
classroomCount > 0 ? ((occupiedClassrooms / classroomCount) * 100).toFixed(1) : 0;
|
classroomCount > 0 ? ((occupiedClassrooms / classroomCount) * 100).toFixed(1) : 0;
|
||||||
|
|
||||||
@@ -134,18 +138,18 @@ export class DashboardService {
|
|||||||
.where('a.attendanceDate = :today', { today: todayStr });
|
.where('a.attendanceDate = :today', { today: todayStr });
|
||||||
this.applyClassScope(attTodayQb, 'a', accessibleClassIds);
|
this.applyClassScope(attTodayQb, 'a', accessibleClassIds);
|
||||||
attTodayQb.groupBy('a.status');
|
attTodayQb.groupBy('a.status');
|
||||||
const attTodayStats = await attTodayQb.getRawMany();
|
const attTodayStats = await attTodayQb.getRawMany<{ status: string; count: string | number }>();
|
||||||
const todayPresent = attTodayStats
|
const todayPresent = attTodayStats
|
||||||
.filter((r) => r.status === 'present')
|
.filter((r) => r.status === 'present')
|
||||||
.reduce((sum, r) => sum + parseInt(r.count, 10), 0);
|
.reduce((sum, r) => sum + parseInt(String(r.count), 10), 0);
|
||||||
const incomeQb = this.billRepo
|
const incomeQb = this.billRepo
|
||||||
.createQueryBuilder('b')
|
.createQueryBuilder('b')
|
||||||
.select('SUM(b.totalAmount)', 'total')
|
.select('SUM(b.totalAmount)', 'total')
|
||||||
.where('b.status = :paid', { paid: 'paid' })
|
.where('b.status = :paid', { paid: 'paid' })
|
||||||
.andWhere('b.periodStart >= :start', { start: `${currentMonth}-01` })
|
.andWhere('b.periodStart >= :start', { start: `${currentMonth}-01` })
|
||||||
.andWhere('b.periodStart < :end', { end: this.nextMonth(currentMonth) });
|
.andWhere('b.periodStart < :end', { end: this.nextMonth(currentMonth) });
|
||||||
const incomeResult = await incomeQb.getRawOne();
|
const incomeResult = await incomeQb.getRawOne<{ total: string | number | null }>();
|
||||||
const monthlyIncome = parseFloat(incomeResult?.total || '0');
|
const monthlyIncome = parseFloat(String(incomeResult?.total || '0'));
|
||||||
|
|
||||||
const attendanceTrend = await this.getAttendanceTrend(todayStr, accessibleClassIds);
|
const attendanceTrend = await this.getAttendanceTrend(todayStr, accessibleClassIds);
|
||||||
const incomeTrend = await this.getIncomeTrend(currentMonth);
|
const incomeTrend = await this.getIncomeTrend(currentMonth);
|
||||||
@@ -157,15 +161,15 @@ export class DashboardService {
|
|||||||
const teacherResult = await this.classTeacherRepo
|
const teacherResult = await this.classTeacherRepo
|
||||||
.createQueryBuilder('ct')
|
.createQueryBuilder('ct')
|
||||||
.select('COUNT(DISTINCT ct.userId)', 'cnt')
|
.select('COUNT(DISTINCT ct.userId)', 'cnt')
|
||||||
.getRawOne();
|
.getRawOne<{ cnt: string | number | null }>();
|
||||||
const teacherCount = parseInt(teacherResult?.cnt || '0', 10);
|
const teacherCount = parseInt(String(teacherResult?.cnt || '0'), 10);
|
||||||
|
|
||||||
const pendingQb = this.depositRepo
|
const pendingQb = this.depositRepo
|
||||||
.createQueryBuilder('d')
|
.createQueryBuilder('d')
|
||||||
.select('SUM(d.amount)', 'total')
|
.select('SUM(d.amount)', 'total')
|
||||||
.where('d.status = :paid', { paid: 'paid' });
|
.where('d.status = :paid', { paid: 'paid' });
|
||||||
const pendingResult = await pendingQb.getRawOne();
|
const pendingResult = await pendingQb.getRawOne<{ total: string | number | null }>();
|
||||||
const pendingDeposits = parseFloat(pendingResult?.total || '0');
|
const pendingDeposits = parseFloat(String(pendingResult?.total || '0'));
|
||||||
|
|
||||||
const activeRentals = await this.rentalRepo.count({
|
const activeRentals = await this.rentalRepo.count({
|
||||||
where: { status: 'active' as const, endDate: MoreThanOrEqual(todayStr) },
|
where: { status: 'active' as const, endDate: MoreThanOrEqual(todayStr) },
|
||||||
@@ -177,11 +181,13 @@ export class DashboardService {
|
|||||||
.select('r.building', 'building')
|
.select('r.building', 'building')
|
||||||
.addSelect('COUNT(*)', 'count')
|
.addSelect('COUNT(*)', 'count')
|
||||||
.where('o.checkOutDate IS NULL');
|
.where('o.checkOutDate IS NULL');
|
||||||
const occupancyByBuilding = await occByBldQb.groupBy('r.building').getRawMany();
|
const occupancyByBuilding = await occByBldQb
|
||||||
|
.groupBy('r.building')
|
||||||
|
.getRawMany<{ building: string | null; count: string | number }>();
|
||||||
|
|
||||||
const attendanceByStatus = attTodayStats.reduce(
|
const attendanceByStatus = attTodayStats.reduce(
|
||||||
(acc, r) => {
|
(acc, r) => {
|
||||||
acc[r.status] = parseInt(r.count, 10);
|
acc[r.status] = parseInt(String(r.count), 10);
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, number>,
|
{} as Record<string, number>,
|
||||||
@@ -193,7 +199,9 @@ export class DashboardService {
|
|||||||
.addSelect('SUM(e.amount)', 'total')
|
.addSelect('SUM(e.amount)', 'total')
|
||||||
.where('e.periodStart >= :start', { start: `${currentMonth}-01` })
|
.where('e.periodStart >= :start', { start: `${currentMonth}-01` })
|
||||||
.andWhere('e.periodEnd <= :end', { end: this.nextMonth(currentMonth) });
|
.andWhere('e.periodEnd <= :end', { end: this.nextMonth(currentMonth) });
|
||||||
const expenseByType = await expByTypeQb.groupBy('e.expenseType').getRawMany();
|
const expenseByType = await expByTypeQb
|
||||||
|
.groupBy('e.expenseType')
|
||||||
|
.getRawMany<{ type: string; total: string | number }>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalRooms,
|
totalRooms,
|
||||||
@@ -288,7 +296,7 @@ export class DashboardService {
|
|||||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||||
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today })
|
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today })
|
||||||
.groupBy('s.classroomId');
|
.groupBy('s.classroomId');
|
||||||
const schedules = await schedQb.getRawMany();
|
const schedules = await schedQb.getRawMany<{ classroomId: number; weekDays: string | number }>();
|
||||||
const rentalQb = this.rentalRepo
|
const rentalQb = this.rentalRepo
|
||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
.select('r.classroomId', 'classroomId')
|
.select('r.classroomId', 'classroomId')
|
||||||
@@ -296,11 +304,11 @@ export class DashboardService {
|
|||||||
.where('r.status = :active', { active: 'active' })
|
.where('r.status = :active', { active: 'active' })
|
||||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
||||||
.groupBy('r.classroomId');
|
.groupBy('r.classroomId');
|
||||||
const rentals = await rentalQb.getRawMany();
|
const rentals = await rentalQb.getRawMany<{ classroomId: number; rentalCount: string | number }>();
|
||||||
const sMap: Record<number, number> = {};
|
const sMap: Record<number, number> = {};
|
||||||
const rMap: Record<number, number> = {};
|
const rMap: Record<number, number> = {};
|
||||||
for (const s of schedules) sMap[s.classroomId] = parseInt(s.weekDays, 10);
|
for (const s of schedules) sMap[s.classroomId] = parseInt(String(s.weekDays), 10);
|
||||||
for (const r of rentals) rMap[r.classroomId] = parseInt(r.rentalCount, 10);
|
for (const r of rentals) rMap[r.classroomId] = parseInt(String(r.rentalCount), 10);
|
||||||
return classrooms.map((c) => ({
|
return classrooms.map((c) => ({
|
||||||
name: c.name,
|
name: c.name,
|
||||||
building: c.building || '',
|
building: c.building || '',
|
||||||
@@ -344,7 +352,7 @@ export class DashboardService {
|
|||||||
.where('s.status = :active', { active: 'active' })
|
.where('s.status = :active', { active: 'active' })
|
||||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||||
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today });
|
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today });
|
||||||
const schedResult = await schedQb.getRawOne();
|
const schedResult = await schedQb.getRawOne<{ cnt: string | number | null }>();
|
||||||
|
|
||||||
// Count classrooms with active rentals today
|
// Count classrooms with active rentals today
|
||||||
const rentalQb = this.rentalRepo
|
const rentalQb = this.rentalRepo
|
||||||
@@ -352,7 +360,7 @@ export class DashboardService {
|
|||||||
.select('COUNT(DISTINCT r.classroomId)', 'cnt')
|
.select('COUNT(DISTINCT r.classroomId)', 'cnt')
|
||||||
.where('r.status = :active', { active: 'active' })
|
.where('r.status = :active', { active: 'active' })
|
||||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
|
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today });
|
||||||
const rentalResult = await rentalQb.getRawOne();
|
const rentalResult = await rentalQb.getRawOne<{ cnt: string | number | null }>();
|
||||||
|
|
||||||
// Combine: use Set merge of both
|
// Combine: use Set merge of both
|
||||||
const combinedQb = this.scheduleRepo
|
const combinedQb = this.scheduleRepo
|
||||||
@@ -362,7 +370,7 @@ export class DashboardService {
|
|||||||
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
|
||||||
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today })
|
.andWhere('s.startDate <= :today AND s.endDate >= :today', { today })
|
||||||
.groupBy('s.classroomId');
|
.groupBy('s.classroomId');
|
||||||
const schedIds = await combinedQb.getRawMany();
|
const schedIds = await combinedQb.getRawMany<{ classroomId: number }>();
|
||||||
|
|
||||||
const combinedRentalQb = this.rentalRepo
|
const combinedRentalQb = this.rentalRepo
|
||||||
.createQueryBuilder('r')
|
.createQueryBuilder('r')
|
||||||
@@ -370,15 +378,15 @@ export class DashboardService {
|
|||||||
.where('r.status = :active', { active: 'active' })
|
.where('r.status = :active', { active: 'active' })
|
||||||
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
.andWhere('r.startDate <= :today AND r.endDate >= :today', { today })
|
||||||
.groupBy('r.classroomId');
|
.groupBy('r.classroomId');
|
||||||
const rentalIds = await combinedRentalQb.getRawMany();
|
const rentalIds = await combinedRentalQb.getRawMany<{ classroomId: number }>();
|
||||||
|
|
||||||
const allInUseIds = new Set([
|
const allInUseIds = new Set([
|
||||||
...schedIds.map((s) => s.classroomId),
|
...schedIds.map((s) => s.classroomId),
|
||||||
...rentalIds.map((r) => r.classroomId),
|
...rentalIds.map((r) => r.classroomId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const scheduleCount = parseInt(schedResult?.cnt || '0', 10);
|
const scheduleCount = parseInt(String(schedResult?.cnt || '0'), 10);
|
||||||
const rentalCount = parseInt(rentalResult?.cnt || '0', 10);
|
const rentalCount = parseInt(String(rentalResult?.cnt || '0'), 10);
|
||||||
const inUseCount = allInUseIds.size;
|
const inUseCount = allInUseIds.size;
|
||||||
const utilizationRate =
|
const utilizationRate =
|
||||||
totalClassrooms > 0 ? ((inUseCount / totalClassrooms) * 100).toFixed(1) : '0';
|
totalClassrooms > 0 ? ((inUseCount / totalClassrooms) * 100).toFixed(1) : '0';
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ export async function migrateMySQLAttendanceFKs(
|
|||||||
// Drop any existing FK constraint on schedule_id or class_id
|
// Drop any existing FK constraint on schedule_id or class_id
|
||||||
const fkColumns = ['schedule_id', 'class_id'];
|
const fkColumns = ['schedule_id', 'class_id'];
|
||||||
for (const col of fkColumns) {
|
for (const col of fkColumns) {
|
||||||
const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(
|
const fkRows = (await runner.query(
|
||||||
`
|
`
|
||||||
SELECT CONSTRAINT_NAME
|
SELECT CONSTRAINT_NAME
|
||||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||||
@@ -148,7 +148,7 @@ export async function migrateMySQLAttendanceFKs(
|
|||||||
AND REFERENCED_TABLE_NAME IS NOT NULL
|
AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||||
`,
|
`,
|
||||||
[col],
|
[col],
|
||||||
);
|
)) as Array<{ CONSTRAINT_NAME: string }>;
|
||||||
|
|
||||||
for (const row of fkRows) {
|
for (const row of fkRows) {
|
||||||
try {
|
try {
|
||||||
@@ -168,7 +168,7 @@ export async function migrateMySQLAttendanceFKs(
|
|||||||
];
|
];
|
||||||
for (const c of constraints) {
|
for (const c of constraints) {
|
||||||
// Only skip if RESTRICT constraint is already confirmed via information_schema
|
// Only skip if RESTRICT constraint is already confirmed via information_schema
|
||||||
const existing: Array<{ DELETE_RULE: string }> = await runner.query(
|
const existing = (await runner.query(
|
||||||
`
|
`
|
||||||
SELECT DELETE_RULE
|
SELECT DELETE_RULE
|
||||||
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
|
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
|
||||||
@@ -177,7 +177,7 @@ export async function migrateMySQLAttendanceFKs(
|
|||||||
AND CONSTRAINT_NAME = ?
|
AND CONSTRAINT_NAME = ?
|
||||||
`,
|
`,
|
||||||
[c.name],
|
[c.name],
|
||||||
);
|
)) as Array<{ DELETE_RULE: string }>;
|
||||||
|
|
||||||
if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') {
|
if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') {
|
||||||
logger.log(`考勤场次删除保护约束已存在: ${c.name}`);
|
logger.log(`考勤场次删除保护约束已存在: ${c.name}`);
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { DataSource } from 'typeorm';
|
|||||||
import { uuidV7 } from '../common/uuid-v7';
|
import { uuidV7 } from '../common/uuid-v7';
|
||||||
import { withQueryRunner } from './database-migrations.runner';
|
import { withQueryRunner } from './database-migrations.runner';
|
||||||
|
|
||||||
|
/** 迁移脚本中用到的 organizations 表最小行结构。 */
|
||||||
|
interface OrganizationRow {
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */
|
||||||
|
function stringify(value: unknown): string {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
export async function backfillOrganizations(
|
export async function backfillOrganizations(
|
||||||
dataSource: DataSource,
|
dataSource: DataSource,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -17,8 +27,8 @@ export async function backfillOrganizations(
|
|||||||
const tableNames = new Set(tables.map((table) => table.name));
|
const tableNames = new Set(tables.map((table) => table.name));
|
||||||
if (!tableNames.has('organizations')) return;
|
if (!tableNames.has('organizations')) return;
|
||||||
|
|
||||||
const organizationRows = () =>
|
const organizationRows = (): Promise<OrganizationRow[]> =>
|
||||||
runner.query('SELECT * FROM organizations WHERE is_host = 1 LIMIT 1');
|
runner.query('SELECT * FROM organizations WHERE is_host = 1 LIMIT 1') as Promise<OrganizationRow[]>;
|
||||||
let host = (await organizationRows())[0];
|
let host = (await organizationRows())[0];
|
||||||
if (!host) {
|
if (!host) {
|
||||||
await runner.query(
|
await runner.query(
|
||||||
@@ -39,14 +49,17 @@ export async function backfillOrganizations(
|
|||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
if (tableNames.has('tenants')) {
|
if (tableNames.has('tenants')) {
|
||||||
const legacyTenants: Array<Record<string, unknown>> =
|
const legacyTenants = (await runner.query('SELECT * FROM tenants')) as Array<
|
||||||
await runner.query('SELECT * FROM tenants');
|
Record<string, unknown>
|
||||||
|
>;
|
||||||
for (const legacy of legacyTenants) {
|
for (const legacy of legacyTenants) {
|
||||||
const name = String(legacy.name || '').trim();
|
const name = stringify(legacy.name || '').trim();
|
||||||
if (!name) continue;
|
if (!name) continue;
|
||||||
let external = (
|
const externalRows = (await runner.query(
|
||||||
await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name])
|
'SELECT * FROM organizations WHERE name = ? LIMIT 1',
|
||||||
)[0];
|
[name],
|
||||||
|
)) as OrganizationRow[];
|
||||||
|
let external = externalRows[0];
|
||||||
if (!external) {
|
if (!external) {
|
||||||
await runner.query(
|
await runner.query(
|
||||||
`INSERT INTO organizations (public_id, code, name, is_host, contact_name, phone, color, notes, status, created_at, updated_at)
|
`INSERT INTO organizations (public_id, code, name, is_host, contact_name, phone, color, notes, status, created_at, updated_at)
|
||||||
@@ -64,7 +77,7 @@ export async function backfillOrganizations(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
external = (
|
external = (
|
||||||
await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name])
|
(await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name])) as OrganizationRow[]
|
||||||
)[0];
|
)[0];
|
||||||
}
|
}
|
||||||
if (!external) continue;
|
if (!external) continue;
|
||||||
@@ -158,7 +171,11 @@ export async function normalizeClassDates(
|
|||||||
.map((column) => `${column} = ${normalizedDate(column)}`)
|
.map((column) => `${column} = ${normalizedDate(column)}`)
|
||||||
.join(',\n ');
|
.join(',\n ');
|
||||||
const predicates = columns.map((column) => needsNormalization(column)).join('\n OR ');
|
const predicates = columns.map((column) => needsNormalization(column)).join('\n OR ');
|
||||||
const result = await dataSource.transaction((manager) =>
|
interface UpdateResultLike {
|
||||||
|
changes?: number;
|
||||||
|
affectedRows?: number;
|
||||||
|
}
|
||||||
|
const result = await dataSource.transaction<UpdateResultLike | undefined>((manager) =>
|
||||||
manager.query(`
|
manager.query(`
|
||||||
UPDATE classes
|
UPDATE classes
|
||||||
SET
|
SET
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { logAudit } from '../common/with-audit-log';
|
import { logAudit } from '../common/with-audit-log';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user: AuthenticatedUser;
|
||||||
|
ip?: string;
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('deposits')
|
@Controller('deposits')
|
||||||
@@ -77,7 +84,7 @@ export class DepositsController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission('deposit:create')
|
@RequirePermission('deposit:create')
|
||||||
async create(@Body() dto: CreateDepositDto, @Request() req: any) {
|
async create(@Body() dto: CreateDepositDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.create(dto, req.user?.id);
|
const result = await this.service.create(dto, req.user?.id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '押金管理', action: '收取押金', targetId: result.id, targetType: 'deposit', detail: `学生${dto.studentId} ¥${dto.amount}`,
|
module: '押金管理', action: '收取押金', targetId: result.id, targetType: 'deposit', detail: `学生${dto.studentId} ¥${dto.amount}`,
|
||||||
@@ -89,7 +96,7 @@ export class DepositsController {
|
|||||||
|
|
||||||
@Post('batch')
|
@Post('batch')
|
||||||
@RequirePermission('deposit:create')
|
@RequirePermission('deposit:create')
|
||||||
async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: any) {
|
async batchCreate(@Body() dto: BatchCreateDepositDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchCreate(dto, req.user?.id);
|
const result = await this.service.batchCreate(dto, req.user?.id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '押金管理', action: '批量收取押金', targetType: 'deposit', detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`,
|
module: '押金管理', action: '批量收取押金', targetType: 'deposit', detail: `批量收取${result.count}人,每人¥${result.amount}${dto.roomType ? `,房型:${dto.roomType}` : ''}${dto.notes ? `,备注:${dto.notes}` : ''}`,
|
||||||
@@ -140,7 +147,7 @@ export class DepositsController {
|
|||||||
|
|
||||||
@Put(':id/refund')
|
@Put(':id/refund')
|
||||||
@RequirePermission('deposit:refund')
|
@RequirePermission('deposit:refund')
|
||||||
async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: any) {
|
async refund(@Param('id', ParseIntPipe) id: number, @Body() dto: RefundDepositDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.refund(id, dto, req.user?.id);
|
const result = await this.service.refund(id, dto, req.user?.id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '押金管理', action: '退还押金', targetId: id, targetType: 'deposit', detail: `退还全部可用押金 ¥${result.refundAmount}`,
|
module: '押金管理', action: '退还押金', targetId: id, targetType: 'deposit', detail: `退还全部可用押金 ¥${result.refundAmount}`,
|
||||||
@@ -167,7 +174,7 @@ export class DepositsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('deposit:delete')
|
@RequirePermission('deposit:delete')
|
||||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.remove(id);
|
const result = await this.service.remove(id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '押金管理', action: '归档押金记录', targetId: id, targetType: 'deposit',
|
module: '押金管理', action: '归档押金记录', targetId: id, targetType: 'deposit',
|
||||||
@@ -177,7 +184,7 @@ export class DepositsController {
|
|||||||
|
|
||||||
@Delete(':id/permanent')
|
@Delete(':id/permanent')
|
||||||
@RequirePermission('deposit:purge')
|
@RequirePermission('deposit:purge')
|
||||||
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.purge(id);
|
const result = await this.service.purge(id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '押金管理', action: '永久删除押金', targetId: id, targetType: 'deposit', detail: '物理删除,不可恢复',
|
module: '押金管理', action: '永久删除押金', targetId: id, targetType: 'deposit', detail: '物理删除,不可恢复',
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { BatchCreateDepositDto, CreateDepositDto, RefundDepositDto } from './dto
|
|||||||
|
|
||||||
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
|
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
|
||||||
|
|
||||||
|
/** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */
|
||||||
|
type RawScalarRow = Record<string, string | number | Date | null>;
|
||||||
|
|
||||||
const capacityRoomTypeText: Record<number, string> = {
|
const capacityRoomTypeText: Record<number, string> = {
|
||||||
1: '单人间',
|
1: '单人间',
|
||||||
2: '二人间',
|
2: '二人间',
|
||||||
@@ -99,7 +102,7 @@ export class DepositsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = await qb.getRawMany();
|
const rows = await qb.getRawMany<RawScalarRow>();
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
studentId: Number(row.studentId),
|
studentId: Number(row.studentId),
|
||||||
studentName: row.studentName,
|
studentName: row.studentName,
|
||||||
@@ -107,9 +110,12 @@ export class DepositsService {
|
|||||||
roomId: Number(row.roomId),
|
roomId: Number(row.roomId),
|
||||||
roomNumber: row.roomNumber,
|
roomNumber: row.roomNumber,
|
||||||
building: row.building ?? null,
|
building: row.building ?? null,
|
||||||
roomType: normalizeRoomType(row.roomType, row.capacity),
|
roomType: normalizeRoomType(
|
||||||
|
row.roomType == null ? null : String(row.roomType),
|
||||||
|
row.capacity == null ? null : Number(row.capacity),
|
||||||
|
),
|
||||||
capacity: Number(row.capacity),
|
capacity: Number(row.capacity),
|
||||||
depositAmount: money(row.depositAmount),
|
depositAmount: money(row.depositAmount == null ? null : Number(row.depositAmount)),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +204,7 @@ export class DepositsService {
|
|||||||
const rows = await qb
|
const rows = await qb
|
||||||
.orderBy('d.createdAt', 'DESC')
|
.orderBy('d.createdAt', 'DESC')
|
||||||
.limit(Math.max(1, Math.min(query?.limit ?? 20, 50)))
|
.limit(Math.max(1, Math.min(query?.limit ?? 20, 50)))
|
||||||
.getRawMany<Record<string, unknown>>();
|
.getRawMany<RawScalarRow>();
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
id: Number(row.id),
|
id: Number(row.id),
|
||||||
studentName: row.studentName == null ? '' : String(row.studentName),
|
studentName: row.studentName == null ? '' : String(row.studentName),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const mappingTransformer = {
|
|||||||
},
|
},
|
||||||
from(value: string | null): JinshujuFieldMapping {
|
from(value: string | null): JinshujuFieldMapping {
|
||||||
if (!value) return {};
|
if (!value) return {};
|
||||||
return JSON.parse(value);
|
return JSON.parse(value) as JinshujuFieldMapping;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export class QueryExamDto {
|
|||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
if (value === 'true' || value === '1') return true;
|
if (value === 'true' || value === '1') return true;
|
||||||
if (value === 'false' || value === '0') return false;
|
if (value === 'false' || value === '0') return false;
|
||||||
return value;
|
return value as boolean;
|
||||||
})
|
})
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isArchived?: boolean;
|
isArchived?: boolean;
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user: AuthenticatedUser;
|
||||||
|
ip?: string;
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('expense-types')
|
@Controller('expense-types')
|
||||||
@@ -28,7 +35,7 @@ export class ExpenseTypesController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission('expense:create')
|
@RequirePermission('expense:create')
|
||||||
async create(@Body() dto: CreateExpenseTypeDto, @Request() req: any) {
|
async create(@Body() dto: CreateExpenseTypeDto, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.create(dto);
|
const result = await this.service.create(dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
@@ -47,7 +54,7 @@ export class ExpenseTypesController {
|
|||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequirePermission('expense:edit')
|
@RequirePermission('expense:edit')
|
||||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseTypeDto, @Request() req: any) {
|
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateExpenseTypeDto, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.update(+id, dto);
|
const result = await this.service.update(+id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
@@ -66,7 +73,7 @@ export class ExpenseTypesController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('expense:delete')
|
@RequirePermission('expense:delete')
|
||||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
await this.service.remove(+id);
|
await this.service.remove(+id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
|
|||||||
@@ -290,8 +290,10 @@ export class ExpenseOperationsService {
|
|||||||
skipped++;
|
skipped++;
|
||||||
errors.push(`第${rowNum}行: ${row.roomNumber} 无有效金额`);
|
errors.push(`第${rowNum}行: ${row.roomNumber} 无有效金额`);
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
errors.push(`第${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`);
|
errors.push(
|
||||||
|
`第${rowNum}行: ${row.roomNumber} 导入失败 - ${e instanceof Error ? e.message : '未知错误'}`,
|
||||||
|
);
|
||||||
skipped++;
|
skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -404,8 +406,10 @@ export class ExpenseOperationsService {
|
|||||||
// 校验金额
|
// 校验金额
|
||||||
try {
|
try {
|
||||||
this.assertPositiveAmount(row.amount);
|
this.assertPositiveAmount(row.amount);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
errors.push(`第${rowNum}行: ${row.studentName} ${e.message}`);
|
errors.push(
|
||||||
|
`第${rowNum}行: ${row.studentName} ${e instanceof Error ? e.message : '未知错误'}`,
|
||||||
|
);
|
||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -422,8 +426,10 @@ export class ExpenseOperationsService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
imported++;
|
imported++;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
errors.push(`第${rowNum}行: ${row.studentName} 导入失败 - ${e.message}`);
|
errors.push(
|
||||||
|
`第${rowNum}行: ${row.studentName} 导入失败 - ${e instanceof Error ? e.message : '未知错误'}`,
|
||||||
|
);
|
||||||
skipped++;
|
skipped++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,23 +34,60 @@ import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
|||||||
import { logAudit } from '../common/with-audit-log';
|
import { logAudit } from '../common/with-audit-log';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import { BatchIdsDto } from '../common/batch-ids.dto';
|
import { BatchIdsDto } from '../common/batch-ids.dto';
|
||||||
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
|
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user: AuthenticatedUser;
|
||||||
|
ip?: string;
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ExcelJS 单元格解析出的标量值 */
|
||||||
|
type CellScalar = string | number | boolean | Date | null | undefined;
|
||||||
|
|
||||||
|
interface UtilityImportRow {
|
||||||
|
periodStr: string;
|
||||||
|
roomNumber: string;
|
||||||
|
electricityAmount: number;
|
||||||
|
electricityFee: number;
|
||||||
|
waterAmount: number;
|
||||||
|
waterFee: number;
|
||||||
|
totalFee: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PersonalImportRow {
|
||||||
|
studentName: string;
|
||||||
|
expenseType: string;
|
||||||
|
amount: number;
|
||||||
|
expenseDate: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** 提取 ExcelJS 单元格的真实值,兼容公式、富文本、日期、超链接等情况 */
|
/** 提取 ExcelJS 单元格的真实值,兼容公式、富文本、日期、超链接等情况 */
|
||||||
function readCell(cell: ExcelJS.Cell): any {
|
function readCell(cell: ExcelJS.Cell | undefined): CellScalar {
|
||||||
let v: any = cell?.value;
|
let v: unknown = cell?.value;
|
||||||
if (v == null) return '';
|
if (v == null) return '';
|
||||||
if (typeof v === 'object') {
|
if (typeof v === 'object' && !(v instanceof Date)) {
|
||||||
|
const record = v as Record<string, unknown>;
|
||||||
// 公式单元格:{ formula, result }
|
// 公式单元格:{ formula, result }
|
||||||
if ('result' in v) v = v.result;
|
if ('result' in record) v = record.result;
|
||||||
// 富文本:{ richText: [...] }
|
// 富文本:{ richText: [...] }
|
||||||
else if ('richText' in v && Array.isArray(v.richText)) {
|
else if ('richText' in record && Array.isArray(record.richText)) {
|
||||||
return v.richText.map((r: any) => r.text || '').join('');
|
return record.richText
|
||||||
|
.map((r) =>
|
||||||
|
r !== null && typeof r === 'object' && 'text' in r
|
||||||
|
? (r as { text?: string }).text || ''
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
}
|
}
|
||||||
// 超链接:{ text, hyperlink }
|
// 超链接:{ text, hyperlink }
|
||||||
else if ('text' in v) v = v.text;
|
else if ('text' in record) v = record.text;
|
||||||
// 错误值:{ error: '#DIV/0!' }
|
// 错误值:{ error: '#DIV/0!' }
|
||||||
else if ('error' in v) return '';
|
else if ('error' in record) return '';
|
||||||
|
else return '';
|
||||||
}
|
}
|
||||||
if (v instanceof Date) {
|
if (v instanceof Date) {
|
||||||
const y = v.getFullYear();
|
const y = v.getFullYear();
|
||||||
@@ -58,7 +95,8 @@ function readCell(cell: ExcelJS.Cell): any {
|
|||||||
const d = String(v.getDate()).padStart(2, '0');
|
const d = String(v.getDate()).padStart(2, '0');
|
||||||
return `${y}-${m}-${d}`;
|
return `${y}-${m}-${d}`;
|
||||||
}
|
}
|
||||||
return v;
|
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;
|
||||||
|
return v == null ? v : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function readCellNum(cell: ExcelJS.Cell): number {
|
function readCellNum(cell: ExcelJS.Cell): number {
|
||||||
@@ -95,7 +133,7 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Post('student-utility')
|
@Post('student-utility')
|
||||||
@RequirePermission('expense:create')
|
@RequirePermission('expense:create')
|
||||||
async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: any) {
|
async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.createStudentUtilityBill(dto, req.user?.id);
|
const result = await this.service.createStudentUtilityBill(dto, req.user?.id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '录入学生水电费并出账', targetId: result.bill.id, targetType: 'bill', detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`,
|
module: '费用管理', action: '录入学生水电费并出账', targetId: result.bill.id, targetType: 'bill', detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`,
|
||||||
@@ -105,7 +143,7 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Post('room')
|
@Post('room')
|
||||||
@RequirePermission('expense:create')
|
@RequirePermission('expense:create')
|
||||||
async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) {
|
async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.createRoomExpense(dto, req.user?.id);
|
const result = await this.service.createRoomExpense(dto, req.user?.id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '录入费用', targetId: result.id, targetType: 'room_expense', detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`,
|
module: '费用管理', action: '录入费用', targetId: result.id, targetType: 'room_expense', detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`,
|
||||||
@@ -115,7 +153,7 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Post('room/batch')
|
@Post('room/batch')
|
||||||
@RequirePermission('expense:create')
|
@RequirePermission('expense:create')
|
||||||
async batchCreateRoomExpenses(@Body() dto: BatchRoomExpenseDto, @Request() req: any) {
|
async batchCreateRoomExpenses(@Body() dto: BatchRoomExpenseDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchCreateRoomExpenses(dto, req.user?.id);
|
const result = await this.service.batchCreateRoomExpenses(dto, req.user?.id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '批量录入费用', detail: JSON.stringify(dto),
|
module: '费用管理', action: '批量录入费用', detail: JSON.stringify(dto),
|
||||||
@@ -131,7 +169,7 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Delete('room/:id')
|
@Delete('room/:id')
|
||||||
@RequirePermission('expense:delete')
|
@RequirePermission('expense:delete')
|
||||||
async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.deleteRoomExpense(id);
|
const result = await this.service.deleteRoomExpense(id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '归档费用', targetId: id, targetType: 'room_expense',
|
module: '费用管理', action: '归档费用', targetId: id, targetType: 'room_expense',
|
||||||
@@ -141,7 +179,7 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Post('room/batch-delete')
|
@Post('room/batch-delete')
|
||||||
@RequirePermission('expense:delete')
|
@RequirePermission('expense:delete')
|
||||||
async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
|
async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchDeleteRoomExpenses(body.ids || []);
|
const result = await this.service.batchDeleteRoomExpenses(body.ids || []);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '批量归档宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`,
|
module: '费用管理', action: '批量归档宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||||
@@ -151,7 +189,7 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Delete('room/:id/permanent')
|
@Delete('room/:id/permanent')
|
||||||
@RequirePermission('expense:purge')
|
@RequirePermission('expense:purge')
|
||||||
async purgeRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async purgeRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.purgeRoomExpense(id);
|
const result = await this.service.purgeRoomExpense(id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '永久删除宿舍费用', targetId: id, targetType: 'room_expense', detail: '物理删除,不可恢复',
|
module: '费用管理', action: '永久删除宿舍费用', targetId: id, targetType: 'room_expense', detail: '物理删除,不可恢复',
|
||||||
@@ -161,7 +199,7 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Post('room/batch-permanent-delete')
|
@Post('room/batch-permanent-delete')
|
||||||
@RequirePermission('expense:purge')
|
@RequirePermission('expense:purge')
|
||||||
async batchPurgeRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
|
async batchPurgeRoomExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchPurgeRoomExpenses(body.ids || []);
|
const result = await this.service.batchPurgeRoomExpenses(body.ids || []);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '批量永久删除宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`,
|
module: '费用管理', action: '批量永久删除宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||||
@@ -172,7 +210,7 @@ export class ExpensesController {
|
|||||||
@Put('room/batch-restore')
|
@Put('room/batch-restore')
|
||||||
@RequirePermission('expense:edit')
|
@RequirePermission('expense:edit')
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
||||||
async batchRestoreRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: any) {
|
async batchRestoreRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchRestoreRoomExpenses(dto.ids);
|
const result = await this.service.batchRestoreRoomExpenses(dto.ids);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '批量恢复宿舍费用', detail: `IDs: ${dto.ids.join(',')}`,
|
module: '费用管理', action: '批量恢复宿舍费用', detail: `IDs: ${dto.ids.join(',')}`,
|
||||||
@@ -185,7 +223,7 @@ export class ExpensesController {
|
|||||||
async updateRoomExpense(
|
async updateRoomExpense(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateRoomExpenseDto,
|
@Body() dto: UpdateRoomExpenseDto,
|
||||||
@Request() req: any,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const result = await this.service.updateRoomExpense(id, dto);
|
const result = await this.service.updateRoomExpense(id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
@@ -196,7 +234,7 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Post('personal')
|
@Post('personal')
|
||||||
@RequirePermission('expense:create')
|
@RequirePermission('expense:create')
|
||||||
async createPersonalExpense(@Body() dto: CreatePersonalExpenseDto, @Request() req: any) {
|
async createPersonalExpense(@Body() dto: CreatePersonalExpenseDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.createPersonalExpense(dto, req.user?.id);
|
const result = await this.service.createPersonalExpense(dto, req.user?.id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '录入费用', detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`,
|
module: '费用管理', action: '录入费用', detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`,
|
||||||
@@ -212,7 +250,7 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Delete('personal/:id')
|
@Delete('personal/:id')
|
||||||
@RequirePermission('expense:delete')
|
@RequirePermission('expense:delete')
|
||||||
async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.deletePersonalExpense(id);
|
const result = await this.service.deletePersonalExpense(id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '归档费用', targetId: id,
|
module: '费用管理', action: '归档费用', targetId: id,
|
||||||
@@ -222,7 +260,7 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Post('personal/batch-delete')
|
@Post('personal/batch-delete')
|
||||||
@RequirePermission('expense:delete')
|
@RequirePermission('expense:delete')
|
||||||
async batchDeletePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
|
async batchDeletePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchDeletePersonalExpenses(body.ids || []);
|
const result = await this.service.batchDeletePersonalExpenses(body.ids || []);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '批量归档个人费用', detail: `IDs: ${(body.ids || []).join(',')}`,
|
module: '费用管理', action: '批量归档个人费用', detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||||
@@ -232,7 +270,7 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Delete('personal/:id/permanent')
|
@Delete('personal/:id/permanent')
|
||||||
@RequirePermission('expense:purge')
|
@RequirePermission('expense:purge')
|
||||||
async purgePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async purgePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.purgePersonalExpense(id);
|
const result = await this.service.purgePersonalExpense(id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '永久删除个人费用', targetId: id, targetType: 'personal_expense', detail: '物理删除,不可恢复',
|
module: '费用管理', action: '永久删除个人费用', targetId: id, targetType: 'personal_expense', detail: '物理删除,不可恢复',
|
||||||
@@ -242,7 +280,7 @@ export class ExpensesController {
|
|||||||
|
|
||||||
@Post('personal/batch-permanent-delete')
|
@Post('personal/batch-permanent-delete')
|
||||||
@RequirePermission('expense:purge')
|
@RequirePermission('expense:purge')
|
||||||
async batchPurgePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
|
async batchPurgePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchPurgePersonalExpenses(body.ids || []);
|
const result = await this.service.batchPurgePersonalExpenses(body.ids || []);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '批量永久删除个人费用', detail: `IDs: ${(body.ids || []).join(',')}`,
|
module: '费用管理', action: '批量永久删除个人费用', detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||||
@@ -253,7 +291,7 @@ export class ExpensesController {
|
|||||||
@Put('personal/batch-restore')
|
@Put('personal/batch-restore')
|
||||||
@RequirePermission('expense:edit')
|
@RequirePermission('expense:edit')
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
||||||
async batchRestorePersonalExpenses(@Body() dto: BatchIdsDto, @Request() req: any) {
|
async batchRestorePersonalExpenses(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchRestorePersonalExpenses(dto.ids);
|
const result = await this.service.batchRestorePersonalExpenses(dto.ids);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '费用管理', action: '批量恢复个人费用', detail: `IDs: ${dto.ids.join(',')}`,
|
module: '费用管理', action: '批量恢复个人费用', detail: `IDs: ${dto.ids.join(',')}`,
|
||||||
@@ -266,7 +304,7 @@ export class ExpensesController {
|
|||||||
async updatePersonalExpense(
|
async updatePersonalExpense(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdatePersonalExpenseDto,
|
@Body() dto: UpdatePersonalExpenseDto,
|
||||||
@Request() req: any,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const result = await this.service.updatePersonalExpense(id, dto);
|
const result = await this.service.updatePersonalExpense(id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
@@ -314,11 +352,11 @@ export class ExpensesController {
|
|||||||
@Post('utility/import')
|
@Post('utility/import')
|
||||||
@RequirePermission('expense:create')
|
@RequirePermission('expense:create')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||||
const ws = workbook.worksheets[0];
|
const ws = workbook.worksheets[0];
|
||||||
const rows: any[] = [];
|
const rows: UtilityImportRow[] = [];
|
||||||
ws.eachRow((row, idx) => {
|
ws.eachRow((row, idx) => {
|
||||||
if (idx === 1) return; // 跳过表头
|
if (idx === 1) return; // 跳过表头
|
||||||
const roomNumber = readCellStr(row.getCell(3));
|
const roomNumber = readCellStr(row.getCell(3));
|
||||||
@@ -379,11 +417,11 @@ export class ExpensesController {
|
|||||||
@Post('personal/import')
|
@Post('personal/import')
|
||||||
@RequirePermission('expense:create')
|
@RequirePermission('expense:create')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||||
const ws = workbook.worksheets[0];
|
const ws = workbook.worksheets[0];
|
||||||
const rows: any[] = [];
|
const rows: PersonalImportRow[] = [];
|
||||||
ws.eachRow((row, idx) => {
|
ws.eachRow((row, idx) => {
|
||||||
if (idx === 1) return;
|
if (idx === 1) return;
|
||||||
const studentName = readCellStr(row.getCell(1));
|
const studentName = readCellStr(row.getCell(1));
|
||||||
@@ -417,7 +455,7 @@ export class ExpensesController {
|
|||||||
{ header: '说明', key: 'description', width: 30 },
|
{ header: '说明', key: 'description', width: 30 },
|
||||||
];
|
];
|
||||||
ws.getRow(1).font = { bold: true };
|
ws.getRow(1).font = { bold: true };
|
||||||
data.forEach((d: any) => {
|
data.forEach((d: PersonalExpense) => {
|
||||||
ws.addRow({
|
ws.addRow({
|
||||||
studentName: d.student?.name || '',
|
studentName: d.student?.name || '',
|
||||||
expenseType: d.expenseType,
|
expenseType: d.expenseType,
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ import {
|
|||||||
import { BillsService } from '../bills/bills.service';
|
import { BillsService } from '../bills/bills.service';
|
||||||
import { ExpenseOperationsService } from './expense-operations.service';
|
import { ExpenseOperationsService } from './expense-operations.service';
|
||||||
|
|
||||||
|
/** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */
|
||||||
|
type RawScalarRow = Record<string, string | number | Date | null>;
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpensesService {
|
export class ExpensesService {
|
||||||
@@ -154,7 +157,7 @@ export class ExpensesService {
|
|||||||
const roomRows = await roomQb
|
const roomRows = await roomQb
|
||||||
.orderBy('e.createdAt', 'DESC')
|
.orderBy('e.createdAt', 'DESC')
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.getRawMany<Record<string, unknown>>();
|
.getRawMany<RawScalarRow>();
|
||||||
|
|
||||||
const personalQb = this.personalExpRepo
|
const personalQb = this.personalExpRepo
|
||||||
.createQueryBuilder('e')
|
.createQueryBuilder('e')
|
||||||
@@ -186,7 +189,7 @@ export class ExpensesService {
|
|||||||
const personalRows = await personalQb
|
const personalRows = await personalQb
|
||||||
.orderBy('e.createdAt', 'DESC')
|
.orderBy('e.createdAt', 'DESC')
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.getRawMany<Record<string, unknown>>();
|
.getRawMany<RawScalarRow>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
roomExpenses: roomRows.map((row) => ({
|
roomExpenses: roomRows.map((row) => ({
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { ImportRun } from './entities/import-run.entity';
|
|||||||
import { ImportStep } from './entities/import-step.entity';
|
import { ImportStep } from './entities/import-step.entity';
|
||||||
import { ImportRow } from './entities/import-row.entity';
|
import { ImportRow } from './entities/import-row.entity';
|
||||||
import { ImportsService } from './imports.service';
|
import { ImportsService } from './imports.service';
|
||||||
import * as workbookModule from './imports.workbook';
|
|
||||||
import type { ParsedImportFile } from './imports.types';
|
import type { ParsedImportFile } from './imports.types';
|
||||||
|
|
||||||
function makeRowsRepo() {
|
function makeRowsRepo() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { ImportRun } from './entities/import-run.entity';
|
import { ImportRun } from './entities/import-run.entity';
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { parseSheets } from './imports.workbook';
|
|||||||
async function xlsxBuffer(rows: Array<Array<unknown>>): Promise<Buffer> {
|
async function xlsxBuffer(rows: Array<Array<unknown>>): Promise<Buffer> {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
const worksheet = workbook.addWorksheet('名单');
|
const worksheet = workbook.addWorksheet('名单');
|
||||||
rows.forEach((row, index) => worksheet.addRow(row));
|
rows.forEach((row, _index) => worksheet.addRow(row));
|
||||||
return (await workbook.xlsx.writeBuffer()) as Buffer;
|
return (await workbook.xlsx.writeBuffer()) as Buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import {
|
|||||||
SaveIntegrationConfigDto,
|
SaveIntegrationConfigDto,
|
||||||
} from './dto/config.dto';
|
} from './dto/config.dto';
|
||||||
|
|
||||||
|
/** 第三方配置在 content JSON 中的存储结构。 */
|
||||||
|
interface StoredConfigShape {
|
||||||
|
config?: unknown;
|
||||||
|
appSecret?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IntegrationConfigService {
|
export class IntegrationConfigService {
|
||||||
private readonly logger = new Logger(IntegrationConfigService.name);
|
private readonly logger = new Logger(IntegrationConfigService.name);
|
||||||
@@ -22,6 +28,18 @@ export class IntegrationConfigService {
|
|||||||
private readonly detailRepo: Repository<IntegrationConfigDetail>,
|
private readonly detailRepo: Repository<IntegrationConfigDetail>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
/** String() 包装:避免 unknown 收窄后触发 no-base-to-string。 */
|
||||||
|
private stringify(value: unknown): string {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解析 content JSON 并取 config 段(无 config 时回退整个对象)。 */
|
||||||
|
private parseStoredConfig(content: string): Record<string, unknown> {
|
||||||
|
const parsed = JSON.parse(content) as StoredConfigShape;
|
||||||
|
const rawCfg = parsed.config || parsed;
|
||||||
|
return rawCfg && typeof rawCfg === 'object' ? (rawCfg as Record<string, unknown>) : {};
|
||||||
|
}
|
||||||
|
|
||||||
/** 获取或创建主配置(全局单例) */
|
/** 获取或创建主配置(全局单例) */
|
||||||
private async ensureConfig(): Promise<IntegrationConfig> {
|
private async ensureConfig(): Promise<IntegrationConfig> {
|
||||||
let config = await this.configRepo.findOne({ where: { type: 'THIRD' } });
|
let config = await this.configRepo.findOne({ where: { type: 'THIRD' } });
|
||||||
@@ -80,8 +98,7 @@ export class IntegrationConfigService {
|
|||||||
if (existingDetail && existingDetail.content) {
|
if (existingDetail && existingDetail.content) {
|
||||||
if (!finalConfig.appSecret) {
|
if (!finalConfig.appSecret) {
|
||||||
try {
|
try {
|
||||||
const oldParsed = JSON.parse(existingDetail.content);
|
const oldCfg = this.parseStoredConfig(existingDetail.content);
|
||||||
const oldCfg = oldParsed.config || oldParsed;
|
|
||||||
if (oldCfg.appSecret) finalConfig.appSecret = oldCfg.appSecret;
|
if (oldCfg.appSecret) finalConfig.appSecret = oldCfg.appSecret;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
@@ -156,7 +173,7 @@ export class IntegrationConfigService {
|
|||||||
* 供同步逻辑使用:读原始(未脱敏)配置。
|
* 供同步逻辑使用:读原始(未脱敏)配置。
|
||||||
* 返回 { agentId, appSecret, corpId, appId? } 或 null。
|
* 返回 { agentId, appSecret, corpId, appId? } 或 null。
|
||||||
*/
|
*/
|
||||||
async getRawConfig(type: string): Promise<Record<string, any> | null> {
|
async getRawConfig(type: string): Promise<Record<string, unknown> | null> {
|
||||||
const config = await this.ensureConfig();
|
const config = await this.ensureConfig();
|
||||||
const detailType = this.getDetailType(type);
|
const detailType = this.getDetailType(type);
|
||||||
const detail = await this.detailRepo.findOne({
|
const detail = await this.detailRepo.findOne({
|
||||||
@@ -164,8 +181,7 @@ export class IntegrationConfigService {
|
|||||||
});
|
});
|
||||||
if (!detail || !detail.content) return null;
|
if (!detail || !detail.content) return null;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(detail.content);
|
return this.parseStoredConfig(detail.content);
|
||||||
return parsed.config || parsed;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -192,8 +208,8 @@ export class IntegrationConfigService {
|
|||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
if (type.toUpperCase() === 'DINGTALK') {
|
if (type.toUpperCase() === 'DINGTALK') {
|
||||||
const appKey = String(config.agentId || '');
|
const appKey = this.stringify(config.agentId || '');
|
||||||
const appSecret = String(config.appSecret || '');
|
const appSecret = this.stringify(config.appSecret || '');
|
||||||
if (!appKey || !appSecret) return null;
|
if (!appKey || !appSecret) return null;
|
||||||
return await this.fetchDingTalkToken(appKey, appSecret);
|
return await this.fetchDingTalkToken(appKey, appSecret);
|
||||||
}
|
}
|
||||||
@@ -227,10 +243,9 @@ export class IntegrationConfigService {
|
|||||||
private parseAndMaskConfig(content: string | null): unknown {
|
private parseAndMaskConfig(content: string | null): unknown {
|
||||||
if (!content) return {};
|
if (!content) return {};
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content);
|
const source = this.parseStoredConfig(content);
|
||||||
const source = parsed.config || parsed;
|
|
||||||
if (!source || typeof source !== 'object' || Array.isArray(source)) return {};
|
if (!source || typeof source !== 'object' || Array.isArray(source)) return {};
|
||||||
const { appSecret: _appSecret, ...masked } = source as Record<string, unknown>;
|
const { appSecret: _appSecret, ...masked } = source;
|
||||||
return masked;
|
return masked;
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {};
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export class WeComService {
|
|||||||
const corpSecret = process.env.WECOM_CORP_SECRET!;
|
const corpSecret = process.env.WECOM_CORP_SECRET!;
|
||||||
const url = `${WECOM_API_BASE}${WECOM_TOKEN_PATH}?corpid=${corpId}&corpsecret=${corpSecret}`;
|
const url = `${WECOM_API_BASE}${WECOM_TOKEN_PATH}?corpid=${corpId}&corpsecret=${corpSecret}`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const body: WeComTokenResponse = await res.json();
|
const body = (await res.json()) as WeComTokenResponse;
|
||||||
if (body.errcode !== 0) {
|
if (body.errcode !== 0) {
|
||||||
throw new Error(`WeCom gettoken failed: ${body.errmsg} (${body.errcode})`);
|
throw new Error(`WeCom gettoken failed: ${body.errmsg} (${body.errcode})`);
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ export class WeComService {
|
|||||||
const all: WeComDeptListResponse['department'] = [];
|
const all: WeComDeptListResponse['department'] = [];
|
||||||
const url = `${WECOM_API_BASE}${WECOM_DEPARTMENT_PATH}?access_token=${token}&id=${parentId}`;
|
const url = `${WECOM_API_BASE}${WECOM_DEPARTMENT_PATH}?access_token=${token}&id=${parentId}`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const body: WeComDeptListResponse = await res.json();
|
const body = (await res.json()) as WeComDeptListResponse;
|
||||||
if (body.errcode !== 0) {
|
if (body.errcode !== 0) {
|
||||||
if (body.errcode === 60003) return all;
|
if (body.errcode === 60003) return all;
|
||||||
throw new Error(`WeCom department list failed: ${body.errmsg} (${body.errcode})`);
|
throw new Error(`WeCom department list failed: ${body.errmsg} (${body.errcode})`);
|
||||||
@@ -93,7 +93,7 @@ export class WeComService {
|
|||||||
): Promise<Array<{ userid: string; name: string; mobile: string; department: number[] }>> {
|
): Promise<Array<{ userid: string; name: string; mobile: string; department: number[] }>> {
|
||||||
const url = `${WECOM_API_BASE}${WECOM_USER_PATH}?access_token=${token}&department_id=${deptId}&fetch_child=1`;
|
const url = `${WECOM_API_BASE}${WECOM_USER_PATH}?access_token=${token}&department_id=${deptId}&fetch_child=1`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const body: WeComUserListResponse = await res.json();
|
const body = (await res.json()) as WeComUserListResponse;
|
||||||
if (body.errcode !== 0) {
|
if (body.errcode !== 0) {
|
||||||
throw new Error(`WeCom user list failed: ${body.errmsg} (${body.errcode})`);
|
throw new Error(`WeCom user list failed: ${body.errmsg} (${body.errcode})`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ async function bootstrap() {
|
|||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
app.use(helmet());
|
app.use(helmet());
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call -- compression 经 export= 声明,eslint 类型解析受限
|
||||||
app.use(compression());
|
app.use(compression());
|
||||||
|
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import { MigrationInterface, QueryRunner } from 'typeorm';
|
|||||||
export class WidenImportSheetsJson1786000000000 implements MigrationInterface {
|
export class WidenImportSheetsJson1786000000000 implements MigrationInterface {
|
||||||
async up(queryRunner: QueryRunner): Promise<void> {
|
async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
if (!(await queryRunner.hasTable('import_runs'))) return;
|
if (!(await queryRunner.hasTable('import_runs'))) return;
|
||||||
const rows = await queryRunner.query(
|
const rows = (await queryRunner.query(
|
||||||
`SELECT DATA_TYPE FROM information_schema.COLUMNS
|
`SELECT DATA_TYPE FROM information_schema.COLUMNS
|
||||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'import_runs' AND COLUMN_NAME = 'sheets_json'`,
|
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'import_runs' AND COLUMN_NAME = 'sheets_json'`,
|
||||||
);
|
)) as Array<{ DATA_TYPE: string }>;
|
||||||
const current = rows?.[0]?.DATA_TYPE as string | undefined;
|
const current = rows?.[0]?.DATA_TYPE as string | undefined;
|
||||||
if (current && current.toLowerCase() !== 'mediumtext') {
|
if (current && current.toLowerCase() !== 'mediumtext') {
|
||||||
await queryRunner.query('ALTER TABLE `import_runs` MODIFY COLUMN `sheets_json` MEDIUMTEXT');
|
await queryRunner.query('ALTER TABLE `import_runs` MODIFY COLUMN `sheets_json` MEDIUMTEXT');
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ import {
|
|||||||
parseOccupancyImportWorksheet,
|
parseOccupancyImportWorksheet,
|
||||||
} from './occupancy-import-template';
|
} from './occupancy-import-template';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user?: { id: number; username: string };
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
connection?: { remoteAddress?: string };
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('occupancies')
|
@Controller('occupancies')
|
||||||
export class OccupanciesController {
|
export class OccupanciesController {
|
||||||
@@ -66,7 +72,7 @@ export class OccupanciesController {
|
|||||||
@Put('batch-restore')
|
@Put('batch-restore')
|
||||||
@RequirePermission('occupancy:delete')
|
@RequirePermission('occupancy:delete')
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
||||||
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) {
|
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchRestore(dto.ids);
|
const result = await this.service.batchRestore(dto.ids);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '入住管理', action: '批量恢复入住记录', detail: `IDs: ${dto.ids.join(',')}`,
|
module: '入住管理', action: '批量恢复入住记录', detail: `IDs: ${dto.ids.join(',')}`,
|
||||||
@@ -76,7 +82,7 @@ export class OccupanciesController {
|
|||||||
|
|
||||||
@Post('batch-check-out')
|
@Post('batch-check-out')
|
||||||
@RequirePermission('occupancy:checkout')
|
@RequirePermission('occupancy:checkout')
|
||||||
async batchCheckOut(@Body() dto: BatchCheckOutDto, @Request() req: any) {
|
async batchCheckOut(@Body() dto: BatchCheckOutDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchCheckOut(dto);
|
const result = await this.service.batchCheckOut(dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '入住管理', action: '批量退宿', detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`,
|
module: '入住管理', action: '批量退宿', detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`,
|
||||||
@@ -86,7 +92,7 @@ export class OccupanciesController {
|
|||||||
|
|
||||||
@Post('check-in')
|
@Post('check-in')
|
||||||
@RequirePermission('occupancy:checkin')
|
@RequirePermission('occupancy:checkin')
|
||||||
async checkIn(@Body() dto: CheckInDto, @Request() req: any) {
|
async checkIn(@Body() dto: CheckInDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.checkIn(dto, req.user?.id);
|
const result = await this.service.checkIn(dto, req.user?.id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '入住管理', action: '办理入住', targetId: result.id, targetType: 'occupancy', detail: `学生${dto.studentId} 入住房间${dto.roomId}`,
|
module: '入住管理', action: '办理入住', targetId: result.id, targetType: 'occupancy', detail: `学生${dto.studentId} 入住房间${dto.roomId}`,
|
||||||
@@ -110,7 +116,7 @@ export class OccupanciesController {
|
|||||||
|
|
||||||
@Put(':id/check-out')
|
@Put(':id/check-out')
|
||||||
@RequirePermission('occupancy:checkout')
|
@RequirePermission('occupancy:checkout')
|
||||||
async checkOut(@Param('id') id: string, @Body() dto: CheckOutDto, @Request() req: any) {
|
async checkOut(@Param('id') id: string, @Body() dto: CheckOutDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.checkOut(+id, dto);
|
const result = await this.service.checkOut(+id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '入住管理', action: '办理退宿', targetId: +id, targetType: 'occupancy',
|
module: '入住管理', action: '办理退宿', targetId: +id, targetType: 'occupancy',
|
||||||
@@ -134,7 +140,7 @@ export class OccupanciesController {
|
|||||||
|
|
||||||
@Put(':id/transfer')
|
@Put(':id/transfer')
|
||||||
@RequirePermission('occupancy:transfer')
|
@RequirePermission('occupancy:transfer')
|
||||||
async transferRoom(@Param('id') id: string, @Body() dto: TransferRoomDto, @Request() req: any) {
|
async transferRoom(@Param('id') id: string, @Body() dto: TransferRoomDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.transferRoom(+id, dto);
|
const result = await this.service.transferRoom(+id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '入住管理', action: '调换宿舍', targetId: +id, targetType: 'occupancy', detail: `换到房间${dto.newRoomId}`,
|
module: '入住管理', action: '调换宿舍', targetId: +id, targetType: 'occupancy', detail: `换到房间${dto.newRoomId}`,
|
||||||
@@ -144,7 +150,7 @@ export class OccupanciesController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('occupancy:delete')
|
@RequirePermission('occupancy:delete')
|
||||||
async remove(@Param('id') id: string, @Request() req: any) {
|
async remove(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.remove(+id);
|
const result = await this.service.remove(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '入住管理', action: '归档入住记录', targetId: +id, targetType: 'occupancy',
|
module: '入住管理', action: '归档入住记录', targetId: +id, targetType: 'occupancy',
|
||||||
@@ -154,7 +160,7 @@ export class OccupanciesController {
|
|||||||
|
|
||||||
@Post('batch-delete')
|
@Post('batch-delete')
|
||||||
@RequirePermission('occupancy:delete')
|
@RequirePermission('occupancy:delete')
|
||||||
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
|
async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchRemove(body.ids || []);
|
const result = await this.service.batchRemove(body.ids || []);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '入住管理', action: '批量归档入住记录', detail: `IDs: ${(body.ids || []).join(',')}`,
|
module: '入住管理', action: '批量归档入住记录', detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||||
@@ -164,7 +170,7 @@ export class OccupanciesController {
|
|||||||
|
|
||||||
@Delete(':id/permanent')
|
@Delete(':id/permanent')
|
||||||
@RequirePermission('occupancy:purge')
|
@RequirePermission('occupancy:purge')
|
||||||
async purge(@Param('id') id: string, @Request() req: any) {
|
async purge(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.purge(+id);
|
const result = await this.service.purge(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '入住管理', action: '永久删除入住记录', targetId: +id, targetType: 'occupancy', detail: '物理删除,不可恢复',
|
module: '入住管理', action: '永久删除入住记录', targetId: +id, targetType: 'occupancy', detail: '物理删除,不可恢复',
|
||||||
@@ -174,7 +180,7 @@ export class OccupanciesController {
|
|||||||
|
|
||||||
@Post('batch-permanent-delete')
|
@Post('batch-permanent-delete')
|
||||||
@RequirePermission('occupancy:purge')
|
@RequirePermission('occupancy:purge')
|
||||||
async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) {
|
async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchPurge(body.ids || []);
|
const result = await this.service.batchPurge(body.ids || []);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '入住管理', action: '批量永久删除入住记录', detail: `IDs: ${(body.ids || []).join(',')}`,
|
module: '入住管理', action: '批量永久删除入住记录', detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||||
@@ -257,7 +263,7 @@ export class OccupanciesController {
|
|||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
async importCheckIn(
|
async importCheckIn(
|
||||||
@UploadedFile() file: Express.Multer.File,
|
@UploadedFile() file: Express.Multer.File,
|
||||||
@Request() req: any,
|
@Request() req: AuthenticatedRequest,
|
||||||
@Query('autoDeposit') autoDeposit?: string,
|
@Query('autoDeposit') autoDeposit?: string,
|
||||||
@Query('depositAmount') depositAmount?: string,
|
@Query('depositAmount') depositAmount?: string,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ function cellText(cell: ExcelJS.Cell | undefined): string {
|
|||||||
if (typeof cell.value === 'object' && 'text' in cell.value) {
|
if (typeof cell.value === 'object' && 'text' in cell.value) {
|
||||||
return String(cell.value.text).trim();
|
return String(cell.value.text).trim();
|
||||||
}
|
}
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- exceljs CellValue 联合类型含富文本/公式对象,原样保留其字符串化结果
|
||||||
return String(cell.value).trim();
|
return String(cell.value).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||||
import { DataSource, IsNull } from 'typeorm';
|
import { DataSource, IsNull, DeepPartial } from 'typeorm';
|
||||||
import { Occupancy, Room, Student, Deposit, Bed, Locker, Organization } from '../entities';
|
import { Occupancy, Room, Student, Deposit, Bed, Locker, Organization } from '../entities';
|
||||||
import { RoomsService } from '../rooms/rooms.service';
|
import { RoomsService } from '../rooms/rooms.service';
|
||||||
|
|
||||||
class ImportRowSkipped extends Error {}
|
class ImportRowSkipped extends Error {}
|
||||||
|
|
||||||
|
type StudentUpdateFields = Pick<
|
||||||
|
Student,
|
||||||
|
| 'studentNo'
|
||||||
|
| 'idNumber'
|
||||||
|
| 'gender'
|
||||||
|
| 'ethnicity'
|
||||||
|
| 'emergencyContact'
|
||||||
|
| 'emergencyPhone'
|
||||||
|
| 'supervisor'
|
||||||
|
>;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OccupancyImportService {
|
export class OccupancyImportService {
|
||||||
constructor(private dataSource: DataSource) {}
|
constructor(private dataSource: DataSource) {}
|
||||||
@@ -87,7 +98,7 @@ export class OccupancyImportService {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// 更新已有学生的缺失信息
|
// 更新已有学生的缺失信息
|
||||||
const updates: any = {};
|
const updates: Partial<StudentUpdateFields> = {};
|
||||||
if (!student.studentNo && row.studentNo?.trim())
|
if (!student.studentNo && row.studentNo?.trim())
|
||||||
updates.studentNo = row.studentNo.trim();
|
updates.studentNo = row.studentNo.trim();
|
||||||
if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
|
if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
|
||||||
@@ -191,7 +202,7 @@ export class OccupancyImportService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 6. 创建入住记录
|
// 6. 创建入住记录
|
||||||
const occData: any = {
|
const occData: DeepPartial<Occupancy> = {
|
||||||
studentId: student.id,
|
studentId: student.id,
|
||||||
roomId: room.id,
|
roomId: room.id,
|
||||||
checkInDate,
|
checkInDate,
|
||||||
@@ -258,11 +269,11 @@ export class OccupancyImportService {
|
|||||||
|
|
||||||
imported++;
|
imported++;
|
||||||
depositsCreated += result.depositsCreated;
|
depositsCreated += result.depositsCreated;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
errors.push(
|
errors.push(
|
||||||
e instanceof ImportRowSkipped
|
e instanceof ImportRowSkipped
|
||||||
? e.message
|
? e.message
|
||||||
: `第${rowNum}行: ${row.name} 导入失败 - ${e.message}`,
|
: `第${rowNum}行: ${row.name} 导入失败 - ${e instanceof Error ? e.message : String(e)}`,
|
||||||
);
|
);
|
||||||
skipped++;
|
skipped++;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
import { CreateAuditLogDto, QueryOperationLogsDto } from './dto/operation-log.dto';
|
import { CreateAuditLogDto, QueryOperationLogsDto } from './dto/operation-log.dto';
|
||||||
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user: AuthenticatedUser;
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
connection?: { remoteAddress?: string };
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('operation-logs')
|
@Controller('operation-logs')
|
||||||
@@ -21,7 +28,7 @@ export class OperationLogsController {
|
|||||||
@RequirePermission('log:create')
|
@RequirePermission('log:create')
|
||||||
async createAuditLog(
|
async createAuditLog(
|
||||||
@Body() body: CreateAuditLogDto,
|
@Body() body: CreateAuditLogDto,
|
||||||
@Request() req: any,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
return this.service.log({
|
return this.service.log({
|
||||||
|
|||||||
@@ -17,6 +17,13 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user: AuthenticatedUser;
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
connection?: { remoteAddress?: string };
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('organizations')
|
@Controller('organizations')
|
||||||
@@ -49,7 +56,7 @@ export class OrganizationsController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission('organization:create')
|
@RequirePermission('organization:create')
|
||||||
async create(@Body() dto: CreateOrganizationDto, @Request() req: any) {
|
async create(@Body() dto: CreateOrganizationDto, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.create(dto);
|
const result = await this.service.create(dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
@@ -68,7 +75,7 @@ export class OrganizationsController {
|
|||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequirePermission('organization:edit')
|
@RequirePermission('organization:edit')
|
||||||
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateOrganizationDto, @Request() req: any) {
|
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateOrganizationDto, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.update(+id, dto);
|
const result = await this.service.update(+id, dto);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
@@ -87,7 +94,7 @@ export class OrganizationsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('organization:delete')
|
@RequirePermission('organization:delete')
|
||||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.remove(+id);
|
const result = await this.service.remove(+id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
@@ -105,7 +112,7 @@ export class OrganizationsController {
|
|||||||
|
|
||||||
@Delete(':id/permanent')
|
@Delete(':id/permanent')
|
||||||
@RequirePermission('organization:purge')
|
@RequirePermission('organization:purge')
|
||||||
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const result = await this.service.purge(+id);
|
const result = await this.service.purge(+id);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
|
|||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { logAudit } from '../common/with-audit-log';
|
import { logAudit } from '../common/with-audit-log';
|
||||||
|
|
||||||
|
/** JwtAuthGuard 保证 req.user 存在(类级 @UseGuards) */
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user: { id: number; username: string };
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
connection?: { remoteAddress?: string };
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('rbac')
|
@Controller('rbac')
|
||||||
export class RbacController {
|
export class RbacController {
|
||||||
@@ -48,7 +55,7 @@ export class RbacController {
|
|||||||
|
|
||||||
@Post('roles')
|
@Post('roles')
|
||||||
@RequirePermission('role:create')
|
@RequirePermission('role:create')
|
||||||
async createRole(@Body() dto: CreateRoleDto, @Request() req: any) {
|
async createRole(@Body() dto: CreateRoleDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.rbacService.createRole(dto);
|
const result = await this.rbacService.createRole(dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: 'RBAC', action: '创建角色', detail: `角色: ${dto.name}`,
|
module: 'RBAC', action: '创建角色', detail: `角色: ${dto.name}`,
|
||||||
@@ -58,29 +65,29 @@ export class RbacController {
|
|||||||
|
|
||||||
@Put('roles/:id')
|
@Put('roles/:id')
|
||||||
@RequirePermission('role:edit')
|
@RequirePermission('role:edit')
|
||||||
async updateRole(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRoleDto, @Request() req: any) {
|
async updateRole(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateRoleDto, @Request() req: AuthenticatedRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await this.rbacService.updateRole(+id, dto);
|
const result = await this.rbacService.updateRole(+id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: 'RBAC', action: '编辑角色', targetId: +id, targetType: 'role', detail: JSON.stringify(dto),
|
module: 'RBAC', action: '编辑角色', targetId: +id, targetType: 'role', detail: JSON.stringify(dto),
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
throw new BadRequestException(e.message);
|
throw new BadRequestException((e as { message?: string })?.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('roles/:id')
|
@Delete('roles/:id')
|
||||||
@RequirePermission('role:delete')
|
@RequirePermission('role:delete')
|
||||||
async deleteRole(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async deleteRole(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await this.rbacService.deleteRole(+id);
|
const result = await this.rbacService.deleteRole(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: 'RBAC', action: '停用角色', targetId: +id, targetType: 'role',
|
module: 'RBAC', action: '停用角色', targetId: +id, targetType: 'role',
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
throw new BadRequestException(e.message);
|
throw new BadRequestException((e as { message?: string })?.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,43 +112,43 @@ export class RbacController {
|
|||||||
|
|
||||||
@Post('users')
|
@Post('users')
|
||||||
@RequirePermission('user:create')
|
@RequirePermission('user:create')
|
||||||
async createUser(@Body() dto: CreateUserDto, @Request() req: any) {
|
async createUser(@Body() dto: CreateUserDto, @Request() req: AuthenticatedRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await this.rbacService.createUser(dto);
|
const result = await this.rbacService.createUser(dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '账号', action: '创建账号', detail: `用户名: ${dto.username}`,
|
module: '账号', action: '创建账号', detail: `用户名: ${dto.username}`,
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
throw new BadRequestException(e.message);
|
throw new BadRequestException((e as { message?: string })?.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('users/:id')
|
@Put('users/:id')
|
||||||
@RequirePermission('user:edit')
|
@RequirePermission('user:edit')
|
||||||
async updateUser(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto, @Request() req: any) {
|
async updateUser(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateUserDto, @Request() req: AuthenticatedRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await this.rbacService.updateUser(+id, dto);
|
const result = await this.rbacService.updateUser(+id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '账号', action: '更新账号', targetId: +id, targetType: 'user', detail: JSON.stringify(dto),
|
module: '账号', action: '更新账号', targetId: +id, targetType: 'user', detail: JSON.stringify(dto),
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
throw new BadRequestException(e.message);
|
throw new BadRequestException((e as { message?: string })?.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Put('users/:id/password')
|
@Put('users/:id/password')
|
||||||
@RequirePermission('user:reset-password')
|
@RequirePermission('user:reset-password')
|
||||||
async resetPassword(@Param('id', ParseIntPipe) id: number, @Body() dto: ResetPasswordDto, @Request() req: any) {
|
async resetPassword(@Param('id', ParseIntPipe) id: number, @Body() dto: ResetPasswordDto, @Request() req: AuthenticatedRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await this.rbacService.resetPassword(+id, dto.password);
|
const result = await this.rbacService.resetPassword(+id, dto.password);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '账号', action: '重置密码', targetId: +id, targetType: 'user',
|
module: '账号', action: '重置密码', targetId: +id, targetType: 'user',
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
throw new BadRequestException(e.message);
|
throw new BadRequestException((e as { message?: string })?.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,9 +176,9 @@ export class RbacController {
|
|||||||
|
|
||||||
@Delete('users/:id/permanent')
|
@Delete('users/:id/permanent')
|
||||||
@RequirePermission('user:purge')
|
@RequirePermission('user:purge')
|
||||||
async purgeUser(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async purgeUser(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
try {
|
try {
|
||||||
const result = await this.rbacService.purgeUser(+id, req.user?.id);
|
const result = await this.rbacService.purgeUser(+id, req.user.id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '账号', action: '永久删除用户', targetId: +id, targetType: 'user', detail: '物理删除,不可恢复',
|
module: '账号', action: '永久删除用户', targetId: +id, targetType: 'user', detail: '物理删除,不可恢复',
|
||||||
});
|
});
|
||||||
@@ -215,7 +222,7 @@ export class RbacController {
|
|||||||
async updateUserProfile(
|
async updateUserProfile(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateProfileDto,
|
@Body() dto: UpdateProfileDto,
|
||||||
@Request() req: any,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const result = await this.rbacService.updateUserProfile(+id, dto);
|
const result = await this.rbacService.updateUserProfile(+id, dto);
|
||||||
@@ -223,15 +230,15 @@ export class RbacController {
|
|||||||
module: '账号', action: '更新资料', targetId: +id, targetType: 'user',
|
module: '账号', action: '更新资料', targetId: +id, targetType: 'user',
|
||||||
});
|
});
|
||||||
return result;
|
return result;
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
throw new BadRequestException(e.message);
|
throw new BadRequestException((e as { message?: string })?.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('teacher-workspace')
|
@Get('teacher-workspace')
|
||||||
@RequirePermission('teacher-workspace:view')
|
@RequirePermission('teacher-workspace:view')
|
||||||
async getTeacherWorkspace(@Request() req: any) {
|
async getTeacherWorkspace(@Request() req: AuthenticatedRequest) {
|
||||||
return this.rbacService.getTeacherWorkspace(req.user?.id);
|
return this.rbacService.getTeacherWorkspace(req.user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('teachers')
|
@Get('teachers')
|
||||||
|
|||||||
@@ -8,6 +8,41 @@ import { RoomInspectionsService } from './room-inspections.service';
|
|||||||
import { occupancyWhereOnDate } from './room-occupancy-date';
|
import { occupancyWhereOnDate } from './room-occupancy-date';
|
||||||
import { parseRoomNumber } from './room-number';
|
import { parseRoomNumber } from './room-number';
|
||||||
|
|
||||||
|
/** getRawMany 原始行:驱动可能返回 string 或 number,故标量字段用联合类型 */
|
||||||
|
interface RoomSearchRawRow {
|
||||||
|
room_id: string | number;
|
||||||
|
room_room_number: string;
|
||||||
|
room_building: string | null;
|
||||||
|
room_floor: string | number | null;
|
||||||
|
room_capacity: string | number;
|
||||||
|
room_room_type: string | null;
|
||||||
|
room_status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoomOccupancySummaryRawRow {
|
||||||
|
roomId: string | number;
|
||||||
|
roomNumber: string;
|
||||||
|
occupied: string | number;
|
||||||
|
capacity: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** getRoomVisual 中按宿舍分组的入住记录展示字段 */
|
||||||
|
interface RoomVisualOccupant {
|
||||||
|
studentId: number;
|
||||||
|
occupancyId: number;
|
||||||
|
studentName: string;
|
||||||
|
bedId: number | null;
|
||||||
|
bedNumber: string | null;
|
||||||
|
checkInDate: string;
|
||||||
|
billingStartDate: string;
|
||||||
|
days: number;
|
||||||
|
organization: string | null;
|
||||||
|
supervisor: string | null;
|
||||||
|
organizationId: number | null;
|
||||||
|
organizationName: string | null;
|
||||||
|
organizationColor: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class RoomQueryService {
|
export class RoomQueryService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -40,7 +75,7 @@ export class RoomQueryService {
|
|||||||
])
|
])
|
||||||
.orderBy('room.roomNumber', 'ASC')
|
.orderBy('room.roomNumber', 'ASC')
|
||||||
.limit(Math.max(1, Math.min(query.limit ?? 20, 50)))
|
.limit(Math.max(1, Math.min(query.limit ?? 20, 50)))
|
||||||
.getRawMany();
|
.getRawMany<RoomSearchRawRow>();
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
id: Number(row.room_id),
|
id: Number(row.room_id),
|
||||||
roomNumber: String(row.room_room_number),
|
roomNumber: String(row.room_room_number),
|
||||||
@@ -68,7 +103,7 @@ export class RoomQueryService {
|
|||||||
.groupBy('room.id')
|
.groupBy('room.id')
|
||||||
.orderBy('room.roomNumber', 'ASC')
|
.orderBy('room.roomNumber', 'ASC')
|
||||||
.limit(Math.max(1, Math.min(query.limit ?? 20, 50)))
|
.limit(Math.max(1, Math.min(query.limit ?? 20, 50)))
|
||||||
.getRawMany();
|
.getRawMany<RoomOccupancySummaryRawRow>();
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
roomId: Number(row.roomId),
|
roomId: Number(row.roomId),
|
||||||
roomNumber: String(row.roomNumber),
|
roomNumber: String(row.roomNumber),
|
||||||
@@ -96,7 +131,7 @@ export class RoomQueryService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 按roomId分组入住记录
|
// 按roomId分组入住记录
|
||||||
const occMap = new Map<number, any[]>();
|
const occMap = new Map<number, RoomVisualOccupant[]>();
|
||||||
// days(已住天数)相对目标日期计算,而非固定今天,历史视图才准确。
|
// days(已住天数)相对目标日期计算,而非固定今天,历史视图才准确。
|
||||||
const refTime = new Date(targetDate).getTime();
|
const refTime = new Date(targetDate).getTime();
|
||||||
for (const occ of occupancies) {
|
for (const occ of occupancies) {
|
||||||
@@ -153,18 +188,18 @@ export class RoomQueryService {
|
|||||||
const inspectionByOccupancyId = new Map(
|
const inspectionByOccupancyId = new Map(
|
||||||
(inspection?.details || []).map((detail) => [detail.occupancyId, detail.status]),
|
(inspection?.details || []).map((detail) => [detail.occupancyId, detail.status]),
|
||||||
);
|
);
|
||||||
const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))];
|
const orgs = [...new Set(occ.map((o) => o.organization).filter(Boolean))];
|
||||||
let orgLabel: string | null = null;
|
let orgLabel: string | null = null;
|
||||||
if (orgs.length > 0 && occ.length > 0) {
|
if (orgs.length > 0 && occ.length > 0) {
|
||||||
const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]);
|
const allSameOrg = occ.every((o) => o.organization && o.organization === orgs[0]);
|
||||||
orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`;
|
orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`;
|
||||||
}
|
}
|
||||||
const organizationColors = [
|
const organizationColors = [
|
||||||
...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean)),
|
...new Set(occ.map((o) => o.organizationColor).filter(Boolean)),
|
||||||
];
|
];
|
||||||
const organizationColor: string | null =
|
const organizationColor: string | null =
|
||||||
organizationColors.length === 1 ? organizationColors[0] : null;
|
organizationColors.length === 1 ? organizationColors[0] : null;
|
||||||
const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))];
|
const organizationIds = [...new Set(occ.map((o) => o.organizationId).filter(Boolean))];
|
||||||
return {
|
return {
|
||||||
id: room.id,
|
id: room.id,
|
||||||
roomNumber: room.roomNumber,
|
roomNumber: room.roomNumber,
|
||||||
|
|||||||
@@ -31,6 +31,21 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
|
|||||||
import { BatchIdsDto } from '../common/batch-ids.dto';
|
import { BatchIdsDto } from '../common/batch-ids.dto';
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user?: { id: number; username: string };
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
connection?: { remoteAddress?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* exceljs 单元格标量文本。保持原 `String(value || '')` 语义。
|
||||||
|
* CellValue 联合类型含富文本/超链接对象,实际导入数据均为标量,故在辅助函数内局部豁免。
|
||||||
|
*/
|
||||||
|
function cellValueText(value: unknown): string {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- exceljs CellValue 联合类型含富文本对象,实际导入数据为标量
|
||||||
|
return String(value || '');
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('rooms')
|
@Controller('rooms')
|
||||||
export class RoomsController {
|
export class RoomsController {
|
||||||
@@ -64,7 +79,7 @@ export class RoomsController {
|
|||||||
@Put('batch-restore')
|
@Put('batch-restore')
|
||||||
@RequirePermission('room:edit')
|
@RequirePermission('room:edit')
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
||||||
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) {
|
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchRestore(dto.ids);
|
const result = await this.service.batchRestore(dto.ids);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '宿舍', action: '批量恢复宿舍', detail: `IDs: ${dto.ids.join(',')}`,
|
module: '宿舍', action: '批量恢复宿舍', detail: `IDs: ${dto.ids.join(',')}`,
|
||||||
@@ -78,7 +93,7 @@ export class RoomsController {
|
|||||||
@Param('roomId') roomId: string,
|
@Param('roomId') roomId: string,
|
||||||
@Param('date') date: string,
|
@Param('date') date: string,
|
||||||
@Body() dto: UpdateRoomInspectionDto,
|
@Body() dto: UpdateRoomInspectionDto,
|
||||||
@Request() req: any,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const result = await this.inspectionsService.submit(
|
const result = await this.inspectionsService.submit(
|
||||||
+roomId,
|
+roomId,
|
||||||
@@ -269,7 +284,7 @@ export class RoomsController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission('room:create')
|
@RequirePermission('room:create')
|
||||||
async create(@Body() dto: CreateRoomDto, @Request() req: any) {
|
async create(@Body() dto: CreateRoomDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.create(dto);
|
const result = await this.service.create(dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '宿舍', action: '添加宿舍', detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}人`,
|
module: '宿舍', action: '添加宿舍', detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}人`,
|
||||||
@@ -279,7 +294,7 @@ export class RoomsController {
|
|||||||
|
|
||||||
@Put(':id')
|
@Put(':id')
|
||||||
@RequirePermission('room:edit')
|
@RequirePermission('room:edit')
|
||||||
async update(@Param('id') id: string, @Body() dto: UpdateRoomDto, @Request() req: any) {
|
async update(@Param('id') id: string, @Body() dto: UpdateRoomDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.update(+id, dto);
|
const result = await this.service.update(+id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '宿舍', action: '编辑宿舍', targetId: +id, targetType: 'room', detail: JSON.stringify(dto),
|
module: '宿舍', action: '编辑宿舍', targetId: +id, targetType: 'room', detail: JSON.stringify(dto),
|
||||||
@@ -289,7 +304,7 @@ export class RoomsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('room:delete')
|
@RequirePermission('room:delete')
|
||||||
async remove(@Param('id') id: string, @Request() req: any) {
|
async remove(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.remove(+id);
|
const result = await this.service.remove(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '宿舍', action: '归档宿舍', targetId: +id, targetType: 'room',
|
module: '宿舍', action: '归档宿舍', targetId: +id, targetType: 'room',
|
||||||
@@ -299,7 +314,7 @@ export class RoomsController {
|
|||||||
|
|
||||||
@Post('batch-delete')
|
@Post('batch-delete')
|
||||||
@RequirePermission('room:delete')
|
@RequirePermission('room:delete')
|
||||||
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
|
async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchRemove(body.ids || []);
|
const result = await this.service.batchRemove(body.ids || []);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '宿舍', action: '批量归档宿舍', detail: `IDs: ${(body.ids || []).join(',')}`,
|
module: '宿舍', action: '批量归档宿舍', detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||||
@@ -309,7 +324,7 @@ export class RoomsController {
|
|||||||
|
|
||||||
@Delete(':id/permanent')
|
@Delete(':id/permanent')
|
||||||
@RequirePermission('room:purge')
|
@RequirePermission('room:purge')
|
||||||
async purge(@Param('id') id: string, @Request() req: any) {
|
async purge(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.purge(+id);
|
const result = await this.service.purge(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '宿舍', action: '永久删除宿舍', targetId: +id, targetType: 'room', detail: '物理删除,不可恢复',
|
module: '宿舍', action: '永久删除宿舍', targetId: +id, targetType: 'room', detail: '物理删除,不可恢复',
|
||||||
@@ -319,7 +334,7 @@ export class RoomsController {
|
|||||||
|
|
||||||
@Post('batch-permanent-delete')
|
@Post('batch-permanent-delete')
|
||||||
@RequirePermission('room:purge')
|
@RequirePermission('room:purge')
|
||||||
async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) {
|
async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchPurge(body.ids || []);
|
const result = await this.service.batchPurge(body.ids || []);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '宿舍', action: '批量永久删除宿舍', detail: `IDs: ${(body.ids || []).join(',')}`,
|
module: '宿舍', action: '批量永久删除宿舍', detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||||
@@ -329,7 +344,7 @@ export class RoomsController {
|
|||||||
|
|
||||||
@Put(':id/restore')
|
@Put(':id/restore')
|
||||||
@RequirePermission('room:edit')
|
@RequirePermission('room:edit')
|
||||||
async restore(@Param('id') id: string, @Request() req: any) {
|
async restore(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.restore(+id);
|
const result = await this.service.restore(+id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '宿舍', action: '恢复宿舍', targetId: +id, targetType: 'room',
|
module: '宿舍', action: '恢复宿舍', targetId: +id, targetType: 'room',
|
||||||
@@ -340,7 +355,7 @@ export class RoomsController {
|
|||||||
@Post('import')
|
@Post('import')
|
||||||
@RequirePermission('room:create')
|
@RequirePermission('room:create')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||||
@@ -356,7 +371,7 @@ export class RoomsController {
|
|||||||
}[] = [];
|
}[] = [];
|
||||||
ws.eachRow((row, idx) => {
|
ws.eachRow((row, idx) => {
|
||||||
if (idx === 1) return;
|
if (idx === 1) return;
|
||||||
const rentalCategoryRaw = String(row.getCell(6).value || '')
|
const rentalCategoryRaw = cellValueText(row.getCell(6).value)
|
||||||
.trim()
|
.trim()
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
const rentalCategory =
|
const rentalCategory =
|
||||||
@@ -366,11 +381,11 @@ export class RoomsController {
|
|||||||
const monthlyRateRaw = Number(row.getCell(7).value);
|
const monthlyRateRaw = Number(row.getCell(7).value);
|
||||||
const monthlyRate = isNaN(monthlyRateRaw) ? undefined : monthlyRateRaw;
|
const monthlyRate = isNaN(monthlyRateRaw) ? undefined : monthlyRateRaw;
|
||||||
rows.push({
|
rows.push({
|
||||||
roomNumber: String(row.getCell(1).value || ''),
|
roomNumber: cellValueText(row.getCell(1).value),
|
||||||
building: String(row.getCell(2).value || '') || undefined,
|
building: cellValueText(row.getCell(2).value) || undefined,
|
||||||
floor: (n => Number.isNaN(n) ? undefined : n)(Number(row.getCell(3).value)),
|
floor: (n => Number.isNaN(n) ? undefined : n)(Number(row.getCell(3).value)),
|
||||||
capacity: Number(row.getCell(4).value) || 4,
|
capacity: Number(row.getCell(4).value) || 4,
|
||||||
roomType: String(row.getCell(5).value || '').trim() || undefined,
|
roomType: cellValueText(row.getCell(5).value).trim() || undefined,
|
||||||
rentalCategory,
|
rentalCategory,
|
||||||
monthlyRate,
|
monthlyRate,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, Repository, IsNull, Not, In } from 'typeorm';
|
import { DataSource, Repository, IsNull, Not, In, FindOptionsWhere } from 'typeorm';
|
||||||
|
|
||||||
import { Room } from '../entities/room.entity';
|
import { Room } from '../entities/room.entity';
|
||||||
import { Occupancy } from '../entities/occupancy.entity';
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
@@ -55,7 +55,7 @@ export class RoomsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async findAll(query?: { building?: string; includeArchived?: boolean }) {
|
async findAll(query?: { building?: string; includeArchived?: boolean }) {
|
||||||
const where: any = {};
|
const where: FindOptionsWhere<Room> = {};
|
||||||
if (query?.building) where.building = query.building;
|
if (query?.building) where.building = query.building;
|
||||||
if (!query?.includeArchived) where.status = Not('archived');
|
if (!query?.includeArchived) where.status = Not('archived');
|
||||||
return this.repo.find({ where, order: { roomNumber: 'ASC' } });
|
return this.repo.find({ where, order: { roomNumber: 'ASC' } });
|
||||||
@@ -78,10 +78,10 @@ export class RoomsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getRoomOverview(query?: { includeArchived?: boolean }) {
|
async getRoomOverview(query?: { includeArchived?: boolean }) {
|
||||||
const where: any = {};
|
const where: FindOptionsWhere<Room> = {};
|
||||||
if (!query?.includeArchived) where.status = Not('archived');
|
if (!query?.includeArchived) where.status = Not('archived');
|
||||||
const rooms = await this.repo.find({ where, order: { building: 'ASC', roomNumber: 'ASC' } });
|
const rooms = await this.repo.find({ where, order: { building: 'ASC', roomNumber: 'ASC' } });
|
||||||
const result: any[] = [];
|
const result: Array<Room & { currentCount: number }> = [];
|
||||||
for (const room of rooms) {
|
for (const room of rooms) {
|
||||||
const count = await this.occRepo.count({
|
const count = await this.occRepo.count({
|
||||||
where: { roomId: room.id, checkOutDate: IsNull() },
|
where: { roomId: room.id, checkOutDate: IsNull() },
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import type { WeeklyViewQueryDto } from './dto/schedule.dto';
|
|||||||
|
|
||||||
const ACTIVE_SCHEDULE_STATUS = 'active';
|
const ACTIVE_SCHEDULE_STATUS = 'active';
|
||||||
|
|
||||||
|
/** String() 包装:避免 raw 行值(unknown 收窄为对象类型)触发 no-base-to-string。 */
|
||||||
|
function stringify(value: unknown): string {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ScheduleQueriesService {
|
export class ScheduleQueriesService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -106,18 +111,18 @@ export class ScheduleQueriesService {
|
|||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
id: Number(row.cs_id),
|
id: Number(row.cs_id),
|
||||||
classId: row.cs_class_id == null ? null : Number(row.cs_class_id),
|
classId: row.cs_class_id == null ? null : Number(row.cs_class_id),
|
||||||
className: row.class_name == null ? null : String(row.class_name),
|
className: row.class_name == null ? null : stringify(row.class_name),
|
||||||
classroomId: Number(row.cs_classroom_id),
|
classroomId: Number(row.cs_classroom_id),
|
||||||
classroomName: row.classroom_name == null ? null : String(row.classroom_name),
|
classroomName: row.classroom_name == null ? null : stringify(row.classroom_name),
|
||||||
weekDay: Number(row.cs_week_day),
|
weekDay: Number(row.cs_week_day),
|
||||||
startTime: String(row.cs_start_time),
|
startTime: stringify(row.cs_start_time),
|
||||||
endTime: String(row.cs_end_time),
|
endTime: stringify(row.cs_end_time),
|
||||||
subject: String(row.cs_subject),
|
subject: stringify(row.cs_subject),
|
||||||
teacherName: row.teacher_name == null ? null : String(row.teacher_name),
|
teacherName: row.teacher_name == null ? null : stringify(row.teacher_name),
|
||||||
startDate: String(row.cs_start_date),
|
startDate: stringify(row.cs_start_date),
|
||||||
endDate: String(row.cs_end_date),
|
endDate: stringify(row.cs_end_date),
|
||||||
scheduleType: String(row.cs_schedule_type),
|
scheduleType: stringify(row.cs_schedule_type),
|
||||||
status: String(row.cs_status),
|
status: stringify(row.cs_status),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export class QueryStudentDto {
|
|||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(({ value }) => {
|
@Transform(({ value }: { value: unknown }) => {
|
||||||
if (typeof value === 'boolean') return value;
|
if (typeof value === 'boolean') return value;
|
||||||
if (value === 'true' || value === '1') return true;
|
if (value === 'true' || value === '1') return true;
|
||||||
if (value === 'false' || value === '0') return false;
|
if (value === 'false' || value === '0') return false;
|
||||||
|
|||||||
@@ -166,6 +166,8 @@ function cellToText(cell: ExcelJS.Cell): string {
|
|||||||
const value = getCellPrimitiveValue(cell);
|
const value = getCellPrimitiveValue(cell);
|
||||||
if (value === null || value === undefined) return '';
|
if (value === null || value === undefined) return '';
|
||||||
if (value instanceof Date) return formatDate(value);
|
if (value instanceof Date) return formatDate(value);
|
||||||
|
// 对象值(错误单元格/共享公式等)保留既有 String() 行为,不做类型收窄
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- 对象值保留既有 '[object Object]' 输出
|
||||||
return String(value).trim();
|
return String(value).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,7 +188,10 @@ function parseDateText(cell: ExcelJS.Cell): string | undefined {
|
|||||||
const value = getCellPrimitiveValue(cell);
|
const value = getCellPrimitiveValue(cell);
|
||||||
if (value instanceof Date) return formatDate(value);
|
if (value instanceof Date) return formatDate(value);
|
||||||
if (typeof value === 'number') return excelSerialToDate(value);
|
if (typeof value === 'number') return excelSerialToDate(value);
|
||||||
const text = value === null || value === undefined ? '' : String(value).trim();
|
if (value === null || value === undefined) return undefined;
|
||||||
|
// 对象值(错误单元格等)保留既有 String() 行为
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- 对象值保留既有 String() 语义
|
||||||
|
const text = String(value).trim();
|
||||||
if (!text) return undefined;
|
if (!text) return undefined;
|
||||||
const normalized = text.replace(/[/.]/g, '-');
|
const normalized = text.replace(/[/.]/g, '-');
|
||||||
const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/u);
|
const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/u);
|
||||||
|
|||||||
@@ -5,6 +5,17 @@ import { Student } from '../entities/student.entity';
|
|||||||
import { ClassStudent } from '../entities/class-student.entity';
|
import { ClassStudent } from '../entities/class-student.entity';
|
||||||
import type { StudentAccessScope } from './student-access-scope';
|
import type { StudentAccessScope } from './student-access-scope';
|
||||||
|
|
||||||
|
/** getRawMany/getRawOne 原始行(select 别名即原始键名;可空列按 NULL 处理) */
|
||||||
|
interface AgentStudentRawRow {
|
||||||
|
student_id: number;
|
||||||
|
student_name: string;
|
||||||
|
student_student_no: string | null;
|
||||||
|
student_gender: string | null;
|
||||||
|
student_status: string;
|
||||||
|
student_organization_id: number;
|
||||||
|
organization_name: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StudentsAgentService {
|
export class StudentsAgentService {
|
||||||
/**
|
/**
|
||||||
@@ -84,13 +95,13 @@ export class StudentsAgentService {
|
|||||||
|
|
||||||
qb.orderBy('student.createdAt', 'DESC').take(limit);
|
qb.orderBy('student.createdAt', 'DESC').take(limit);
|
||||||
|
|
||||||
const rows: Record<string, unknown>[] = await qb.getRawMany();
|
const rows = await qb.getRawMany<AgentStudentRawRow>();
|
||||||
if (rows.length === 0) return [];
|
if (rows.length === 0) return [];
|
||||||
|
|
||||||
// Second bounded query: classIds only for the returned student ids.
|
// Second bounded query: classIds only for the returned student ids.
|
||||||
// For teacher scope, the class filter MUST be re-applied so the
|
// For teacher scope, the class filter MUST be re-applied so the
|
||||||
// teacher only sees classIds they are assigned to.
|
// teacher only sees classIds they are assigned to.
|
||||||
const studentIds = rows.map((r) => r.student_id as number);
|
const studentIds = rows.map((r) => r.student_id);
|
||||||
const csQb = this.classStudentRepo
|
const csQb = this.classStudentRepo
|
||||||
.createQueryBuilder('cs')
|
.createQueryBuilder('cs')
|
||||||
.select(['cs.studentId', 'cs.classId'])
|
.select(['cs.studentId', 'cs.classId'])
|
||||||
@@ -104,24 +115,24 @@ export class StudentsAgentService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const classRows = await csQb.getRawMany();
|
const classRows = await csQb.getRawMany<{ cs_student_id: number; cs_class_id: number }>();
|
||||||
|
|
||||||
const classMap = new Map<number, number[]>();
|
const classMap = new Map<number, number[]>();
|
||||||
for (const cr of classRows as { cs_student_id: number; cs_class_id: number }[]) {
|
for (const cr of classRows) {
|
||||||
const sid = cr.cs_student_id;
|
const sid = cr.cs_student_id;
|
||||||
if (!classMap.has(sid)) classMap.set(sid, []);
|
if (!classMap.has(sid)) classMap.set(sid, []);
|
||||||
classMap.get(sid)!.push(cr.cs_class_id);
|
classMap.get(sid)!.push(cr.cs_class_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
id: r.student_id as number,
|
id: r.student_id,
|
||||||
name: r.student_name as string,
|
name: r.student_name,
|
||||||
studentNo: (r.student_student_no as string) ?? '',
|
studentNo: r.student_student_no ?? '',
|
||||||
gender: (r.student_gender as string) ?? '',
|
gender: r.student_gender ?? '',
|
||||||
status: r.student_status as string,
|
status: r.student_status,
|
||||||
organizationId: r.student_organization_id as number,
|
organizationId: r.student_organization_id,
|
||||||
organizationName: (r.organization_name as string) ?? '',
|
organizationName: r.organization_name ?? '',
|
||||||
classIds: classMap.get(r.student_id as number) ?? [],
|
classIds: classMap.get(r.student_id) ?? [],
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +169,7 @@ export class StudentsAgentService {
|
|||||||
|
|
||||||
this.applyStudentScope(qb, scope);
|
this.applyStudentScope(qb, scope);
|
||||||
|
|
||||||
const row = await qb.getRawOne();
|
const row = await qb.getRawOne<AgentStudentRawRow>();
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
// For teacher scope, re-apply class filter so teacher only sees
|
// For teacher scope, re-apply class filter so teacher only sees
|
||||||
@@ -176,17 +187,17 @@ export class StudentsAgentService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const classRows = await csQb.getRawMany();
|
const classRows = await csQb.getRawMany<{ cs_class_id: number }>();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: row.student_id as number,
|
id: row.student_id,
|
||||||
name: row.student_name as string,
|
name: row.student_name,
|
||||||
studentNo: (row.student_student_no as string) ?? '',
|
studentNo: row.student_student_no ?? '',
|
||||||
gender: (row.student_gender as string) ?? '',
|
gender: row.student_gender ?? '',
|
||||||
status: row.student_status as string,
|
status: row.student_status,
|
||||||
organizationId: row.student_organization_id as number,
|
organizationId: row.student_organization_id,
|
||||||
organizationName: (row.organization_name as string) ?? '',
|
organizationName: row.organization_name ?? '',
|
||||||
classIds: (classRows as { cs_class_id: number }[]).map((cr) => cr.cs_class_id),
|
classIds: classRows.map((cr) => cr.cs_class_id),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export class StudentsController {
|
|||||||
|
|
||||||
@Get('export')
|
@Get('export')
|
||||||
@RequirePermission('student:export')
|
@RequirePermission('student:export')
|
||||||
async exportExcel(@Query() query: QueryStudentDto, @Res() res?: Response, @Request() req?: any) {
|
async exportExcel(@Query() query: QueryStudentDto, @Res() res: Response, @Request() req: AuthenticatedRequest) {
|
||||||
const classIds = await this.service.getAccessibleClassIds(
|
const classIds = await this.service.getAccessibleClassIds(
|
||||||
req.user.id,
|
req.user.id,
|
||||||
this.canManageAllStudents(req),
|
this.canManageAllStudents(req),
|
||||||
@@ -137,13 +137,13 @@ export class StudentsController {
|
|||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '学生管理', action: '导出学生', detail: `导出 ${students.length} 名学生`,
|
module: '学生管理', action: '导出学生', detail: `导出 ${students.length} 名学生`,
|
||||||
});
|
});
|
||||||
res!.setHeader(
|
res.setHeader(
|
||||||
'Content-Type',
|
'Content-Type',
|
||||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
);
|
);
|
||||||
res!.setHeader('Content-Disposition', 'attachment; filename=students.xlsx');
|
res.setHeader('Content-Disposition', 'attachment; filename=students.xlsx');
|
||||||
await workbook.xlsx.write(res!);
|
await workbook.xlsx.write(res);
|
||||||
res!.end();
|
res.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('template')
|
@Get('template')
|
||||||
@@ -167,7 +167,7 @@ export class StudentsController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@RequirePermission('student:create')
|
@RequirePermission('student:create')
|
||||||
async create(@Body() dto: CreateStudentDto, @Request() req: any) {
|
async create(@Body() dto: CreateStudentDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.create(dto);
|
const result = await this.service.create(dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`,
|
module: '学生管理', action: '新增学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`,
|
||||||
@@ -178,7 +178,7 @@ export class StudentsController {
|
|||||||
@Put('batch-restore')
|
@Put('batch-restore')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
||||||
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: any) {
|
async batchRestore(@Body() dto: BatchIdsDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchRestore(dto.ids);
|
const result = await this.service.batchRestore(dto.ids);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '学生管理', action: '批量恢复学生', detail: `IDs: ${dto.ids.join(',')}`,
|
module: '学生管理', action: '批量恢复学生', detail: `IDs: ${dto.ids.join(',')}`,
|
||||||
@@ -191,7 +191,7 @@ export class StudentsController {
|
|||||||
async update(
|
async update(
|
||||||
@Param('id', ParseIntPipe) id: number,
|
@Param('id', ParseIntPipe) id: number,
|
||||||
@Body() dto: UpdateStudentDto,
|
@Body() dto: UpdateStudentDto,
|
||||||
@Request() req: any,
|
@Request() req: AuthenticatedRequest,
|
||||||
) {
|
) {
|
||||||
const result = await this.service.update(id, dto);
|
const result = await this.service.update(id, dto);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
@@ -202,7 +202,7 @@ export class StudentsController {
|
|||||||
|
|
||||||
@Delete(':id')
|
@Delete(':id')
|
||||||
@RequirePermission('student:delete')
|
@RequirePermission('student:delete')
|
||||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.remove(id);
|
const result = await this.service.remove(id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '学生管理', action: '归档学生', targetId: id, targetType: 'student',
|
module: '学生管理', action: '归档学生', targetId: id, targetType: 'student',
|
||||||
@@ -212,7 +212,7 @@ export class StudentsController {
|
|||||||
|
|
||||||
@Post('batch-delete')
|
@Post('batch-delete')
|
||||||
@RequirePermission('student:delete')
|
@RequirePermission('student:delete')
|
||||||
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
|
async batchRemove(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchRemove(body.ids || []);
|
const result = await this.service.batchRemove(body.ids || []);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '学生管理', action: '批量归档学生', detail: `IDs: ${(body.ids || []).join(',')}`,
|
module: '学生管理', action: '批量归档学生', detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||||
@@ -222,7 +222,7 @@ export class StudentsController {
|
|||||||
|
|
||||||
@Delete(':id/permanent')
|
@Delete(':id/permanent')
|
||||||
@RequirePermission('student:purge')
|
@RequirePermission('student:purge')
|
||||||
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async purge(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.purge(id);
|
const result = await this.service.purge(id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '学生管理', action: '永久删除学生', targetId: id, targetType: 'student', detail: '物理删除,不可恢复',
|
module: '学生管理', action: '永久删除学生', targetId: id, targetType: 'student', detail: '物理删除,不可恢复',
|
||||||
@@ -232,7 +232,7 @@ export class StudentsController {
|
|||||||
|
|
||||||
@Post('batch-permanent-delete')
|
@Post('batch-permanent-delete')
|
||||||
@RequirePermission('student:purge')
|
@RequirePermission('student:purge')
|
||||||
async batchPurge(@Body() body: { ids: number[] }, @Request() req: any) {
|
async batchPurge(@Body() body: { ids: number[] }, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchPurge(body.ids || []);
|
const result = await this.service.batchPurge(body.ids || []);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '学生管理', action: '批量永久删除学生', detail: `IDs: ${(body.ids || []).join(',')}`,
|
module: '学生管理', action: '批量永久删除学生', detail: `IDs: ${(body.ids || []).join(',')}`,
|
||||||
@@ -242,7 +242,7 @@ export class StudentsController {
|
|||||||
|
|
||||||
@Put(':id/restore')
|
@Put(':id/restore')
|
||||||
@RequirePermission('student:edit')
|
@RequirePermission('student:edit')
|
||||||
async restore(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
async restore(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.restore(id);
|
const result = await this.service.restore(id);
|
||||||
await logAudit(this.logService, req, {
|
await logAudit(this.logService, req, {
|
||||||
module: '学生管理', action: '恢复学生', targetId: id, targetType: 'student',
|
module: '学生管理', action: '恢复学生', targetId: id, targetType: 'student',
|
||||||
@@ -253,7 +253,7 @@ export class StudentsController {
|
|||||||
@Post('import')
|
@Post('import')
|
||||||
@RequirePermission('student:import')
|
@RequirePermission('student:import')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||||
const importData = parseStudentImportWorkbook(workbook);
|
const importData = parseStudentImportWorkbook(workbook);
|
||||||
@@ -278,7 +278,7 @@ export class StudentsController {
|
|||||||
@Post('import-match')
|
@Post('import-match')
|
||||||
@RequirePermission('student:import')
|
@RequirePermission('student:import')
|
||||||
@UseInterceptors(FileInterceptor('file'))
|
@UseInterceptors(FileInterceptor('file'))
|
||||||
async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
async matchImport(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) {
|
||||||
const workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer);
|
||||||
const importData = parseStudentImportWorkbook(workbook);
|
const importData = parseStudentImportWorkbook(workbook);
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ export function validateMatchRule(formToken: string, mappings: JinshujuFieldMapp
|
|||||||
'emergencyContact',
|
'emergencyContact',
|
||||||
'emergencyPhone',
|
'emergencyPhone',
|
||||||
]);
|
]);
|
||||||
for (const [studentField, fieldKey] of Object.entries(mappings)) {
|
// Object.entries 对无索引签名的接口会回退为 any,这里显式标注条目类型
|
||||||
|
for (const [studentField, fieldKey] of Object.entries(mappings) as Array<
|
||||||
|
[string, string | undefined]
|
||||||
|
>) {
|
||||||
if (!allowedStudentFields.has(studentField)) {
|
if (!allowedStudentFields.has(studentField)) {
|
||||||
throw new ConflictException(`不允许映射学生字段:${studentField}`);
|
throw new ConflictException(`不允许映射学生字段:${studentField}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ export class SyncService {
|
|||||||
let matched = 0;
|
let matched = 0;
|
||||||
let created = 0;
|
let created = 0;
|
||||||
await this.dataSource.transaction(async (manager) => {
|
await this.dataSource.transaction(async (manager) => {
|
||||||
const orgs = await manager.query(
|
const orgs = await manager.query<Array<{ id: number }>>(
|
||||||
'SELECT id FROM organizations WHERE is_host = 1 AND status = ? LIMIT 1',
|
'SELECT id FROM organizations WHERE is_host = 1 AND status = ? LIMIT 1',
|
||||||
['active'],
|
['active'],
|
||||||
);
|
);
|
||||||
@@ -199,9 +199,23 @@ export class SyncService {
|
|||||||
const decision = decisionMap.get(serial);
|
const decision = decisionMap.get(serial);
|
||||||
if (!decision || decision.action === 'skip') continue;
|
if (!decision || decision.action === 'skip') continue;
|
||||||
|
|
||||||
const mappedValues = Object.fromEntries(
|
// 匹配规则只允许映射以下字符串字段(validateMatchRule 白名单)。
|
||||||
Object.entries(map)
|
const mappedValues: Partial<
|
||||||
.map(([studentField, fieldKey]) => [studentField, extractField(entry, fieldKey)])
|
Pick<
|
||||||
|
Student,
|
||||||
|
| 'name'
|
||||||
|
| 'studentNo'
|
||||||
|
| 'phone'
|
||||||
|
| 'idNumber'
|
||||||
|
| 'gender'
|
||||||
|
| 'ethnicity'
|
||||||
|
| 'emergencyContact'
|
||||||
|
| 'emergencyPhone'
|
||||||
|
>
|
||||||
|
> = Object.fromEntries(
|
||||||
|
// Object.entries 对无索引签名的接口会回退为 any,这里显式标注条目类型
|
||||||
|
(Object.entries(map) as Array<[string, string | undefined]>)
|
||||||
|
.map(([studentField, fieldKey]) => [studentField, extractField(entry, fieldKey)] as const)
|
||||||
.filter(([, value]) => value),
|
.filter(([, value]) => value),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,16 @@ import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|||||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
import { extractRequestInfo } from '../common/request-utils';
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
|
import type { AuthenticatedUser } from '../authorization';
|
||||||
import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto';
|
import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto';
|
||||||
import { WalletsService } from './wallets.service';
|
import { WalletsService } from './wallets.service';
|
||||||
|
|
||||||
|
interface AuthenticatedRequest {
|
||||||
|
user: AuthenticatedUser;
|
||||||
|
ip?: string;
|
||||||
|
headers?: Record<string, string | string[] | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('wallets')
|
@Controller('wallets')
|
||||||
export class WalletsController {
|
export class WalletsController {
|
||||||
@@ -35,7 +42,7 @@ export class WalletsController {
|
|||||||
|
|
||||||
@Post('change-balance')
|
@Post('change-balance')
|
||||||
@RequirePermission('wallet:edit')
|
@RequirePermission('wallet:edit')
|
||||||
async changeBalance(@Body() dto: ChangeWalletBalanceDto, @Request() req: any) {
|
async changeBalance(@Body() dto: ChangeWalletBalanceDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.changeBalance(dto, req.user?.id);
|
const result = await this.service.changeBalance(dto, req.user?.id);
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
@@ -55,7 +62,7 @@ export class WalletsController {
|
|||||||
|
|
||||||
@Post('batch-change-balance')
|
@Post('batch-change-balance')
|
||||||
@RequirePermission('wallet:edit')
|
@RequirePermission('wallet:edit')
|
||||||
async batchChangeBalance(@Body() dto: BatchChangeWalletBalanceDto, @Request() req: any) {
|
async batchChangeBalance(@Body() dto: BatchChangeWalletBalanceDto, @Request() req: AuthenticatedRequest) {
|
||||||
const result = await this.service.batchChangeBalance(dto, req.user?.id);
|
const result = await this.service.batchChangeBalance(dto, req.user?.id);
|
||||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
await this.logService.log({
|
await this.logService.log({
|
||||||
|
|||||||
Reference in New Issue
Block a user