feat(server): 入住记录新增编辑接口与权限迁移

This commit is contained in:
2026-08-11 17:28:16 +08:00
parent f858d90b76
commit 353a6c72e1
8 changed files with 438 additions and 4 deletions

View File

@@ -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<void> {
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<void> {
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'`);
}
}

View File

@@ -1,6 +1,6 @@
import { ValidationPipe } from '@nestjs/common'; import { ValidationPipe } from '@nestjs/common';
import { validate } from 'class-validator'; import { validate } from 'class-validator';
import { CheckInDto, TransferRoomDto } from './occupancy.dto'; import { CheckInDto, TransferRoomDto, UpdateOccupancyDto } from './occupancy.dto';
describe('manual occupancy DTO bed requirements', () => { describe('manual occupancy DTO bed requirements', () => {
it('requires a bed for manual check-in', async () => { it('requires a bed for manual check-in', async () => {
@@ -98,3 +98,74 @@ describe('occupancy date boundaries', () => {
await expect(validate(dto)).resolves.toHaveLength(0); 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');
});
});

View File

@@ -6,6 +6,7 @@ import {
IsOptional, IsOptional,
IsString, IsString,
Matches, Matches,
MaxLength,
Min, Min,
} from 'class-validator'; } from 'class-validator';
@@ -102,3 +103,25 @@ export class BatchCheckOutDto {
@IsString() @IsString()
checkOutReason?: string; 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; // 退宿原因,对齐实体列宽
}

View File

@@ -33,3 +33,65 @@ describe('OccupanciesController permissions', () => {
).toEqual(['occupancy:purge']); ).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',
}),
);
});
});

View File

@@ -25,10 +25,10 @@ import type { Response } from 'express';
import { OccupanciesService } from './occupancies.service'; import { OccupanciesService } from './occupancies.service';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
import { NotificationType } from '../entities/notification.entity'; 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 { 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, withAuditLog } from '../common/with-audit-log';
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 { BatchIdsDto } from '../common/batch-ids.dto'; import { BatchIdsDto } from '../common/batch-ids.dto';
@@ -149,6 +149,30 @@ export class OccupanciesController {
return result; 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') @Delete(':id')
@RequirePermission('occupancy:delete') @RequirePermission('occupancy:delete')
async remove(@Param('id') id: string, @Request() req: AuthenticatedRequest) { async remove(@Param('id') id: string, @Request() req: AuthenticatedRequest) {

View File

@@ -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<Occupancy>;
const service = new OccupanciesService(
occupancyRepo,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
{} as DataSource,
{} as Repository<any>,
);
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' }));
});
});

View File

@@ -9,7 +9,7 @@ import { Locker } from '../entities/locker.entity';
import { Deposit } from '../entities/deposit.entity'; import { Deposit } from '../entities/deposit.entity';
import { Organization } from '../entities/organization.entity'; import { Organization } from '../entities/organization.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.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'; 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<T extends ObjectLiteral>( private withPessimisticWriteLock<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>, qb: SelectQueryBuilder<T>,
): SelectQueryBuilder<T> { ): SelectQueryBuilder<T> {

View File

@@ -25,6 +25,7 @@ export const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: stri
{ code: 'occupancy:checkout', name: '办理退宿', group: 'occupancy' }, { code: 'occupancy:checkout', name: '办理退宿', group: 'occupancy' },
{ code: 'occupancy:transfer', name: '调换宿舍', group: 'occupancy' }, { code: 'occupancy:transfer', name: '调换宿舍', group: 'occupancy' },
{ code: 'occupancy:delete', name: '归档入住记录', group: 'occupancy' }, { code: 'occupancy:delete', name: '归档入住记录', group: 'occupancy' },
{ code: 'occupancy:edit', name: '编辑入住记录', group: 'occupancy' },
{ code: 'expense:view', name: '查看费用', group: 'expense' }, { code: 'expense:view', name: '查看费用', group: 'expense' },
{ code: 'expense:create', name: '录入费用', group: 'expense' }, { code: 'expense:create', name: '录入费用', group: 'expense' },
{ code: 'expense:edit', name: '编辑费用', group: 'expense' }, { code: 'expense:edit', name: '编辑费用', group: 'expense' },