forked from wangziqi/gongxue-base
Compare commits
7 Commits
codex/xyx
...
79fa472b78
| Author | SHA1 | Date | |
|---|---|---|---|
| 79fa472b78 | |||
| 029af37f3a | |||
| d572e984d2 | |||
| a93ba657a8 | |||
| 77714642a5 | |||
| e7aa202603 | |||
| 013b3f4afe |
@@ -38,11 +38,166 @@ const typeMap: Record<string, string> = {
|
|||||||
water: '水费',
|
water: '水费',
|
||||||
electricity: '电费',
|
electricity: '电费',
|
||||||
cleaning: '保洁费',
|
cleaning: '保洁费',
|
||||||
|
rent: '租金',
|
||||||
damage: '损坏赔偿',
|
damage: '损坏赔偿',
|
||||||
penalty: '罚款',
|
penalty: '罚款',
|
||||||
other: '其他',
|
other: '其他',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const escapeHtml = (value: unknown) =>
|
||||||
|
String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
|
||||||
|
const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`;
|
||||||
|
|
||||||
|
const buildBillPrintHtml = (bill: any) => {
|
||||||
|
const studentName = bill.student?.name || '-';
|
||||||
|
const status = statusMap[bill.status]?.text || bill.status || '-';
|
||||||
|
const generatedAt = bill.generatedAt
|
||||||
|
? dayjs(bill.generatedAt).format('YYYY-MM-DD HH:mm')
|
||||||
|
: dayjs().format('YYYY-MM-DD HH:mm');
|
||||||
|
const hasDeposit = Number(bill.availableDeposit || 0) > 0;
|
||||||
|
const items = bill.items || [];
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>账单_${escapeHtml(studentName)}_${escapeHtml(bill.id)}</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4; margin: 0; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
color: #000;
|
||||||
|
background: #f5f5f5;
|
||||||
|
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans CJK SC", Arial, sans-serif;
|
||||||
|
-webkit-print-color-adjust: exact;
|
||||||
|
print-color-adjust: exact;
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
width: 210mm;
|
||||||
|
min-height: 297mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 50px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
h1 { margin: 0; text-align: center; font-size: 20px; line-height: 1.35; font-weight: 700; }
|
||||||
|
.generated-time { margin-top: 8px; text-align: center; color: #666; font-size: 10px; }
|
||||||
|
.basic-info { margin-top: 24px; font-size: 12px; line-height: 1.8; }
|
||||||
|
.section-title { margin: 16px 0 6px; font-size: 14px; font-weight: 700; text-decoration: underline; }
|
||||||
|
.amount-summary { font-size: 12px; line-height: 1.75; }
|
||||||
|
.total { color: #007aff; font-size: 14px; font-weight: 700; }
|
||||||
|
.deposit { color: #52c41a; font-size: 11px; }
|
||||||
|
.deposit-applied { color: #fa8c16; font-size: 11px; }
|
||||||
|
.after-deposit { color: #ff3b30; font-size: 14px; font-weight: 700; }
|
||||||
|
table { width: 100%; border-collapse: collapse; table-layout: fixed; margin-top: 8px; }
|
||||||
|
th, td { padding: 5px 6px; border-bottom: 1px solid #ccc; font-size: 9px; line-height: 1.45; text-align: left; vertical-align: top; word-break: break-word; }
|
||||||
|
th { color: #333; font-weight: 700; }
|
||||||
|
td.amount, th.amount { text-align: right; white-space: nowrap; }
|
||||||
|
.footer { margin-top: 34px; text-align: center; color: #999; font-size: 8px; }
|
||||||
|
.print-actions {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
top: 18px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.print-actions button {
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid #1f6feb;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #1f6feb;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body { background: #fff; }
|
||||||
|
.page { margin: 0; }
|
||||||
|
.print-actions { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="print-actions">
|
||||||
|
<button onclick="window.print()">打印 / 另存为 PDF</button>
|
||||||
|
</div>
|
||||||
|
<main class="page">
|
||||||
|
<h1>恭学教育基地水电费账单</h1>
|
||||||
|
<div class="generated-time">生成时间: ${escapeHtml(generatedAt)}</div>
|
||||||
|
|
||||||
|
<div class="basic-info">
|
||||||
|
<div>学生姓名: ${escapeHtml(studentName)}</div>
|
||||||
|
<div>计费周期: ${escapeHtml(bill.periodStart)} ~ ${escapeHtml(bill.periodEnd)}</div>
|
||||||
|
<div>账单状态: ${escapeHtml(status)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-title">费用汇总</div>
|
||||||
|
<div class="amount-summary">
|
||||||
|
<div>分摊费用: ${escapeHtml(money(bill.sharedAmount))}</div>
|
||||||
|
<div>个人费用: ${escapeHtml(money(bill.personalAmount))}</div>
|
||||||
|
<div class="total">应付总额: ${escapeHtml(money(bill.totalAmount))}</div>
|
||||||
|
${
|
||||||
|
hasDeposit
|
||||||
|
? `<div class="deposit">可用押金: ${escapeHtml(money(bill.availableDeposit))}</div>
|
||||||
|
<div class="deposit-applied">押金抵扣: -${escapeHtml(money(bill.depositApplied))}</div>
|
||||||
|
<div class="after-deposit">抵扣后应付: ${escapeHtml(money(bill.amountAfterDeposit ?? bill.totalAmount))}</div>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div class="section-title">费用明细</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 22%;">费用类型</th>
|
||||||
|
<th>说明</th>
|
||||||
|
<th style="width: 11%;">天数</th>
|
||||||
|
<th style="width: 12%;">总人天</th>
|
||||||
|
<th class="amount" style="width: 16%;">金额(元)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${
|
||||||
|
items.length
|
||||||
|
? items
|
||||||
|
.map(
|
||||||
|
(item: any) => `<tr>
|
||||||
|
<td>${escapeHtml(typeMap[item.expenseType] || item.expenseType || '-')}</td>
|
||||||
|
<td>${escapeHtml(item.description || '-')}</td>
|
||||||
|
<td>${escapeHtml(item.days || 0)}</td>
|
||||||
|
<td>${escapeHtml(item.totalRoomDays || 0)}</td>
|
||||||
|
<td class="amount">${escapeHtml(Number(item.studentAmount || 0).toFixed(2))}</td>
|
||||||
|
</tr>`,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
: '<tr><td colspan="5" style="text-align:center; color:#999;">暂无费用明细</td></tr>'
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
本账单由恭学教育基地管理系统自动生成
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
setTimeout(() => window.print(), 250);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
};
|
||||||
|
|
||||||
const BillsPage: React.FC = () => {
|
const BillsPage: React.FC = () => {
|
||||||
const [bills, setBills] = useState<any[]>([]);
|
const [bills, setBills] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -181,11 +336,24 @@ const BillsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleExportPdf = (billId: number) => {
|
const handleExportPdf = useCallback(async (billId: number) => {
|
||||||
downloadBlob(`/bills/export/pdf/${billId}`, `账单_${billId}.pdf`).catch(() =>
|
const printWindow = window.open('', '_blank');
|
||||||
message.error('导出失败'),
|
if (!printWindow) {
|
||||||
);
|
message.error('无法打开打印窗口,请允许浏览器弹窗后重试');
|
||||||
};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
printWindow.document.write('<!doctype html><title>账单加载中</title><body>账单加载中...</body>');
|
||||||
|
try {
|
||||||
|
const bill = await api.get(`/bills/${billId}`);
|
||||||
|
printWindow.document.open();
|
||||||
|
printWindow.document.write(buildBillPrintHtml(bill));
|
||||||
|
printWindow.document.close();
|
||||||
|
} catch (e: any) {
|
||||||
|
printWindow.close();
|
||||||
|
message.error(e?.message || '账单数据加载失败');
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const columns = useMemo(() => [
|
const columns = useMemo(() => [
|
||||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildDepositStudentOption } from './deposit-student-option';
|
import { buildDepositStudentOption, buildDepositStudentOptions } from './deposit-student-option';
|
||||||
|
|
||||||
describe('deposit student option', () => {
|
describe('deposit student option', () => {
|
||||||
it('uses the student number as the non-sensitive identifier', () => {
|
it('uses the student number as the non-sensitive identifier', () => {
|
||||||
@@ -17,4 +17,13 @@ describe('deposit student option', () => {
|
|||||||
label: '张三 (#23)',
|
label: '张三 (#23)',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses lookup rows without requiring a status field', () => {
|
||||||
|
expect(buildDepositStudentOptions([{ id: 23, name: '张三', studentNo: 'S2026001' }])).toEqual([
|
||||||
|
{
|
||||||
|
value: 23,
|
||||||
|
label: '张三 (S2026001)',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ export const buildDepositStudentOption = (student: DepositStudentLookup) => ({
|
|||||||
value: student.id,
|
value: student.id,
|
||||||
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
|
label: `${student.name} (${student.studentNo || `#${student.id}`})`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const buildDepositStudentOptions = (students: DepositStudentLookup[]) =>
|
||||||
|
students.map(buildDepositStudentOption);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import dayjs from 'dayjs';
|
|||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import { buildDepositStudentOption } from './deposit-student-option';
|
import { buildDepositStudentOptions } from './deposit-student-option';
|
||||||
|
|
||||||
const statusMap: Record<string, { text: string; color: string }> = {
|
const statusMap: Record<string, { text: string; color: string }> = {
|
||||||
paid: { text: '已缴', color: 'green' },
|
paid: { text: '已缴', color: 'green' },
|
||||||
@@ -86,10 +86,7 @@ const DepositsPage: React.FC = () => {
|
|||||||
}, [data, searchText, filterStatus]);
|
}, [data, searchText, filterStatus]);
|
||||||
|
|
||||||
const studentOptions = useMemo(
|
const studentOptions = useMemo(
|
||||||
() =>
|
() => buildDepositStudentOptions(students),
|
||||||
students
|
|
||||||
.filter((s: any) => s.status === 'active')
|
|
||||||
.map(buildDepositStudentOption),
|
|
||||||
[students],
|
[students],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { Bill } from '../entities/bill.entity';
|
|||||||
import { BillItem } from '../entities/bill-item.entity';
|
import { BillItem } from '../entities/bill-item.entity';
|
||||||
import { Deposit } from '../entities/deposit.entity';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
import * as ExcelJS from 'exceljs';
|
import * as ExcelJS from 'exceljs';
|
||||||
import * as PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
136
apps/server/src/rooms/rooms.service.spec.ts
Normal file
136
apps/server/src/rooms/rooms.service.spec.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||||
|
import { Bed } from '../entities/bed.entity';
|
||||||
|
import { Locker } from '../entities/locker.entity';
|
||||||
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
|
import { Room } from '../entities/room.entity';
|
||||||
|
import { RoomExpense } from '../entities/room-expense.entity';
|
||||||
|
import { RoomsService } from './rooms.service';
|
||||||
|
|
||||||
|
describe('RoomsService — capacity consistency', () => {
|
||||||
|
const createService = (options?: {
|
||||||
|
room?: Partial<Room>;
|
||||||
|
beds?: Partial<Bed>[];
|
||||||
|
activeOccupantCount?: number;
|
||||||
|
}) => {
|
||||||
|
const room = { id: 1, capacity: 2, status: 'full', ...options?.room } as Room;
|
||||||
|
const beds = (options?.beds ?? [
|
||||||
|
{ id: 1, roomId: 1, bedNumber: '1号床' },
|
||||||
|
{ id: 2, roomId: 1, bedNumber: '2号床' },
|
||||||
|
]) as Bed[];
|
||||||
|
|
||||||
|
const roomRepo = {
|
||||||
|
findOne: jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce(room)
|
||||||
|
.mockResolvedValue({ ...room, capacity: 4 }),
|
||||||
|
update: jest.fn().mockResolvedValue(undefined),
|
||||||
|
} as unknown as Repository<Room>;
|
||||||
|
const bedRepo = {
|
||||||
|
find: jest.fn().mockResolvedValue(beds),
|
||||||
|
create: jest.fn((value) => value),
|
||||||
|
save: jest.fn(async (value) => value),
|
||||||
|
} as unknown as Repository<Bed>;
|
||||||
|
const occupancyRepo = {
|
||||||
|
count: jest.fn().mockResolvedValue(options?.activeOccupantCount ?? 2),
|
||||||
|
} as unknown as Repository<Occupancy>;
|
||||||
|
|
||||||
|
const manager = {
|
||||||
|
getRepository: jest.fn((entity) => {
|
||||||
|
if (entity === Room) return roomRepo;
|
||||||
|
if (entity === Bed) return bedRepo;
|
||||||
|
if (entity === Occupancy) return occupancyRepo;
|
||||||
|
throw new Error(`Unexpected repository: ${String(entity)}`);
|
||||||
|
}),
|
||||||
|
} as unknown as EntityManager;
|
||||||
|
const dataSource = {
|
||||||
|
transaction: jest.fn(async (callback) => callback(manager)),
|
||||||
|
} as unknown as DataSource;
|
||||||
|
|
||||||
|
const service = new RoomsService(
|
||||||
|
roomRepo,
|
||||||
|
occupancyRepo,
|
||||||
|
{} as Repository<RoomExpense>,
|
||||||
|
bedRepo,
|
||||||
|
{} as Repository<Locker>,
|
||||||
|
dataSource,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { service, roomRepo, bedRepo, occupancyRepo };
|
||||||
|
};
|
||||||
|
|
||||||
|
it('automatically creates missing beds when capacity increases', async () => {
|
||||||
|
const { service, roomRepo, bedRepo } = createService();
|
||||||
|
|
||||||
|
await service.update(1, { capacity: 4 });
|
||||||
|
|
||||||
|
expect(bedRepo.create).toHaveBeenNthCalledWith(1, { roomId: 1, bedNumber: '3号床' });
|
||||||
|
expect(bedRepo.create).toHaveBeenNthCalledWith(2, { roomId: 1, bedNumber: '4号床' });
|
||||||
|
expect(bedRepo.save).toHaveBeenCalledWith([
|
||||||
|
{ roomId: 1, bedNumber: '3号床' },
|
||||||
|
{ roomId: 1, bedNumber: '4号床' },
|
||||||
|
]);
|
||||||
|
expect(roomRepo.update).toHaveBeenCalledWith(1, { capacity: 4, status: 'available' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects capacity lower than the active occupant count', async () => {
|
||||||
|
const { service, roomRepo, bedRepo } = createService({
|
||||||
|
room: { capacity: 4, status: 'available' },
|
||||||
|
beds: [{ id: 1, roomId: 1, bedNumber: '1号床' }],
|
||||||
|
activeOccupantCount: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.update(1, { capacity: 2 })).rejects.toThrow(
|
||||||
|
new BadRequestException('额定人数不能少于当前入住人数,当前有 3 人入住'),
|
||||||
|
);
|
||||||
|
expect(roomRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(bedRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects capacity lower than the existing bed count', async () => {
|
||||||
|
const { service, roomRepo, bedRepo } = createService({
|
||||||
|
room: { capacity: 4, status: 'available' },
|
||||||
|
activeOccupantCount: 1,
|
||||||
|
beds: [
|
||||||
|
{ id: 1, roomId: 1, bedNumber: '1号床' },
|
||||||
|
{ id: 2, roomId: 1, bedNumber: '2号床' },
|
||||||
|
{ id: 3, roomId: 1, bedNumber: '3号床' },
|
||||||
|
{ id: 4, roomId: 1, bedNumber: '4号床' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.update(1, { capacity: 3 })).rejects.toThrow(
|
||||||
|
new BadRequestException(
|
||||||
|
'额定人数不能少于现有床位数,当前有 4 张床位,请先删除多余的空闲床位',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(roomRepo.update).not.toHaveBeenCalled();
|
||||||
|
expect(bedRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks the room full when a valid capacity reduction reaches the occupant count', async () => {
|
||||||
|
const { service, roomRepo, bedRepo } = createService({
|
||||||
|
room: { capacity: 4, status: 'available' },
|
||||||
|
activeOccupantCount: 2,
|
||||||
|
beds: [
|
||||||
|
{ id: 1, roomId: 1, bedNumber: '1号床' },
|
||||||
|
{ id: 2, roomId: 1, bedNumber: '2号床' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.update(1, { capacity: 2 });
|
||||||
|
|
||||||
|
expect(roomRepo.update).toHaveBeenCalledWith(1, { capacity: 2, status: 'full' });
|
||||||
|
expect(bedRepo.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates other room fields without changing beds', async () => {
|
||||||
|
const { service, roomRepo, bedRepo, occupancyRepo } = createService();
|
||||||
|
|
||||||
|
await service.update(1, { building: '2号楼' });
|
||||||
|
|
||||||
|
expect(roomRepo.update).toHaveBeenCalledWith(1, { building: '2号楼' });
|
||||||
|
expect(bedRepo.find).not.toHaveBeenCalled();
|
||||||
|
expect(occupancyRepo.count).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, Like, IsNull, Not, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
import {
|
||||||
|
DataSource,
|
||||||
|
Repository,
|
||||||
|
Like,
|
||||||
|
IsNull,
|
||||||
|
Not,
|
||||||
|
In,
|
||||||
|
LessThanOrEqual,
|
||||||
|
MoreThanOrEqual,
|
||||||
|
} 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';
|
||||||
@@ -19,6 +28,7 @@ export class RoomsService {
|
|||||||
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
||||||
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
|
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
|
||||||
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
||||||
|
private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -116,9 +126,54 @@ export class RoomsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async update(id: number, dto: UpdateRoomDto) {
|
async update(id: number, dto: UpdateRoomDto) {
|
||||||
await this.findOne(id);
|
return this.dataSource.transaction(async (manager) => {
|
||||||
await this.repo.update(id, dto);
|
const roomRepo = manager.getRepository(Room);
|
||||||
return this.repo.findOne({ where: { id } });
|
const bedRepo = manager.getRepository(Bed);
|
||||||
|
const occupancyRepo = manager.getRepository(Occupancy);
|
||||||
|
const room = await roomRepo.findOne({ where: { id } });
|
||||||
|
if (!room) throw new NotFoundException('宿舍不存在');
|
||||||
|
|
||||||
|
if (dto.capacity !== undefined) {
|
||||||
|
const [beds, activeOccupantCount] = await Promise.all([
|
||||||
|
bedRepo.find({ where: { roomId: id }, order: { bedNumber: 'ASC' } }),
|
||||||
|
occupancyRepo.count({ where: { roomId: id, checkOutDate: IsNull() } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (dto.capacity < activeOccupantCount) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`额定人数不能少于当前入住人数,当前有 ${activeOccupantCount} 人入住`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dto.capacity < beds.length) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`额定人数不能少于现有床位数,当前有 ${beds.length} 张床位,请先删除多余的空闲床位`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.capacity > beds.length) {
|
||||||
|
const countToCreate = dto.capacity - beds.length;
|
||||||
|
const start = this.getNextBedNumber(beds);
|
||||||
|
const newBeds = Array.from({ length: countToCreate }, (_, index) =>
|
||||||
|
bedRepo.create({ roomId: id, bedNumber: `${start + index}号床` }),
|
||||||
|
);
|
||||||
|
await bedRepo.save(newBeds);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
dto.status === undefined &&
|
||||||
|
room.status !== 'maintenance' &&
|
||||||
|
room.status !== 'archived'
|
||||||
|
) {
|
||||||
|
dto = {
|
||||||
|
...dto,
|
||||||
|
status: activeOccupantCount >= dto.capacity ? 'full' : 'available',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await roomRepo.update(id, dto);
|
||||||
|
return roomRepo.findOne({ where: { id } });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: number) {
|
async remove(id: number) {
|
||||||
@@ -413,6 +468,14 @@ export class RoomsService {
|
|||||||
await this.bedRepo.save(beds);
|
await this.bedRepo.save(beds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getNextBedNumber(beds: Pick<Bed, 'bedNumber'>[]): number {
|
||||||
|
const numbers = beds.map((bed) => {
|
||||||
|
const match = bed.bedNumber.match(/^\d+/);
|
||||||
|
return match ? parseInt(match[0], 10) : 0;
|
||||||
|
});
|
||||||
|
return numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
private async assertCanAddBeds(room: Room, count: number): Promise<void> {
|
private async assertCanAddBeds(room: Room, count: number): Promise<void> {
|
||||||
const existingCount = await this.bedRepo.count({ where: { roomId: room.id } });
|
const existingCount = await this.bedRepo.count({ where: { roomId: room.id } });
|
||||||
this.assertCanAddBedsFromCount(room, existingCount, count);
|
this.assertCanAddBedsFromCount(room, existingCount, count);
|
||||||
@@ -421,7 +484,9 @@ export class RoomsService {
|
|||||||
private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void {
|
private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void {
|
||||||
const remaining = Math.max((room.capacity ?? 0) - existingCount, 0);
|
const remaining = Math.max((room.capacity ?? 0) - existingCount, 0);
|
||||||
if (count > remaining) {
|
if (count > remaining) {
|
||||||
throw new BadRequestException(`床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining} 张`);
|
throw new BadRequestException(
|
||||||
|
`床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining} 张`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user