From 353a6c72e15e0022055d526c405e8f5ffbb995a3 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Tue, 11 Aug 2026 17:28:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E5=85=A5=E4=BD=8F=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=96=B0=E5=A2=9E=E7=BC=96=E8=BE=91=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E4=B8=8E=E6=9D=83=E9=99=90=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...786439000000-AddOccupancyEditPermission.ts | 37 ++++ .../src/occupancies/dto/occupancy.dto.spec.ts | 73 +++++++- .../src/occupancies/dto/occupancy.dto.ts | 23 +++ .../occupancies.controller.spec.ts | 62 +++++++ .../src/occupancies/occupancies.controller.ts | 28 ++- .../occupancies/occupancies.service.spec.ts | 173 ++++++++++++++++++ .../src/occupancies/occupancies.service.ts | 45 ++++- apps/server/src/rbac/rbac-presets.ts | 1 + 8 files changed, 438 insertions(+), 4 deletions(-) create mode 100644 apps/server/src/migrations/1786439000000-AddOccupancyEditPermission.ts diff --git a/apps/server/src/migrations/1786439000000-AddOccupancyEditPermission.ts b/apps/server/src/migrations/1786439000000-AddOccupancyEditPermission.ts new file mode 100644 index 00000000..4edc6868 --- /dev/null +++ b/apps/server/src/migrations/1786439000000-AddOccupancyEditPermission.ts @@ -0,0 +1,37 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * 新增「编辑入住记录」权限点,并授予当前已拥有入住写权限 + * (occupancy:checkin/checkout/transfer/delete 任一)的角色。 + * 幂等:permissions 靠 code 唯一索引守卫,role_permissions 用 INSERT IGNORE + DISTINCT。 + */ +export class AddOccupancyEditPermission1786439000000 implements MigrationInterface { + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + INSERT INTO \`permissions\` (\`code\`, \`name\`, \`group\`) + SELECT 'occupancy:edit', '编辑入住记录', 'occupancy' + FROM DUAL + WHERE NOT EXISTS ( + SELECT 1 FROM \`permissions\` WHERE \`code\` = 'occupancy:edit' + ) + `); + await queryRunner.query(` + INSERT IGNORE INTO \`role_permissions\` (\`role_id\`, \`permission_id\`) + SELECT DISTINCT rp.role_id, edit_perm.id + FROM \`role_permissions\` rp + JOIN \`permissions\` edit_perm ON edit_perm.code = 'occupancy:edit' + JOIN \`permissions\` src + ON src.code IN ('occupancy:checkin', 'occupancy:checkout', 'occupancy:transfer', 'occupancy:delete') + WHERE rp.permission_id = src.id + `); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DELETE rp FROM \`role_permissions\` rp + JOIN \`permissions\` p ON p.id = rp.permission_id + WHERE p.code = 'occupancy:edit' + `); + await queryRunner.query(`DELETE FROM \`permissions\` WHERE \`code\` = 'occupancy:edit'`); + } +} diff --git a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts index 40889f01..f36f4a63 100644 --- a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts +++ b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts @@ -1,6 +1,6 @@ import { ValidationPipe } from '@nestjs/common'; import { validate } from 'class-validator'; -import { CheckInDto, TransferRoomDto } from './occupancy.dto'; +import { CheckInDto, TransferRoomDto, UpdateOccupancyDto } from './occupancy.dto'; describe('manual occupancy DTO bed requirements', () => { it('requires a bed for manual check-in', async () => { @@ -98,3 +98,74 @@ describe('occupancy date boundaries', () => { await expect(validate(dto)).resolves.toHaveLength(0); }); }); + +describe('UpdateOccupancyDto', () => { + it('requires check-in date and billing start date', async () => { + const dto = Object.assign(new UpdateOccupancyDto(), {}); + + const errors = await validate(dto); + + expect(errors.some((error) => error.property === 'checkInDate')).toBe(true); + expect(errors.some((error) => error.property === 'billingStartDate')).toBe(true); + }); + + it('accepts an active occupancy edit with only the two date fields', async () => { + const dto = Object.assign(new UpdateOccupancyDto(), { + checkInDate: '2026-07-10', + billingStartDate: '2026-07-10', + }); + + await expect(validate(dto)).resolves.toHaveLength(0); + }); + + it('accepts optional checkout fields for a checked-out occupancy', async () => { + const dto = Object.assign(new UpdateOccupancyDto(), { + checkInDate: '2026-07-10', + billingStartDate: '2026-07-10', + checkOutDate: '2026-08-01', + billingEndDate: '2026-07-31', + checkOutReason: '结业', + }); + + await expect(validate(dto)).resolves.toHaveLength(0); + }); + + it('rejects a non-date-only check-in date', async () => { + const dto = Object.assign(new UpdateOccupancyDto(), { + checkInDate: '2026-7-10', + billingStartDate: '2026-07-10', + }); + + const errors = await validate(dto); + + expect(errors.some((error) => error.property === 'checkInDate')).toBe(true); + }); + + it('rejects a checkout reason longer than the column width', async () => { + const dto = Object.assign(new UpdateOccupancyDto(), { + checkInDate: '2026-07-10', + billingStartDate: '2026-07-10', + checkOutReason: 'x'.repeat(101), + }); + + const errors = await validate(dto); + + expect(errors.some((error) => error.property === 'checkOutReason')).toBe(true); + }); + + it('strips non-whitelisted fields', async () => { + const pipe = new ValidationPipe({ transform: true, whitelist: true }); + const dto = await pipe.transform( + { + checkInDate: '2026-07-10', + billingStartDate: '2026-07-10', + roomId: 99, + studentId: 88, + }, + { type: 'body', metatype: UpdateOccupancyDto }, + ); + + expect(dto).not.toHaveProperty('roomId'); + expect(dto).not.toHaveProperty('studentId'); + }); +}); diff --git a/apps/server/src/occupancies/dto/occupancy.dto.ts b/apps/server/src/occupancies/dto/occupancy.dto.ts index d16b76bc..cecd9814 100644 --- a/apps/server/src/occupancies/dto/occupancy.dto.ts +++ b/apps/server/src/occupancies/dto/occupancy.dto.ts @@ -6,6 +6,7 @@ import { IsOptional, IsString, Matches, + MaxLength, Min, } from 'class-validator'; @@ -102,3 +103,25 @@ export class BatchCheckOutDto { @IsString() checkOutReason?: string; } + + +export class UpdateOccupancyDto { + @Matches(/^\d{4}-\d{2}-\d{2}$/) + checkInDate: string; // YYYY-MM-DD + + @Matches(/^\d{4}-\d{2}-\d{2}$/) + billingStartDate: string; // YYYY-MM-DD + + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + checkOutDate?: string; // 已退宿记录必填,在住记录不可提交 + + @IsOptional() + @Matches(/^\d{4}-\d{2}-\d{2}$/) + billingEndDate?: string; // 计费截止日,可选 + + @IsOptional() + @IsString() + @MaxLength(100) + checkOutReason?: string; // 退宿原因,对齐实体列宽 +} diff --git a/apps/server/src/occupancies/occupancies.controller.spec.ts b/apps/server/src/occupancies/occupancies.controller.spec.ts index d8e16bb7..4d3440e9 100644 --- a/apps/server/src/occupancies/occupancies.controller.spec.ts +++ b/apps/server/src/occupancies/occupancies.controller.spec.ts @@ -33,3 +33,65 @@ describe('OccupanciesController permissions', () => { ).toEqual(['occupancy:purge']); }); }); + +describe('OccupanciesController edit occupancy', () => { + it('requires occupancy:edit for the update route', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, OccupanciesController.prototype.update)).toEqual([ + 'occupancy:edit', + ]); + }); + + it('updates the occupancy and writes an audit log', async () => { + const service = { + update: jest.fn().mockResolvedValue({ id: 1, studentId: 3, roomId: 2 }), + }; + const logService = { log: jest.fn().mockResolvedValue(undefined) }; + const controller = new OccupanciesController( + service as any, + logService as any, + {} as any, + {} as any, + ); + const req = { user: { id: 9, username: 'tester' }, headers: {}, connection: {} }; + const dto = { checkInDate: '2026-07-10', billingStartDate: '2026-07-10' }; + + const result = await controller.update('1', dto, req as any); + + expect(result).toEqual({ id: 1, studentId: 3, roomId: 2 }); + expect(service.update).toHaveBeenCalledWith(1, dto); + expect(logService.log).toHaveBeenCalledWith( + expect.objectContaining({ + module: '入住管理', + action: '编辑入住记录', + targetId: 1, + targetType: 'occupancy', + userId: 9, + username: 'tester', + detail: '入住日期 2026-07-10,计费起始 2026-07-10', + }), + ); + }); + + it('includes the checkout date in the audit detail when provided', async () => { + const service = { update: jest.fn().mockResolvedValue({ id: 1 }) }; + const logService = { log: jest.fn().mockResolvedValue(undefined) }; + const controller = new OccupanciesController( + service as any, + logService as any, + {} as any, + {} as any, + ); + + await controller.update( + '1', + { checkInDate: '2026-07-10', billingStartDate: '2026-07-10', checkOutDate: '2026-08-01' }, + { user: { id: 9, username: 'tester' }, headers: {}, connection: {} } as any, + ); + + expect(logService.log).toHaveBeenCalledWith( + expect.objectContaining({ + detail: '入住日期 2026-07-10,计费起始 2026-07-10,退宿日期 2026-08-01', + }), + ); + }); +}); diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index 8400c0a7..2ab8cd9b 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -25,10 +25,10 @@ import type { Response } from 'express'; import { OccupanciesService } from './occupancies.service'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationType } from '../entities/notification.entity'; -import { CheckInDto, CheckOutDto, TransferRoomDto, BatchCheckOutDto } from './dto/occupancy.dto'; +import { CheckInDto, CheckOutDto, TransferRoomDto, BatchCheckOutDto, UpdateOccupancyDto } from './dto/occupancy.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; -import { logAudit } from '../common/with-audit-log'; +import { logAudit, withAuditLog } from '../common/with-audit-log'; import { extractRequestInfo } from '../common/request-utils'; import { RequirePermission } from '../auth/decorators/permission.decorator'; import { BatchIdsDto } from '../common/batch-ids.dto'; @@ -149,6 +149,30 @@ export class OccupanciesController { return result; } + @Put(':id') + @RequirePermission('occupancy:edit') + @UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true })) + async update( + @Param('id') id: string, + @Body() dto: UpdateOccupancyDto, + @Request() req: AuthenticatedRequest, + ) { + return withAuditLog( + this.logService, + req, + (result) => ({ + module: '入住管理', + action: '编辑入住记录', + targetId: +id, + targetType: 'occupancy', + detail: `入住日期 ${dto.checkInDate},计费起始 ${dto.billingStartDate}${ + dto.checkOutDate ? `,退宿日期 ${dto.checkOutDate}` : '' + }`, + }), + () => this.service.update(+id, dto), + ); + } + @Delete(':id') @RequirePermission('occupancy:delete') async remove(@Param('id') id: string, @Request() req: AuthenticatedRequest) { diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts index e74977a2..9b8d3c7e 100644 --- a/apps/server/src/occupancies/occupancies.service.spec.ts +++ b/apps/server/src/occupancies/occupancies.service.spec.ts @@ -560,3 +560,176 @@ describe('OccupanciesService — import deposit boundaries', () => { ); }); }); + +describe('OccupanciesService — update occupancy', () => { + const createUpdateService = (occupancy: Occupancy | null) => { + const occupancyRepo = { + findOne: jest.fn().mockResolvedValue(occupancy), + save: jest.fn(async (value: Occupancy) => ({ ...value, id: value.id ?? 1 })), + } as any as Repository; + const service = new OccupanciesService( + occupancyRepo, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as DataSource, + {} as Repository, + ); + return { service, occupancyRepo }; + }; + + const activeOccupancy = { + id: 1, + roomId: 2, + studentId: 3, + bedId: 4, + checkInDate: '2026-07-10', + billingStartDate: '2026-07-10', + checkOutDate: null, + billingEndDate: null, + checkOutReason: null, + status: 'active', + } as Occupancy; + + const checkedOutOccupancy = { + ...activeOccupancy, + checkOutDate: '2026-08-01', + billingEndDate: '2026-07-31', + checkOutReason: '结业', + } as Occupancy; + + it('rejects editing a non-existent occupancy', async () => { + const { service } = createUpdateService(null); + await expect( + service.update(999, { checkInDate: '2026-07-11', billingStartDate: '2026-07-11' }), + ).rejects.toThrow('入住记录不存在'); + }); + + it('rejects editing an archived occupancy', async () => { + const { service } = createUpdateService({ + ...activeOccupancy, + status: 'archived', + } as Occupancy); + await expect( + service.update(1, { checkInDate: '2026-07-11', billingStartDate: '2026-07-11' }), + ).rejects.toThrow('已归档记录不可编辑'); + }); + + it('rejects checkout fields on an active occupancy', async () => { + const { service } = createUpdateService(activeOccupancy); + await expect( + service.update(1, { + checkInDate: '2026-07-11', + billingStartDate: '2026-07-11', + checkOutDate: '2026-07-20', + }), + ).rejects.toThrow('在住记录不可修改退宿信息,如需退宿请使用退宿功能'); + await expect( + service.update(1, { + checkInDate: '2026-07-11', + billingStartDate: '2026-07-11', + billingEndDate: '2026-07-20', + }), + ).rejects.toThrow('在住记录不可修改退宿信息,如需退宿请使用退宿功能'); + await expect( + service.update(1, { + checkInDate: '2026-07-11', + billingStartDate: '2026-07-11', + checkOutReason: '结业', + }), + ).rejects.toThrow('在住记录不可修改退宿信息,如需退宿请使用退宿功能'); + }); + + it('requires keeping checkOutDate on a checked-out occupancy', async () => { + const { service } = createUpdateService(checkedOutOccupancy); + await expect( + service.update(1, { checkInDate: '2026-07-10', billingStartDate: '2026-07-10' }), + ).rejects.toThrow('已退宿记录必须保留退宿日期,如需恢复在住请重新办理入住'); + }); + + it.each([ + [ + { checkInDate: '2026-07-12', billingStartDate: '2026-07-11' }, + '计费起始日不能早于入住日期', + ], + ])('rejects billing start before check-in', async (dto, message) => { + const { service } = createUpdateService(activeOccupancy); + await expect(service.update(1, dto as any)).rejects.toThrow(message); + }); + + it('rejects check-out before check-in', async () => { + const { service } = createUpdateService(checkedOutOccupancy); + await expect( + service.update(1, { + checkInDate: '2026-07-10', + billingStartDate: '2026-07-10', + checkOutDate: '2026-07-09', + }), + ).rejects.toThrow('退宿日期不能早于入住日期'); + }); + + it('rejects billing end before billing start', async () => { + const { service } = createUpdateService(checkedOutOccupancy); + await expect( + service.update(1, { + checkInDate: '2026-07-10', + billingStartDate: '2026-07-15', + checkOutDate: '2026-08-01', + billingEndDate: '2026-07-14', + }), + ).rejects.toThrow('计费截止日不能早于计费起始日'); + }); + + it('rejects billing end after check-out', async () => { + const { service } = createUpdateService(checkedOutOccupancy); + await expect( + service.update(1, { + checkInDate: '2026-07-10', + billingStartDate: '2026-07-10', + checkOutDate: '2026-08-01', + billingEndDate: '2026-08-02', + }), + ).rejects.toThrow('计费截止日不能晚于退宿日期'); + }); + + it('updates only the date fields of an active occupancy', async () => { + const { service, occupancyRepo } = createUpdateService(activeOccupancy); + const result = await service.update(1, { + checkInDate: '2026-07-11', + billingStartDate: '2026-07-12', + }); + expect(occupancyRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + checkInDate: '2026-07-11', + billingStartDate: '2026-07-12', + checkOutDate: null, + roomId: 2, + bedId: 4, + }), + ); + expect(result).toEqual(expect.objectContaining({ id: 1, checkInDate: '2026-07-11' })); + }); + + it('updates checkout fields of a checked-out occupancy and keeps billing end when omitted', async () => { + const { service, occupancyRepo } = createUpdateService(checkedOutOccupancy); + const result = await service.update(1, { + checkInDate: '2026-07-09', + billingStartDate: '2026-07-09', + checkOutDate: '2026-08-05', + checkOutReason: ' 退训 ', + }); + expect(occupancyRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + checkInDate: '2026-07-09', + billingStartDate: '2026-07-09', + checkOutDate: '2026-08-05', + billingEndDate: '2026-07-31', + checkOutReason: '退训', + }), + ); + expect(result).toEqual(expect.objectContaining({ checkOutDate: '2026-08-05' })); + }); +}); diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index 0df6bbc5..2fe65367 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -9,7 +9,7 @@ import { Locker } from '../entities/locker.entity'; import { Deposit } from '../entities/deposit.entity'; import { Organization } from '../entities/organization.entity'; import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; -import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto'; +import { CheckInDto, CheckOutDto, TransferRoomDto, UpdateOccupancyDto } from './dto/occupancy.dto'; import { OccupancyOperationsService } from './occupancy-operations.service'; @@ -167,6 +167,49 @@ export class OccupanciesService { }); } + async update(id: number, dto: UpdateOccupancyDto) { + const occ = await this.repo.findOne({ where: { id } }); + if (!occ) throw new NotFoundException('入住记录不存在'); + if (occ.status === 'archived') throw new BadRequestException('已归档记录不可编辑'); + + this.assertDateOnly(dto.checkInDate, '入住日期'); + this.assertDateOnly(dto.billingStartDate, '计费起始日'); + this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期'); + + if (occ.checkOutDate) { + // 已退宿记录:退宿日期必须保留(清空即改回在住,需走重新入住流程) + if (!dto.checkOutDate) { + throw new BadRequestException('已退宿记录必须保留退宿日期,如需恢复在住请重新办理入住'); + } + this.assertDateOnly(dto.checkOutDate, '退宿日期'); + this.assertDateOrder(dto.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); + if (dto.billingEndDate) { + this.assertDateOnly(dto.billingEndDate, '计费截止日'); + this.assertDateOrder( + dto.billingStartDate, + dto.billingEndDate, + '计费截止日不能早于计费起始日', + ); + this.assertDateOrder(dto.billingEndDate, dto.checkOutDate, '计费截止日不能晚于退宿日期'); + } + occ.checkInDate = dto.checkInDate; + occ.billingStartDate = dto.billingStartDate; + occ.checkOutDate = dto.checkOutDate; + // 未提交的字段保留原值(实体列可空但 TS 类型为非空,与既有退宿逻辑一致) + if (dto.billingEndDate !== undefined) occ.billingEndDate = dto.billingEndDate; + occ.checkOutReason = (dto.checkOutReason ?? occ.checkOutReason).trim(); + } else { + // 在住记录只能修改入住日期/计费起始日;退宿信息走退宿功能 + if (dto.checkOutDate || dto.billingEndDate || dto.checkOutReason) { + throw new BadRequestException('在住记录不可修改退宿信息,如需退宿请使用退宿功能'); + } + occ.checkInDate = dto.checkInDate; + occ.billingStartDate = dto.billingStartDate; + } + + return this.repo.save(occ); + } + private withPessimisticWriteLock( qb: SelectQueryBuilder, ): SelectQueryBuilder { diff --git a/apps/server/src/rbac/rbac-presets.ts b/apps/server/src/rbac/rbac-presets.ts index 2a47c5bf..bd4c60d0 100644 --- a/apps/server/src/rbac/rbac-presets.ts +++ b/apps/server/src/rbac/rbac-presets.ts @@ -25,6 +25,7 @@ export const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: stri { code: 'occupancy:checkout', name: '办理退宿', group: 'occupancy' }, { code: 'occupancy:transfer', name: '调换宿舍', group: 'occupancy' }, { code: 'occupancy:delete', name: '归档入住记录', group: 'occupancy' }, + { code: 'occupancy:edit', name: '编辑入住记录', group: 'occupancy' }, { code: 'expense:view', name: '查看费用', group: 'expense' }, { code: 'expense:create', name: '录入费用', group: 'expense' }, { code: 'expense:edit', name: '编辑费用', group: 'expense' },