feat(server): 入住记录新增编辑接口与权限迁移
This commit is contained in:
@@ -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'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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; // 退宿原因,对齐实体列宽
|
||||||
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
Reference in New Issue
Block a user