feat: replace tenants with organization management

This commit is contained in:
2026-07-10 21:27:26 +08:00
parent 8ed1682b90
commit 8f0991a51f
49 changed files with 1292 additions and 698 deletions

View File

@@ -22,7 +22,7 @@ const TeachersPage = lazy(() => import('./pages/Teachers'));
const StudentProfilePage = lazy(() => import('./pages/StudentProfile'));
const ClassesPage = lazy(() => import('./pages/Classes'));
const ClassDetailPage = lazy(() => import('./pages/Classes/detail'));
const TenantsPage = lazy(() => import('./pages/Tenants'));
const OrganizationsPage = lazy(() => import('./pages/Organizations'))
const ClassroomRentalsPage = lazy(() => import('./pages/ClassroomRentals'));
const ClassroomSchedulePage = lazy(() => import('./pages/ClassroomSchedule'));
const SchedulesPage = lazy(() => import('./pages/Schedules'));
@@ -205,10 +205,10 @@ const App: React.FC = () => {
}
/>
<Route
path="tenants"
path="organizations"
element={
<PermissionRoute permission="tenant:view">
<TenantsPage />
<PermissionRoute permission="organization:view">
<OrganizationsPage />
</PermissionRoute>
}
/>

View File

@@ -90,7 +90,7 @@ const allMenuItems: MenuItemType[] = [
{ key: '/classroom-schedule', icon: <CalendarOutlined />, label: '排期总览', permission: 'classroom:view' },
{ key: '/classrooms', icon: <ReadOutlined />, label: '教室列表', permission: 'classroom:view' },
{ key: '/classroom-rentals', icon: <FileProtectOutlined />, label: '租赁订单', permission: 'rental:view' },
{ key: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
{ key: '/organizations', icon: <TagsOutlined />, label: '机构管理', permission: 'organization:view' },
],
},
{

View File

@@ -31,7 +31,7 @@ export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) =>
const ClassroomRentalsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [classrooms, setClassrooms] = useState<any[]>([]);
const [tenants, setTenants] = useState<any[]>([]);
const [organizations, setOrganizations] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
@@ -50,8 +50,8 @@ const ClassroomRentalsPage: React.FC = () => {
const s = searchText.toLowerCase();
return data.filter((r: any) => {
const matchClassroom = r.classroom?.name?.toLowerCase().includes(s);
const matchTenant = r.tenant?.name?.toLowerCase().includes(s);
return matchClassroom || matchTenant;
const matchOrganization = r.lesseeOrganization?.name?.toLowerCase().includes(s);
return matchClassroom || matchOrganization;
});
}, [data, searchText]);
@@ -70,9 +70,12 @@ const ClassroomRentalsPage: React.FC = () => {
const fetchMeta = async () => {
try {
const [cr, tn]: any = await Promise.all([api.get('/classrooms'), api.get('/tenants')]);
const [cr, tn]: any = await Promise.all([
api.get('/classrooms'),
api.get('/organizations', { params: { scope: 'all' } }),
]);
setClassrooms(cr);
setTenants(tn);
setOrganizations(tn);
} catch (e: any) {
message.error(e?.message || '加载教室列表失败');
}
@@ -166,7 +169,8 @@ const ClassroomRentalsPage: React.FC = () => {
setSaving(true);
const payload = {
classroomId: values.classroomId,
tenantId: values.tenantId,
lessorOrganizationId: values.lessorOrganizationId,
lesseeOrganizationId: values.lesseeOrganizationId,
startDate: values.dateRange[0].format('YYYY-MM-DD'),
endDate: values.dateRange[1].format('YYYY-MM-DD'),
dailyRate: values.dailyRate,
@@ -188,7 +192,7 @@ const ClassroomRentalsPage: React.FC = () => {
} catch (e: any) {
if (e?.conflicts?.length) {
const list = e.conflicts
.map((c: any) => `${c.tenantName}(${c.startDate}~${c.endDate})`)
.map((c: any) => `${c.organizationName}(${c.startDate}~${c.endDate})`)
.join('、');
message.error(`时间段冲突:${list}`);
} else {
@@ -232,7 +236,8 @@ const ClassroomRentalsPage: React.FC = () => {
resetUnavailableDates();
form.setFieldsValue({
classroomId: record.classroomId,
tenantId: record.tenantId,
lessorOrganizationId: record.lessorOrganizationId,
lesseeOrganizationId: record.lesseeOrganizationId,
dateRange: [dayjs(record.startDate), dayjs(record.endDate)],
dailyRate: record.dailyRate ? Number(record.dailyRate) : undefined,
totalAmount: record.totalAmount ? Number(record.totalAmount) : undefined,
@@ -264,9 +269,9 @@ const ClassroomRentalsPage: React.FC = () => {
),
},
{
title: '租赁方',
title: '承租机构',
width: 100,
dataIndex: 'tenant',
dataIndex: 'lesseeOrganization',
render: (t: any) =>
t ? (
<Tag
@@ -392,7 +397,7 @@ const ClassroomRentalsPage: React.FC = () => {
>
<Space wrap>
<Input.Search
placeholder="搜索教室/租赁方"
placeholder="搜索教室/承租机构"
allowClear
style={{ width: 180 }}
onSearch={(v) => setSearchText(v)}
@@ -458,12 +463,35 @@ const ClassroomRentalsPage: React.FC = () => {
}))}
/>
</Form.Item>
<Form.Item name="tenantId" label="租赁方" rules={[{ required: true }]}>
<Form.Item name="lessorOrganizationId" label="出租机构" tooltip="默认由本机构出租">
<Select
showSearch
optionFilterProp="label"
placeholder="选择租赁方"
options={tenants.map((t) => ({ value: t.id, label: t.name }))}
placeholder="默认本机构"
allowClear
options={organizations
.filter((organization) => organization.isHost)
.map((organization) => ({
value: organization.id,
label: `${organization.name}(本机构)`,
}))}
/>
</Form.Item>
<Form.Item
name="lesseeOrganizationId"
label="承租机构"
rules={[{ required: true, message: '请选择承租机构' }]}
>
<Select
showSearch
optionFilterProp="label"
placeholder="选择外部承租机构"
options={organizations
.filter((organization) => !organization.isHost && organization.status === 'active')
.map((organization) => ({
value: organization.id,
label: organization.name,
}))}
/>
</Form.Item>
<Form.Item name="dateRange" label="租赁起止日期" rules={[{ required: true }]}>

View File

@@ -12,7 +12,7 @@ import {
Spin,
Empty,
Tooltip,
} from 'antd';
} from 'antd';
import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
@@ -24,7 +24,7 @@ interface ScheduleData {
month: number;
days: number;
classrooms: any[];
tenants: any[];
organizations: any[];
matrix: Record<number, Record<number, any>>;
summary: Record<
number,
@@ -95,10 +95,7 @@ const ClassroomSchedulePage: React.FC = () => {
const handleDownloadContract = async (id: number, filename?: string) => {
try {
await downloadBlob(
`/classroom-rentals/${id}/contract`,
filename || `contract-${id}.pdf`,
);
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
} catch {
// downloadBlob already shows an error via throw
}
@@ -175,7 +172,7 @@ const ClassroomSchedulePage: React.FC = () => {
<Card size="small" style={{ marginBottom: 16 }} title="图例">
<Space wrap>
<Tag color="#52c41a"></Tag>
{data.tenants.map((t) => (
{data.organizations.map((t) => (
<Tag
key={t.id}
color={t.color}
@@ -184,7 +181,9 @@ const ClassroomSchedulePage: React.FC = () => {
{t.name} ()
</Tag>
))}
<Tag color="#d9d9d9" style={{ color: '#999' }}></Tag>
<Tag color="#d9d9d9" style={{ color: '#999' }}>
</Tag>
</Space>
</Card>
)}
@@ -310,7 +309,7 @@ const ClassroomSchedulePage: React.FC = () => {
title={
isInternal
? `${cell.className} · ${cell.subject}\n${cell.teacherName} · ${cell.startTime}-${cell.endTime}`
: `${cell.tenantName}${cell.hasContract ? ' · 有合同' : ''}`
: `${cell.organizationName}${cell.hasContract ? ' · 有合同' : ''}`
}
>
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
@@ -347,21 +346,22 @@ const ClassroomSchedulePage: React.FC = () => {
{detailModal.classroom?.roomType}
</div>
<div>
<strong></strong>
<strong></strong>
<Tag
color={detailModal.tenant?.color}
color={detailModal.lesseeOrganization?.color}
style={{
background: detailModal.tenant?.color,
background: detailModal.lesseeOrganization?.color,
color: '#fff',
borderColor: detailModal.tenant?.color,
borderColor: detailModal.lesseeOrganization?.color,
}}
>
{detailModal.tenant?.name}
{detailModal.lesseeOrganization?.name}
</Tag>
</div>
<div>
<strong></strong>
{detailModal.tenant?.contact || '-'} {detailModal.tenant?.phone || ''}
{detailModal.lesseeOrganization?.contactName || '-'}{' '}
{detailModal.lesseeOrganization?.phone || ''}
</div>
<div>
<strong></strong>

View File

@@ -39,7 +39,7 @@ const OccupanciesPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]);
const [rooms, setRooms] = useState<any[]>([]);
const [tenants, setTenants] = useState<any[]>([]);
const [organizations, setOrganizations] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [checkInModal, setCheckInModal] = useState(false);
const [checkOutModal, setCheckOutModal] = useState<any>(null);
@@ -67,9 +67,9 @@ const OccupanciesPage: React.FC = () => {
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
api.get('/students'),
api.get('/rooms/overview'),
api.get('/tenants'),
api.get('/organizations'),
])) as PromiseSettledResult<any>[];
const labels = ['入住数据', '学生列表', '房间列表', '租赁方'];
const labels = ['入住数据', '学生列表', '房间列表', '机构列表'];
[occRes, stuRes, rmRes, tnRes].forEach((res, i) => {
if (res.status === 'rejected') {
message.warning(`${labels[i]}加载失败`);
@@ -78,7 +78,7 @@ const OccupanciesPage: React.FC = () => {
setData(occRes.status === 'fulfilled' ? occRes.value : []);
setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []);
setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []);
setTenants(tnRes.status === 'fulfilled' ? tnRes.value : []);
setOrganizations(tnRes.status === 'fulfilled' ? tnRes.value : []);
} catch (e) {
console.error(e);
message.error('数据加载异常');
@@ -129,8 +129,8 @@ const OccupanciesPage: React.FC = () => {
roomId: values.roomId,
checkInDate: values.checkInDate.format('YYYY-MM-DD'),
billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'),
rentalType: values.rentalType,
tenantId: values.tenantId,
stayType: values.stayType,
responsibleOrganizationId: values.responsibleOrganizationId,
notes: values.notes,
bedId: values.bedId,
lockerId: values.lockerId || undefined,
@@ -556,7 +556,7 @@ const OccupanciesPage: React.FC = () => {
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="rentalType" label="租赁类型">
<Form.Item name="stayType" label="入住类型">
<Select
allowClear
options={[
@@ -566,13 +566,13 @@ const OccupanciesPage: React.FC = () => {
placeholder="默认为短租"
/>
</Form.Item>
<Form.Item name="tenantId" label="关联单位">
<Form.Item name="responsibleOrganizationId" label="负责机构">
<Select
showSearch
allowClear
optionFilterProp="label"
placeholder="选择关联单位"
options={tenants.map((t: { id: number; name: string }) => ({
placeholder="默认取学生所属机构"
options={organizations.map((t: { id: number; name: string }) => ({
value: t.id,
label: t.name,
}))}

View File

@@ -0,0 +1,314 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
import { BankOutlined, InboxOutlined, PlusOutlined } from '@ant-design/icons';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
const PRESET_COLORS = [
'#ff7875',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
interface OrganizationItem {
id: number;
code: string;
name: string;
isHost: boolean;
contactName?: string;
phone?: string;
color?: string;
notes?: string;
status: 'active' | 'archived';
}
const OrganizationsPage: React.FC = () => {
const [data, setData] = useState<OrganizationItem[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<OrganizationItem | null>(null);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string>();
const filteredData = useMemo(() => {
const keyword = searchText.trim().toLowerCase();
return data.filter((item) => {
const matchesKeyword =
!keyword ||
item.name.toLowerCase().includes(keyword) ||
item.code.toLowerCase().includes(keyword) ||
item.contactName?.toLowerCase().includes(keyword);
return matchesKeyword && (!filterStatus || item.status === filterStatus);
});
}, [data, searchText, filterStatus]);
const fetchData = async () => {
setLoading(true);
try {
setData(
await api.get<OrganizationItem[]>('/organizations', { params: { includeArchived: true } }),
);
} catch (error: any) {
message.error(error?.message || '机构数据加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void fetchData();
}, []);
const openEditor = (record?: OrganizationItem) => {
setEditing(record ?? null);
form.resetFields();
if (record) form.setFieldsValue(record);
else form.setFieldsValue({ color: PRESET_COLORS[data.length % PRESET_COLORS.length] });
setModalOpen(true);
};
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) await api.put(`/organizations/${editing.id}`, values);
else await api.post('/organizations', values);
message.success(editing ? '机构已更新' : '机构已创建');
setModalOpen(false);
await fetchData();
} catch (error: any) {
message.error(error?.message || '保存失败');
} finally {
setSaving(false);
}
};
const columns = [
{
title: '机构',
dataIndex: 'name',
width: 220,
render: (name: string, record: OrganizationItem) => (
<Space>
<span
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: record.color || '#8c8c8c',
}}
/>
<strong>{name}</strong>
{record.isHost ? (
<Tag color="blue" icon={<BankOutlined />}>
</Tag>
) : (
<Tag></Tag>
)}
</Space>
),
},
{
title: '机构编码',
dataIndex: 'code',
width: 130,
render: (value: string) => <code>{value}</code>,
},
{
title: '联系人',
dataIndex: 'contactName',
width: 120,
render: (value?: string) => value || '-',
},
{ title: '电话', dataIndex: 'phone', width: 140, render: (value?: string) => value || '-' },
{ title: '备注', dataIndex: 'notes', ellipsis: true, render: (value?: string) => value || '-' },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : 'default'}>
{status === 'active' ? '正常' : '已归档'}
</Tag>
),
},
{
title: '操作',
width: 160,
render: (_: unknown, record: OrganizationItem) => (
<Space>
<PermissionButton
permission="organization:edit"
size="small"
onClick={() => openEditor(record)}
>
</PermissionButton>
{!record.isHost && record.status === 'active' ? (
<Popconfirm
title="归档后仍保留历史学生、入住和租赁记录"
onConfirm={async () => {
try {
await api.delete(`/organizations/${record.id}`);
message.success('机构已归档');
await fetchData();
} catch (error: any) {
message.error(error?.message || '归档失败');
}
}}
>
<PermissionButton
permission="organization:delete"
size="small"
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
) : null}
</Space>
),
},
];
return (
<div>
<Alert
type="info"
showIcon
message="统一机构管理"
description="本机构与外部机构使用同一套资料。学生明确归属机构;教室租赁则单独记录出租机构和承租机构。"
style={{ marginBottom: 16 }}
/>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索名称、编码或联系人"
allowClear
style={{ width: 260 }}
onChange={(event) => setSearchText(event.target.value)}
/>
<Select
placeholder="全部状态"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={setFilterStatus}
options={[
{ value: 'active', label: '正常' },
{ value: 'archived', label: '已归档' },
]}
/>
</Space>
<PermissionButton
permission="organization:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => openEditor()}
>
</PermissionButton>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无机构" /> }}
scroll={{ x: 1100 }}
pagination={{ pageSize: 20, showTotal: (total) => `${total} 个机构` }}
/>
<Modal
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
open={modalOpen}
onOk={handleSave}
onCancel={() => setModalOpen(false)}
confirmLoading={saving}
okText="保存"
>
<Form form={form} layout="vertical">
{editing?.isHost ? (
<Alert
type="warning"
showIcon
message="这是系统本机构,不可归档,也不能改为外部机构。"
style={{ marginBottom: 16 }}
/>
) : null}
<Form.Item
name="name"
label="机构名称"
rules={[{ required: true, message: '请输入机构名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="code"
label="机构编码"
tooltip="用于导入和系统识别,建议使用大写英文、数字、下划线或短横线"
rules={[
{ required: true },
{ pattern: /^[A-Z0-9_-]+$/, message: '仅支持大写英文、数字、下划线和短横线' },
]}
>
<Input
disabled={editing?.isHost}
placeholder="如 PARTNER_A"
onChange={(event) => form.setFieldValue('code', event.target.value.toUpperCase())}
/>
</Form.Item>
<Form.Item name="contactName" label="联系人">
<Input />
</Form.Item>
<Form.Item name="phone" label="电话">
<Input />
</Form.Item>
<Form.Item name="color" label="识别颜色">
<Space wrap>
{PRESET_COLORS.map((color) => (
<button
type="button"
key={color}
aria-label={`选择 ${color}`}
onClick={() => form.setFieldValue('color', color)}
style={{
width: 30,
height: 30,
borderRadius: 6,
border: '1px solid #d9d9d9',
background: color,
cursor: 'pointer',
}}
/>
))}
</Space>
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default OrganizationsPage;

View File

@@ -25,7 +25,7 @@ const PermissionsPage: React.FC = () => {
bill: '账单管理',
deposit: '押金管理',
classroom: '教室管理',
tenant: '租赁方',
organization: '机构管理',
rental: '租赁订单',
log: '操作日志',
user: '用户管理',

View File

@@ -125,7 +125,7 @@ const RolesPage: React.FC = () => {
bill: '账单管理',
deposit: '押金管理',
classroom: '教室管理',
tenant: '租赁方',
organization: '机构管理',
rental: '租赁订单',
log: '操作日志',
user: '用户管理',

View File

@@ -11,8 +11,8 @@ function getCardStyle(room: any): React.CSSProperties {
else if (room.currentCount === 0) base = { background: '#f6ffed', borderColor: '#b7eb8f' };
else if (room.currentCount >= room.capacity) base = { background: '#fff2f0', borderColor: '#ffccc7' };
else base = { background: '#e6f4ff', borderColor: '#91caff' };
if (room.tenantColor) {
return { ...base, background: `color-mix(in srgb, ${room.tenantColor} 15%, ${base.background || '#fff'} 85%)` };
if (room.organizationColor) {
return { ...base, background: `color-mix(in srgb, ${room.organizationColor} 15%, ${base.background || '#fff'} 85%)` };
}
return base;
}
@@ -24,18 +24,18 @@ function getStatusLabel(room: any) {
return <Tag color="processing"></Tag>;
}
function getTenantTags(occupants: any[]) {
const tenantList = [
function getOrganizationTags(occupants: any[]) {
const organizationList = [
...new Map(
occupants
.filter((o: any) => o.tenantName)
.map((o: any) => [o.tenantId, { name: o.tenantName, color: o.tenantColor }]),
.filter((o: any) => o.organizationName)
.map((o: any) => [o.organizationId, { name: o.organizationName, color: o.organizationColor }]),
).values(),
] as { name: string; color: string | null }[];
if (tenantList.length === 0) return null;
if (organizationList.length === 0) return null;
return (
<div className="room-card-tag-wrapper" style={{ marginBottom: 6 }}>
{tenantList.map((t) => (
{organizationList.map((t) => (
<Tag
key={t.name}
color={t.color || 'gold'}
@@ -53,7 +53,7 @@ const RoomVisualPage: React.FC = () => {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [selectedBuilding, setSelectedBuilding] = useState<string>('all');
const [selectedTenant, setSelectedTenant] = useState<number | 'all'>('all');
const [selectedOrganization, setSelectedOrganization] = useState<number | 'all'>('all');
const [detailRoom, setDetailRoom] = useState<any>(null);
const [asOf, setAsOf] = useState<Dayjs | null>(null);
@@ -80,7 +80,7 @@ const RoomVisualPage: React.FC = () => {
const rooms = data.rooms.filter((r: any) => {
if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false;
if (selectedTenant !== 'all' && !(r.tenantIds || []).includes(selectedTenant)) return false;
if (selectedOrganization !== 'all' && !(r.organizationIds || []).includes(selectedOrganization)) return false;
return true;
});
@@ -93,7 +93,7 @@ const RoomVisualPage: React.FC = () => {
const availableBedsCount = totalBeds - occupiedBeds;
const fullRooms = rooms.filter((r: any) => r.currentCount >= r.capacity).length;
/* getCardStyle, getStatusLabel, getTenantTags are now standalone functions outside the component */
/* getCardStyle, getStatusLabel, getOrganizationTags are now standalone functions outside the component */
return (
<div>
@@ -128,12 +128,12 @@ const RoomVisualPage: React.FC = () => {
]}
/>
<Select
value={selectedTenant}
onChange={setSelectedTenant}
value={selectedOrganization}
onChange={setSelectedOrganization}
style={{ width: 180 }}
options={[
{ value: 'all', label: '全部租赁方' },
...(data.tenants || []).map((t: any) => ({
{ value: 'all', label: '全部机构' },
...(data.organizations || []).map((t: any) => ({
value: t.id,
label: (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
@@ -215,10 +215,10 @@ const RoomVisualPage: React.FC = () => {
}}
>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 16, fontWeight: 600, color: '#1d1d1f' }}>
{room.tenantColor && (
{room.organizationColor && (
<span style={{
width: 10, height: 10, borderRadius: '50%',
backgroundColor: room.tenantColor, display: 'inline-block',
backgroundColor: room.organizationColor, display: 'inline-block',
flexShrink: 0,
}} />
)}
@@ -257,7 +257,7 @@ const RoomVisualPage: React.FC = () => {
</Tag>
</div>
)}
{getTenantTags(room.occupants)}
{getOrganizationTags(room.occupants)}
{room.occupants.length > 0 && (
<div className="room-card-tag-wrapper" style={{ borderTop: '1px solid rgba(0,0,0,0.06)', paddingTop: 6 }}>
{room.occupants.slice(0, 4).map((o: any) => (
@@ -303,10 +303,10 @@ const RoomVisualPage: React.FC = () => {
{detailRoom.building || '-'} {detailRoom.floor ? `${detailRoom.floor}F` : ''}
</div>
{detailRoom.tenantColor && (
{detailRoom.organizationColor && (
<div style={{ marginBottom: 8 }}>
<Tag color={detailRoom.tenantColor}>
{detailRoom.occupants[0]?.tenantName || '租户'}
<Tag color={detailRoom.organizationColor}>
{detailRoom.occupants[0]?.organizationName || '机构'}
</Tag>
</div>
)}

View File

@@ -67,11 +67,11 @@ const StudentsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [tenants, setTenants] = useState<any[]>([]);
const [organizations, setOrganizations] = useState<any[]>([]);
const [editing, setEditing] = useState<any>(null);
const [searchName, setSearchName] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
const [filterOrganizationId, setFilterOrganizationId] = useState<number | undefined>(undefined);
const [showArchived, setShowArchived] = useState(false);
const [archivedCount, setArchivedCount] = useState(0);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
@@ -136,7 +136,7 @@ const StudentsPage: React.FC = () => {
includeArchived: 'true',
};
if (filterStatus) params.status = filterStatus;
if (filterTenantId) params.tenantId = filterTenantId;
if (filterOrganizationId) params.organizationId = filterOrganizationId;
const res = (await api.get('/students', { params })) as Array<Record<string, unknown>>;
const list = res as Array<Record<string, unknown>>;
const archived = list.filter((r) => r.status === 'archived');
@@ -147,7 +147,7 @@ const StudentsPage: React.FC = () => {
message.error(err?.message || '加载失败,请稍后重试');
}
setLoading(false);
}, [searchName, showArchived, filterStatus, filterTenantId]);
}, [searchName, showArchived, filterStatus, filterOrganizationId]);
useEffect(() => {
fetchData();
@@ -155,9 +155,9 @@ const StudentsPage: React.FC = () => {
useEffect(() => {
api
.get('/tenants', { params: { includeArchived: 'false' } })
.get('/organizations', { params: { includeArchived: 'false' } })
.then((res: unknown) => {
setTenants(res as Array<{ id: number; name: string }>);
setOrganizations(res as Array<{ id: number; name: string }>);
})
.catch(() => {});
}, []);
@@ -325,15 +325,15 @@ const StudentsPage: React.FC = () => {
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 },
{
title: '所属机构',
dataIndex: 'tenant',
dataIndex: 'organization',
width: 100,
render: (tenant: { name?: string } | null) =>
tenant?.name ? (
render: (organization: { name?: string } | null) =>
organization?.name ? (
<Tag
color="purple"
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{tenant.name}
{organization.name}
</Tag>
) : (
'-'
@@ -415,7 +415,7 @@ const StudentsPage: React.FC = () => {
),
},
],
[handleViewSensitive, openDrawer, showArchived, tenants],
[handleViewSensitive, openDrawer, showArchived, organizations],
);
return (
@@ -457,12 +457,12 @@ const StudentsPage: React.FC = () => {
placeholder="所属机构"
allowClear
style={{ width: 140 }}
value={filterTenantId}
value={filterOrganizationId}
onChange={(v) => {
setFilterTenantId(v);
setFilterOrganizationId(v);
}}
>
{tenants.map((t: { id: number; name: string }) => (
{organizations.map((t: { id: number; name: string }) => (
<Select.Option key={t.id} value={t.id}>
{t.name}
</Select.Option>
@@ -502,6 +502,8 @@ const StudentsPage: React.FC = () => {
onClick={() => {
setEditing(null);
form.resetFields();
const host = organizations.find((organization) => organization.isHost);
if (host) form.setFieldValue('organizationId', host.id);
setModalOpen(true);
}}
>
@@ -661,14 +663,21 @@ const StudentsPage: React.FC = () => {
<Form.Item name="emergencyPhone" label="紧急联系人电话">
<Input />
</Form.Item>
<Form.Item name="tenantId" label="所属机构" tooltip="选择租赁方,留空表示本机构">
<Form.Item
name="organizationId"
label="所属机构"
rules={[{ required: true, message: '请选择所属机构' }]}
>
<Select
allowClear
placeholder="选择租赁方"
options={tenants.map((t: { id: number; name: string }) => ({
value: t.id,
label: t.name,
}))}
showSearch
optionFilterProp="label"
placeholder="选择所属机构"
options={organizations.map(
(organization: { id: number; name: string; isHost?: boolean }) => ({
value: organization.id,
label: organization.isHost ? `${organization.name}(本机构)` : organization.name,
}),
)}
/>
</Form.Item>
<Form.Item name="supervisor" label="负责人/班主任">

View File

@@ -1,273 +0,0 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Table, Modal, Form, Input, Select, Space, Tag, Popconfirm, Empty } from 'antd';
import { PlusOutlined, InboxOutlined } from '@ant-design/icons';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
const PRESET_COLORS = [
'#ff7875',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
const TenantsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const filteredData = useMemo(() => {
let result = data;
if (searchText) {
const s = searchText.toLowerCase();
result = result.filter((d: Record<string, unknown>) =>
(typeof d.name === 'string' && d.name.toLowerCase().includes(s)) ||
(typeof d.contact === 'string' && d.contact.toLowerCase().includes(s)));
}
if (filterStatus) result = result.filter((d: Record<string, unknown>) => d.status === filterStatus);
return result;
}, [data, searchText, filterStatus]);
const fetchData = async () => {
setLoading(true);
try {
const res: any = await api.get('/tenants');
setData(res);
} catch (e: unknown) {
const err = e as { message?: string };
message.error(err?.message || '加载失败,请稍后重试');
}
setLoading(false);
};
useEffect(() => {
fetchData();
}, []);
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
if (editing) {
await api.put(`/tenants/${editing.id}`, values);
message.success('更新成功');
} else {
await api.post('/tenants', values);
message.success('创建成功');
}
setModalOpen(false);
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) {
message.error(e?.message || '操作失败');
} finally {
setSaving(false);
}
};
const handleArchive = async (id: number) => {
try {
await api.delete(`/tenants/${id}`);
message.success('已归档');
fetchData();
} catch (e: any) {
message.error(e?.message || '归档失败');
}
};
const columns = useMemo(() => [
{
title: '租赁方名称', width: 120,
dataIndex: 'name',
render: (v: string, r: any) => (
<Space>
<Tag
color={r.color || 'default'}
style={{ borderColor: r.color, color: '#fff', background: r.color }}
>
{v}
</Tag>
</Space>
),
},
{ title: '联系人', dataIndex: 'contact', width: 100, render: (v: string) => v || '-' },
{ title: '电话', dataIndex: 'phone', width: 120, render: (v: string) => v || '-' },
{
title: '颜色', width: 80,
dataIndex: 'color',
render: (v: string) =>
v ? (
<span
style={{
display: 'inline-block',
width: 20,
height: 20,
background: v,
borderRadius: 4,
verticalAlign: 'middle',
}}
/>
) : (
'-'
),
},
{ title: '备注', dataIndex: 'notes', ellipsis: true, render: (v: string) => v || '-' },
{
title: '操作',
width: 150,
render: (_: any, record: any) => (
<Space>
<PermissionButton
permission="tenant:edit"
size="small"
onClick={() => {
setEditing(record);
form.setFieldsValue(record);
setModalOpen(true);
}}
>
</PermissionButton>
<Popconfirm
title="归档后仍可查看历史租赁"
onConfirm={() => handleArchive(record.id)}
okText="归档"
cancelText="取消"
>
<PermissionButton permission="tenant:delete" size="small" icon={<InboxOutlined />}>
</PermissionButton>
</Popconfirm>
</Space>
),
},
], [handleArchive]);
return (
<div>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Input.Search
placeholder="搜索名称或联系人"
allowClear
style={{ width: 200 }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
if (!e.target.value) setSearchText('');
}}
/>
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus}
options={[{value:'active',label:'活跃'},{value:'archived',label:'已归档'}]} />
<PermissionButton
permission="tenant:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
>
</PermissionButton>
</div>
<Table
scroll={{ x: 1000 }}
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无数据" /> }}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
/>
<Modal
title={editing ? '编辑租赁方' : '添加租赁方'}
open={modalOpen}
onOk={handleSave}
onCancel={() => {
setModalOpen(false);
setEditing(null);
}}
okText="保存"
confirmLoading={saving}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="如:犀牛华安 / 艺考 / 博才" />
</Form.Item>
<Form.Item name="contact" label="联系人">
<Input />
</Form.Item>
<Form.Item name="phone" label="电话">
<Input />
</Form.Item>
<Form.Item name="color" label="标签颜色" tooltip="可视化排期时用的颜色,留空则自动分配">
<Space.Compact>
<Input placeholder="#40a9ff" style={{ flex: 1 }} />
<span
style={{
padding: '0 4px',
display: 'flex',
alignItems: 'center',
border: '1px solid #d9d9d9',
backgroundColor: '#fafafa',
gap: 4,
}}
>
{PRESET_COLORS.map((c) => (
<span
key={c}
role="button"
tabIndex={0}
aria-label={`选择颜色 ${c}`}
onClick={() => form.setFieldValue('color', c)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
form.setFieldValue('color', c);
}
}}
style={{
display: 'inline-block',
width: 28,
height: 28,
background: c,
borderRadius: 3,
cursor: 'pointer',
border: '1px solid #d9d9d9',
}}
/>
))}
</span>
</Space.Compact>
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default TenantsPage;

View File

@@ -114,7 +114,7 @@ export const SAMPLE_OCCUPANCY = {
checkInDate: '2026-03-01',
billingStartDate: '2026-03-01',
billingEndDate: '2026-06-30',
rentalType: 'short',
stayType: 'short',
};
// ── Bill / Expense (PRD §9-10) ──────────────────────────────────────
@@ -169,7 +169,7 @@ export const SAMPLE_CLASSROOM = {
status: 'available',
};
// ── Tenant (PRD §12) ────────────────────────────────────────────────
// ── Organization (PRD §12) ────────────────────────────────────────────────
export const SAMPLE_TENANT = {
name: '测试合作机构A',
@@ -214,7 +214,7 @@ export const PERMISSION_NODES = [
'attendance:view', 'attendance:add', 'attendance:update', 'attendance:delete',
'attendance:batch',
'classroom:view', 'classroom:add', 'classroom:update', 'classroom:delete',
'tenant:view', 'tenant:add', 'tenant:update', 'tenant:delete',
'organization:view', 'organization:create', 'organization:edit', 'organization:delete',
'rental:view', 'rental:add', 'rental:update', 'rental:delete',
'archive:view', 'archive:import', 'archive:export',
'report:generate',

View File

@@ -19,7 +19,7 @@ import {
Deposit,
DepositInstallment,
Classroom,
Tenant,
Organization,
ClassroomRental,
Permission,
Role,
@@ -55,7 +55,7 @@ import { OperationLogsModule } from './operation-logs/operation-logs.module';
import { DepositsModule } from './deposits/deposits.module';
import { ClassroomsModule } from './classrooms/classrooms.module';
import { ClassesModule } from './classes/classes.module';
import { TenantsModule } from './tenants/tenants.module';
import { OrganizationsModule } from './organizations/organizations.module';
import { AttendanceModule } from './attendance/attendance.module';
import { SchedulesModule } from './schedules/schedules.module';
import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module';
@@ -65,7 +65,10 @@ import { ArchiveModule } from './archive/archive.module';
import { ExpenseTypesModule } from './expense-types/expense-types.module';
import { DatabaseMigrationsModule } from './database/database-migrations.module';
import { IntegrationConfig, IntegrationConfigDetail } from './integration/entities/integration-config.entity';
import {
IntegrationConfig,
IntegrationConfigDetail,
} from './integration/entities/integration-config.entity';
import { IntegrationConfigModule } from './integration/config/config.module';
@Module({
@@ -98,7 +101,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
Deposit,
DepositInstallment,
Classroom,
Tenant,
Organization,
ClassroomRental,
Class,
ClassStudent,
@@ -121,7 +124,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
StudentDingMapping,
IntegrationConfig,
IntegrationConfigDetail,
];
];
if (dbType === 'mysql') {
return {
type: 'mysql' as const,
@@ -157,7 +160,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
ClassroomsModule,
AttendanceModule,
ClassesModule,
TenantsModule,
OrganizationsModule,
SchedulesModule,
ClassroomRentalsModule,
SyncModule,

View File

@@ -94,7 +94,7 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'long',
stayType: 'long',
room: { roomNumber: '101', monthlyRate: '800' as unknown as number } as Room,
} as Occupancy,
]),
@@ -135,12 +135,12 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-10',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
{
id: 2, studentId: 11, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
@@ -187,18 +187,18 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'long',
stayType: 'long',
room: { roomNumber: '101', monthlyRate: '600' as unknown as number } as Room,
} as Occupancy,
{
id: 2, studentId: 11, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-15',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
{
id: 3, studentId: 12, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-15',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
@@ -247,7 +247,7 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-01-01', billingEndDate: '2026-03-31',
rentalType: 'long',
stayType: 'long',
room: { roomNumber: '101', monthlyRate: '800' as unknown as number } as Room,
} as Occupancy,
]),
@@ -289,7 +289,7 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-15', billingEndDate: '2026-06-30',
rentalType: 'long',
stayType: 'long',
room: { roomNumber: '101', monthlyRate: '600' as unknown as number } as Room,
} as Occupancy,
]),
@@ -343,12 +343,12 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-10',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
{
id: 2, studentId: 11, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]);
}
@@ -356,7 +356,7 @@ describe('BillsService — generateBills', () => {
{
id: 3, studentId: 12, roomId: 2,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]);
});
@@ -409,7 +409,7 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
@@ -452,7 +452,7 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-07-01', billingEndDate: '2026-07-15',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
@@ -476,7 +476,7 @@ describe('BillsService — generateBills', () => {
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
rentalType: 'short', room: undefined,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);

View File

@@ -82,8 +82,8 @@ export class BillsService {
// 分离长租与短租入住记录
const shortTermOccs = occupancies.filter((o) => o.rentalType !== 'long');
const longTermOccs = occupancies.filter((o) => o.rentalType === 'long');
const shortTermOccs = occupancies.filter((o) => o.stayType !== 'long');
const longTermOccs = occupancies.filter((o) => o.stayType === 'long');
// 长租:按月租费独立计费,不参与人天数分摊
for (const occ of longTermOccs) {

View File

@@ -36,13 +36,13 @@ export class ClassroomRentalsController {
@RequirePermission('rental:view')
findAll(
@Query('classroomId') classroomId?: string,
@Query('tenantId') tenantId?: string,
@Query('lesseeOrganizationId') lesseeOrganizationId?: string,
@Query('month') month?: string,
@Query('includeEnded') includeEnded?: string,
) {
return this.service.findAll({
classroomId: classroomId ? +classroomId : undefined,
tenantId: tenantId ? +tenantId : undefined,
lesseeOrganizationId: lesseeOrganizationId ? +lesseeOrganizationId : undefined,
month,
includeEnded: includeEnded === 'true',
});
@@ -79,10 +79,18 @@ export class ClassroomRentalsController {
throw new BadRequestException('月份必须在 1-12 之间');
}
const parsedExcludeId = excludeId === undefined ? undefined : Number(excludeId);
if (parsedExcludeId !== undefined && (!Number.isInteger(parsedExcludeId) || parsedExcludeId <= 0)) {
if (
parsedExcludeId !== undefined &&
(!Number.isInteger(parsedExcludeId) || parsedExcludeId <= 0)
) {
throw new BadRequestException('排除的租赁订单不合法');
}
return this.service.getUnavailableDates(parsedClassroomId, parsedYear, parsedMonth, parsedExcludeId);
return this.service.getUnavailableDates(
parsedClassroomId,
parsedYear,
parsedMonth,
parsedExcludeId,
);
}
@Get(':id')
@@ -103,7 +111,7 @@ export class ClassroomRentalsController {
action: '新增租赁',
targetId: result.id,
targetType: 'classroom-rental',
detail: `教室${dto.classroomId} 租赁方${dto.tenantId} ${dto.startDate}~${dto.endDate}`,
detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`,
ipAddress,
userAgent,
});

View File

@@ -2,14 +2,17 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity';
import { Tenant } from '../entities/tenant.entity';
import { Organization } from '../entities/organization.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { ClassroomRentalsService } from './classroom-rentals.service';
import { ClassroomRentalsController } from './classroom-rentals.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([ClassroomRental, Classroom, Tenant, ClassSchedule]), OperationLogsModule],
imports: [
TypeOrmModule.forFeature([ClassroomRental, Classroom, Organization, ClassSchedule]),
OperationLogsModule,
],
controllers: [ClassroomRentalsController],
providers: [ClassroomRentalsService],
exports: [ClassroomRentalsService],

View File

@@ -5,7 +5,7 @@ import { Not, Repository } from 'typeorm';
import { ClassroomRentalsService } from './classroom-rentals.service';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity';
import { Tenant } from '../entities/tenant.entity';
import { Organization } from '../entities/organization.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
@@ -28,9 +28,12 @@ describe('ClassroomRentalsService — findConflicts', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ClassroomRentalsService,
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } },
{
provide: getRepositoryToken(ClassroomRental),
useValue: { createQueryBuilder: jest.fn() },
},
{ provide: getRepositoryToken(Classroom), useValue: {} },
{ provide: getRepositoryToken(Tenant), useValue: {} },
{ provide: getRepositoryToken(Organization), useValue: {} },
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
],
}).compile();
@@ -41,7 +44,12 @@ describe('ClassroomRentalsService — findConflicts', () => {
});
it('returns rental conflicts when no schedule conflicts exist', async () => {
const rental = { id: 1, startDate: '2026-03-01', endDate: '2026-03-31', tenant: { name: 'A机构' } } as ClassroomRental;
const rental = {
id: 1,
startDate: '2026-03-01',
endDate: '2026-03-31',
organization: { name: 'A机构' },
} as ClassroomRental;
const rentalQb = mockQueryBuilder<ClassroomRental>([rental]);
const scheduleQb = mockQueryBuilder<ClassSchedule>([]);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
@@ -56,18 +64,32 @@ describe('ClassroomRentalsService — findConflicts', () => {
it('throws ConflictException when an active schedule overlaps the same classroom and date range', async () => {
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
const scheduleQb = mockQueryBuilder<ClassSchedule>([
{ id: 5, subject: '数学', weekDay: 1, startDate: '2026-03-01', endDate: '2026-06-30' } as ClassSchedule,
{
id: 5,
subject: '数学',
weekDay: 1,
startDate: '2026-03-01',
endDate: '2026-06-30',
} as ClassSchedule,
]);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
await expect(service.findConflicts(1, '2026-03-15', '2026-04-15')).rejects.toThrow(ConflictException);
await expect(service.findConflicts(1, '2026-03-15', '2026-04-15')).rejects.toThrow(
ConflictException,
);
});
it('does not treat a weekly schedule as a conflict when its weekday does not occur in the rental range', async () => {
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
const scheduleQb = mockQueryBuilder<ClassSchedule>([
{ id: 5, subject: '数学', weekDay: 1, startDate: '2026-07-01', endDate: '2026-07-31' } as ClassSchedule,
{
id: 5,
subject: '数学',
weekDay: 1,
startDate: '2026-07-01',
endDate: '2026-07-31',
} as ClassSchedule,
]);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
@@ -111,7 +133,7 @@ describe('ClassroomRentalsService — unavailable dates', () => {
ClassroomRentalsService,
{ provide: getRepositoryToken(ClassroomRental), useValue: { find: jest.fn() } },
{ provide: getRepositoryToken(Classroom), useValue: {} },
{ provide: getRepositoryToken(Tenant), useValue: {} },
{ provide: getRepositoryToken(Organization), useValue: {} },
{ provide: getRepositoryToken(ClassSchedule), useValue: { find: jest.fn() } },
],
}).compile();
@@ -151,12 +173,18 @@ describe('ClassroomRentalsService — unavailable dates', () => {
describe('ClassroomRentalsService — rental schedule sync', () => {
let service: ClassroomRentalsService;
let rentalRepo: jest.Mocked<
Pick<Repository<ClassroomRental>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
Pick<
Repository<ClassroomRental>,
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
>
>;
let classroomRepo: jest.Mocked<Pick<Repository<Classroom>, 'findOne'>>;
let tenantRepo: jest.Mocked<Pick<Repository<Tenant>, 'findOne'>>;
let organizationRepo: jest.Mocked<Pick<Repository<Organization>, 'findOne'>>;
let scheduleRepo: jest.Mocked<
Pick<Repository<ClassSchedule>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
Pick<
Repository<ClassSchedule>,
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
>
>;
beforeEach(async () => {
@@ -168,12 +196,17 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
delete: jest.fn(),
createQueryBuilder: jest.fn(),
} as jest.Mocked<
Pick<Repository<ClassroomRental>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
Pick<
Repository<ClassroomRental>,
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
>
>;
classroomRepo = { findOne: jest.fn() } as jest.Mocked<Pick<Repository<Classroom>, 'findOne'>>;
tenantRepo = { findOne: jest.fn() } as jest.Mocked<Pick<Repository<Tenant>, 'findOne'>>;
organizationRepo = { findOne: jest.fn() } as jest.Mocked<
Pick<Repository<Organization>, 'findOne'>
>;
scheduleRepo = {
findOne: jest.fn(),
@@ -183,7 +216,10 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
delete: jest.fn(),
createQueryBuilder: jest.fn(),
} as jest.Mocked<
Pick<Repository<ClassSchedule>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
Pick<
Repository<ClassSchedule>,
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
>
>;
const module: TestingModule = await Test.createTestingModule({
@@ -191,7 +227,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
ClassroomRentalsService,
{ provide: getRepositoryToken(ClassroomRental), useValue: rentalRepo },
{ provide: getRepositoryToken(Classroom), useValue: classroomRepo },
{ provide: getRepositoryToken(Tenant), useValue: tenantRepo },
{ provide: getRepositoryToken(Organization), useValue: organizationRepo },
{ provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepo },
],
}).compile();
@@ -203,22 +239,43 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
it('saves the rental and creates a RENTAL class_schedule row', async () => {
const dto: CreateRentalDto = {
classroomId: 1,
tenantId: 2,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
};
const classroom = { id: 1, departmentId: 10 } as Classroom;
const tenant = { id: 2, name: 'Tenant A' } as Tenant;
const hostOrganization = {
id: 1,
name: 'Host',
isHost: true,
status: 'active',
} as Organization;
const organization = {
id: 2,
name: 'Organization A',
isHost: false,
status: 'active',
} as Organization;
classroomRepo.findOne.mockResolvedValue(classroom);
tenantRepo.findOne.mockResolvedValue(tenant);
rentalRepo.create.mockImplementation((entity) => ({ ...(entity as object) } as ClassroomRental));
rentalRepo.save.mockImplementation((entity) => Promise.resolve({ ...(entity as object), id: 1 } as ClassroomRental));
organizationRepo.findOne
.mockResolvedValueOnce(hostOrganization)
.mockResolvedValueOnce(organization);
rentalRepo.create.mockImplementation(
(entity) => ({ ...(entity as object) }) as ClassroomRental,
);
rentalRepo.save.mockImplementation((entity) =>
Promise.resolve({ ...(entity as object), id: 1 } as ClassroomRental),
);
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
scheduleRepo.findOne.mockResolvedValue(null);
scheduleRepo.create.mockImplementation((entity) => ({ ...(entity as object) } as ClassSchedule));
scheduleRepo.save.mockImplementation((entity) => Promise.resolve({ ...(entity as object), id: 100 } as ClassSchedule));
scheduleRepo.create.mockImplementation(
(entity) => ({ ...(entity as object) }) as ClassSchedule,
);
scheduleRepo.save.mockImplementation((entity) =>
Promise.resolve({ ...(entity as object), id: 100 } as ClassSchedule),
);
const result = await service.create(dto);
@@ -226,11 +283,11 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
expect(rentalRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
classroomId: 1,
tenantId: 2,
lessorOrganizationId: 1,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
status: 'active',
departmentId: 10,
}),
);
expect(scheduleRepo.create).toHaveBeenCalledWith(
@@ -241,12 +298,11 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
endTime: '23:59',
startDate: '2026-03-01',
endDate: '2026-03-31',
subject: 'Tenant A 租赁',
subject: 'Organization A 租赁',
teacherId: null,
scheduleType: 'RENTAL',
rentalId: 1,
status: 'active',
departmentId: 10,
}),
);
expect(scheduleRepo.save).toHaveBeenCalled();
@@ -258,13 +314,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
const existingRental = {
id: 1,
classroomId: 1,
tenantId: 2,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
status: 'active',
notes: '',
departmentId: 10,
tenant: { id: 2, name: 'Tenant A' } as Tenant,
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
classroom: { id: 1 } as Classroom,
} as ClassroomRental;
const updatedRental = {
@@ -272,7 +327,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
startDate: '2026-04-01',
endDate: '2026-04-30',
} as ClassroomRental;
const existingSchedule = { id: 50, rentalId: 1, scheduleType: 'RENTAL', classroomId: 1 } as ClassSchedule;
const existingSchedule = {
id: 50,
rentalId: 1,
scheduleType: 'RENTAL',
classroomId: 1,
} as ClassSchedule;
rentalRepo.findOne.mockResolvedValueOnce(existingRental).mockResolvedValueOnce(updatedRental);
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
@@ -295,7 +355,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
startDate: '2026-04-01',
endDate: '2026-04-30',
status: 'active',
subject: 'Tenant A 租赁',
subject: 'Organization A 租赁',
}),
);
expect(scheduleRepo.create).not.toHaveBeenCalled();
@@ -306,12 +366,11 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
const rental = {
id: 1,
classroomId: 1,
tenantId: 2,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
status: 'active',
departmentId: 10,
tenant: { id: 2, name: 'Tenant A' } as Tenant,
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
} as ClassroomRental;
const cancelledRental = { ...rental, status: 'cancelled' } as ClassroomRental;
@@ -332,12 +391,11 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
const rental = {
id: 1,
classroomId: 1,
tenantId: 2,
lesseeOrganizationId: 2,
startDate: '2026-03-01',
endDate: '2026-03-31',
status: 'active',
departmentId: 10,
tenant: { id: 2, name: 'Tenant A' } as Tenant,
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
} as ClassroomRental;
rentalRepo.findOne.mockResolvedValue(rental);
@@ -349,3 +407,55 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
});
});
});
describe('ClassroomRentalsService — organization roles', () => {
it('stores explicit lessor and lessee organizations for a rental', async () => {
const rentalRepo = {
findOne: jest.fn(),
save: jest.fn(async (value) => ({ ...value, id: 1 })),
create: jest.fn((value) => value),
update: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassroomRental>([])),
} as any;
const classroomRepo = {
findOne: jest.fn().mockResolvedValue({ id: 1, departmentId: 10 }),
} as any;
const organizationRepo = {
findOne: jest
.fn()
.mockResolvedValueOnce({ id: 1, name: '本机构', isHost: true, status: 'active' })
.mockResolvedValueOnce({ id: 2, name: '合作机构', isHost: false, status: 'active' }),
} as any;
const scheduleRepo = {
findOne: jest.fn().mockResolvedValue(null),
save: jest.fn(async (value) => ({ ...value, id: 100 })),
create: jest.fn((value) => value),
update: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassSchedule>([])),
} as any;
const service = new ClassroomRentalsService(
rentalRepo,
classroomRepo,
organizationRepo,
scheduleRepo,
);
await service.create({
classroomId: 1,
lessorOrganizationId: 1,
lesseeOrganizationId: 2,
startDate: '2026-08-01',
endDate: '2026-08-31',
} as any);
expect(rentalRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
lessorOrganizationId: 1,
lesseeOrganizationId: 2,
}),
);
});
});

View File

@@ -8,14 +8,14 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity';
import { Tenant } from '../entities/tenant.entity';
import { Organization } from '../entities/organization.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
import * as path from 'path';
import * as fs from 'fs';
// 预设色板(与 tenants.service 保持一致,作为颜色兜底)
// 预设色板(与 organizations.service 保持一致,作为颜色兜底)
const COLOR_PALETTE = [
'#ff7875',
'#ffa940',
@@ -31,11 +31,10 @@ const COLOR_PALETTE = [
@Injectable()
export class ClassroomRentalsService {
constructor(
@InjectRepository(ClassroomRental) private repo: Repository<ClassroomRental>,
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
) {}
@@ -52,17 +51,18 @@ export class ClassroomRentalsService {
async findAll(query?: {
classroomId?: number;
tenantId?: number;
lesseeOrganizationId?: number;
month?: string;
includeEnded?: boolean;
}) {
const qb = this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.classroom', 'classroom')
.leftJoinAndSelect('r.tenant', 'tenant')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.orderBy('r.startDate', 'DESC');
if (query?.classroomId) qb.andWhere('r.classroomId = :cid', { cid: query.classroomId });
if (query?.tenantId) qb.andWhere('r.tenantId = :tid', { tid: query.tenantId });
if (query?.lesseeOrganizationId)
qb.andWhere('r.lesseeOrganizationId = :oid', { oid: query.lesseeOrganizationId });
if (query?.month) {
const [y, m] = query.month.split('-').map(Number);
const first = `${y}-${String(m).padStart(2, '0')}-01`;
@@ -75,7 +75,10 @@ export class ClassroomRentalsService {
}
async findOne(id: number) {
const rental = await this.repo.findOne({ where: { id }, relations: ['classroom', 'tenant'] });
const rental = await this.repo.findOne({
where: { id },
relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'],
});
if (!rental) throw new NotFoundException('租赁订单不存在');
return rental;
}
@@ -128,7 +131,7 @@ export class ClassroomRentalsService {
async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) {
const qb = this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.tenant', 'tenant')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.where('r.classroomId = :cid', { cid: classroomId })
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
.andWhere('r.startDate <= :end', { end: endDate })
@@ -156,7 +159,7 @@ export class ClassroomRentalsService {
id: s.id,
startDate: s.startDate,
endDate: s.endDate,
tenantName: `[内部排课] ${s.subject}`,
organizationName: `[内部排课] ${s.subject}`,
})),
});
}
@@ -164,7 +167,11 @@ export class ClassroomRentalsService {
return rentals;
}
private hasScheduleOccurrence(schedule: ClassSchedule, startDate: string, endDate: string): boolean {
private hasScheduleOccurrence(
schedule: ClassSchedule,
startDate: string,
endDate: string,
): boolean {
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
if (overlapStart > overlapEnd) return false;
@@ -191,7 +198,12 @@ export class ClassroomRentalsService {
}
}
private addScheduleOccurrences(dates: Set<string>, schedule: ClassSchedule, startDate: string, endDate: string) {
private addScheduleOccurrences(
dates: Set<string>,
schedule: ClassSchedule,
startDate: string,
endDate: string,
) {
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
if (overlapStart > overlapEnd) return;
@@ -210,8 +222,19 @@ export class ClassroomRentalsService {
if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期');
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
if (!classroom) throw new NotFoundException('教室不存在');
const tenant = await this.tenantRepo.findOne({ where: { id: dto.tenantId } });
if (!tenant) throw new NotFoundException('租赁方不存在');
const lessorOrganization = dto.lessorOrganizationId
? await this.organizationRepo.findOne({
where: { id: dto.lessorOrganizationId, status: 'active' },
})
: await this.organizationRepo.findOne({ where: { isHost: true, status: 'active' } });
if (!lessorOrganization) throw new NotFoundException('出租机构不存在或未启用');
const lesseeOrganization = await this.organizationRepo.findOne({
where: { id: dto.lesseeOrganizationId, status: 'active' },
});
if (!lesseeOrganization) throw new NotFoundException('承租机构不存在或未启用');
if (lessorOrganization.id === lesseeOrganization.id) {
throw new BadRequestException('出租机构和承租机构不能相同');
}
const conflicts = await this.findConflicts(dto.classroomId, dto.startDate, dto.endDate);
if (conflicts.length > 0) {
@@ -221,12 +244,19 @@ export class ClassroomRentalsService {
id: c.id,
startDate: c.startDate,
endDate: c.endDate,
tenantName: c.tenant?.name,
organizationName: c.lesseeOrganization?.name,
})),
});
}
const rental = this.repo.create({ ...dto, createdBy: userId, status: 'active' });
const rental = this.repo.create({
...dto,
lessorOrganizationId: lessorOrganization.id,
lesseeOrganizationId: lesseeOrganization.id,
createdBy: userId,
status: 'active',
});
const saved = await this.repo.save(rental);
await this.syncScheduleFromRental(saved, lesseeOrganization.name);
return saved;
}
@@ -246,11 +276,28 @@ export class ClassroomRentalsService {
id: c.id,
startDate: c.startDate,
endDate: c.endDate,
tenantName: c.tenant?.name,
organizationName: c.lesseeOrganization?.name,
})),
});
}
}
const newLessorId = dto.lessorOrganizationId ?? rental.lessorOrganizationId;
const newLesseeId = dto.lesseeOrganizationId ?? rental.lesseeOrganizationId;
if (newLessorId === newLesseeId) {
throw new BadRequestException('出租机构和承租机构不能相同');
}
if (dto.lessorOrganizationId) {
const lessor = await this.organizationRepo.findOne({
where: { id: dto.lessorOrganizationId, status: 'active' },
});
if (!lessor) throw new NotFoundException('出租机构不存在或未启用');
}
if (dto.lesseeOrganizationId) {
const lessee = await this.organizationRepo.findOne({
where: { id: dto.lesseeOrganizationId, status: 'active' },
});
if (!lessee) throw new NotFoundException('承租机构不存在或未启用');
}
await this.repo.update(id, dto);
const updated = await this.findOne(id);
if (dto.status === 'cancelled') {
@@ -283,8 +330,8 @@ export class ClassroomRentalsService {
/**
* 同步租赁订单到 class_schedulesschedule_type = 'RENTAL'
*/
private async syncScheduleFromRental(rental: ClassroomRental, tenantName?: string) {
const name = tenantName || rental.tenant?.name || '租赁方';
private async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) {
const name = organizationName || rental.lesseeOrganization?.name || '承租机构';
const weekDay = this.dateToWeekDay(rental.startDate);
let schedule = await this.scheduleRepo.findOne({
where: { rentalId: rental.id, scheduleType: 'RENTAL' },
@@ -395,13 +442,13 @@ export class ClassroomRentalsService {
});
const rentals = await this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.tenant', 'tenant')
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
.leftJoinAndSelect('r.classroom', 'classroom')
.where('r.status != :cancelled', { cancelled: 'cancelled' })
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
.getMany();
const tenantMap = new Map<number, any>();
const organizationMap = new Map<number, any>();
const matrix: Record<number, Record<number, any>> = {};
const summary: Record<
number,
@@ -420,11 +467,13 @@ export class ClassroomRentalsService {
const monthEnd = new Date(last);
const effStart = start < monthStart ? monthStart : start;
const effEnd = end > monthEnd ? monthEnd : end;
if (rental.tenant && !tenantMap.has(rental.tenant.id)) {
tenantMap.set(rental.tenant.id, {
id: rental.tenant.id,
name: rental.tenant.name,
color: rental.tenant.color || COLOR_PALETTE[rental.tenant.id % COLOR_PALETTE.length],
if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) {
organizationMap.set(rental.lesseeOrganization.id, {
id: rental.lesseeOrganization.id,
name: rental.lesseeOrganization.name,
color:
rental.lesseeOrganization.color ||
COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length],
});
}
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
@@ -433,10 +482,11 @@ export class ClassroomRentalsService {
matrix[rental.classroomId][day] = {
scheduleType: 'RENTAL',
rentalId: rental.id,
tenantId: rental.tenantId,
tenantName: rental.tenant?.name || '未知',
organizationId: rental.lesseeOrganizationId,
organizationName: rental.lesseeOrganization?.name || '未知',
color:
rental.tenant?.color || COLOR_PALETTE[(rental.tenantId || 0) % COLOR_PALETTE.length],
rental.lesseeOrganization?.color ||
COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length],
hasContract: !!rental.contractPath,
};
}
@@ -454,8 +504,12 @@ export class ClassroomRentalsService {
for (const sched of schedules) {
if (!sched.classroomId) continue;
const schedStart = new Date(Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()));
const schedEnd = new Date(Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()));
const schedStart = new Date(
Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()),
);
const schedEnd = new Date(
Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()),
);
for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) {
const dow = d.getDay() === 0 ? 7 : d.getDay();
if (dow !== sched.weekDay) continue;
@@ -494,7 +548,7 @@ export class ClassroomRentalsService {
capacity: c.capacity,
supervisor: c.supervisor,
})),
tenants: Array.from(tenantMap.values()),
organizations: Array.from(organizationMap.values()),
matrix,
summary,
};

View File

@@ -1,19 +1,15 @@
import {
IsOptional,
IsString,
IsNotEmpty,
IsInt,
IsNumber,
IsEnum,
IsDateString,
} from 'class-validator';
import { IsOptional, IsString, IsInt, IsNumber, IsEnum, IsDateString } from 'class-validator';
export class CreateRentalDto {
@IsInt()
classroomId: number;
@IsOptional()
@IsInt()
tenantId: number;
lessorOrganizationId?: number;
@IsInt()
lesseeOrganizationId: number;
@IsDateString()
startDate: string;
@@ -41,7 +37,11 @@ export class UpdateRentalDto {
@IsOptional()
@IsInt()
tenantId?: number;
lessorOrganizationId?: number;
@IsOptional()
@IsInt()
lesseeOrganizationId?: number;
@IsOptional()
@IsDateString()

View File

@@ -97,7 +97,7 @@ export class ClassroomsService {
const rentals = await this.rentalRepo
.createQueryBuilder('r')
.leftJoin('Tenant', 't', 't.id = r.tenantId')
.leftJoin('Organization', 't', 't.id = r.lesseeOrganizationId')
.select('r.classroomId', 'classroomId')
.addSelect('r.startDate', 'startDate')
.addSelect('r.endDate', 'endDate')

View File

@@ -0,0 +1,11 @@
import { uuidV7 } from './uuid-v7';
describe('uuidV7', () => {
it('creates an RFC 9562 version 7 UUID with time-sortable prefixes', () => {
const first = uuidV7(1_700_000_000_000);
const second = uuidV7(1_700_000_000_001);
expect(first).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(first < second).toBe(true);
});
});

View File

@@ -0,0 +1,21 @@
import { randomBytes } from 'node:crypto';
/** Generates an RFC 9562 UUIDv7 using the current Unix timestamp and cryptographic randomness. */
export function uuidV7(now = Date.now()): string {
const bytes = Buffer.alloc(16);
const random = randomBytes(10);
let timestamp = BigInt(now);
for (let index = 5; index >= 0; index -= 1) {
bytes[index] = Number(timestamp & 0xffn);
timestamp >>= 8n;
}
bytes[6] = 0x70 | (random[0] & 0x0f);
bytes[7] = random[1];
bytes[8] = 0x80 | (random[2] & 0x3f);
random.copy(bytes, 9, 3, 10);
const hex = bytes.toString('hex');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
@Injectable()
export class DatabaseMigrationsService implements OnApplicationBootstrap {
@@ -8,9 +9,129 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
constructor(private readonly dataSource: DataSource) {}
async onApplicationBootstrap(): Promise<void> {
await this.backfillOrganizations();
await this.normalizeClassDates();
}
private async backfillOrganizations(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const tables = await runner.getTables([
'tenants',
'organizations',
'students',
'occupancies',
'classroom_rentals',
]);
const tableNames = new Set(tables.map((table) => table.name));
if (!tableNames.has('organizations')) return;
const organizationRows = () =>
runner.query('SELECT * FROM organizations WHERE is_host = 1 LIMIT 1');
let host = (await organizationRows())[0];
if (!host) {
await runner.query(
`INSERT INTO organizations (public_id, code, name, is_host, color, notes, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
[
uuidV7(),
'HOST',
process.env.HOST_ORGANIZATION_NAME || '本机构',
1,
'#1677ff',
'系统默认运营主体',
'active',
],
);
host = (await organizationRows())[0];
}
if (!host) return;
if (tableNames.has('tenants')) {
const legacyTenants: Array<Record<string, unknown>> =
await runner.query('SELECT * FROM tenants');
for (const legacy of legacyTenants) {
const name = String(legacy.name || '').trim();
if (!name) continue;
let external = (
await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name])
)[0];
if (!external) {
await runner.query(
`INSERT INTO organizations (public_id, code, name, is_host, contact_name, phone, color, notes, status, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)`,
[
uuidV7(),
`ORG_${legacy.id}`,
name,
0,
legacy.contact || null,
legacy.phone || null,
legacy.color || null,
legacy.notes || null,
legacy.status || 'active',
],
);
external = (
await runner.query('SELECT * FROM organizations WHERE name = ? LIMIT 1', [name])
)[0];
}
if (!external) continue;
if (tableNames.has('students')) {
await runner
.query(
'UPDATE students SET organization_id = ? WHERE organization_id IS NULL AND tenant_id = ?',
[external.id, legacy.id],
)
.catch(() => undefined);
}
if (tableNames.has('occupancies')) {
await runner
.query(
'UPDATE occupancies SET responsible_organization_id = ? WHERE responsible_organization_id IS NULL AND tenant_id = ?',
[external.id, legacy.id],
)
.catch(() => undefined);
}
if (tableNames.has('classroom_rentals')) {
await runner
.query(
'UPDATE classroom_rentals SET lessee_organization_id = ?, lessor_organization_id = ? WHERE lessee_organization_id IS NULL AND tenant_id = ?',
[external.id, host.id, legacy.id],
)
.catch(() => undefined);
}
}
}
if (tableNames.has('students')) {
await runner.query(
'UPDATE students SET organization_id = ? WHERE organization_id IS NULL',
[host.id],
);
}
if (tableNames.has('occupancies')) {
await runner.query(
`UPDATE occupancies
SET responsible_organization_id = COALESCE(
(SELECT organization_id FROM students WHERE students.id = occupancies.student_id), ?
)
WHERE responsible_organization_id IS NULL`,
[host.id],
);
}
if (tableNames.has('classroom_rentals')) {
await runner.query(
'UPDATE classroom_rentals SET lessor_organization_id = ? WHERE lessor_organization_id IS NULL',
[host.id],
);
}
} finally {
await runner.release();
}
}
private async normalizeClassDates(): Promise<void> {
const driver = this.dataSource.options.type;
const dateExpression = (column: string) =>

View File

@@ -9,7 +9,7 @@ import {
Index,
} from 'typeorm';
import { Classroom } from './classroom.entity';
import { Tenant } from './tenant.entity';
import { Organization } from './organization.entity';
@Entity('classroom_rentals')
@Index(['classroomId', 'startDate', 'endDate'])
@@ -24,12 +24,19 @@ export class ClassroomRental {
@JoinColumn({ name: 'classroom_id' })
classroom: Classroom;
@Column({ name: 'tenant_id' })
tenantId: number;
@Column({ name: 'lessor_organization_id', nullable: true })
lessorOrganizationId: number;
@ManyToOne(() => Tenant)
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'lessor_organization_id' })
lessorOrganization: Organization;
@Column({ name: 'lessee_organization_id', nullable: true })
lesseeOrganizationId: number;
@ManyToOne(() => Organization)
@JoinColumn({ name: 'lessee_organization_id' })
lesseeOrganization: Organization;
@Column({ name: 'start_date', type: 'date' })
startDate: string;
@@ -64,5 +71,4 @@ export class ClassroomRental {
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -12,7 +12,7 @@ export { OperationLog } from './operation-log.entity';
export { Deposit } from './deposit.entity';
export { DepositInstallment } from './deposit-installment.entity';
export { Classroom } from './classroom.entity';
export { Tenant } from './tenant.entity';
export { Organization } from './organization.entity';
export { ClassroomRental } from './classroom-rental.entity';
export { Permission } from './permission.entity';
export { Role } from './role.entity';

View File

@@ -8,7 +8,7 @@ import {
} from 'typeorm';
import { Student } from './student.entity';
import { Room } from './room.entity';
import { Tenant } from './tenant.entity';
import { Organization } from './organization.entity';
import { Bed } from './bed.entity';
import { Locker } from './locker.entity';
@@ -55,15 +55,15 @@ export class Occupancy {
@JoinColumn({ name: 'locker_id' })
locker: Locker;
@Column({ name: 'rental_type', length: 10, default: 'short' })
rentalType: string;
@Column({ name: 'stay_type', length: 10, default: 'short' })
stayType: string;
@Column({ name: 'tenant_id', type: 'integer', nullable: true })
tenantId: number;
@Column({ name: 'responsible_organization_id', type: 'integer', nullable: true })
responsibleOrganizationId: number;
@ManyToOne(() => Tenant, { nullable: true })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => Organization, { nullable: true })
@JoinColumn({ name: 'responsible_organization_id' })
responsibleOrganization: Organization;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@@ -75,5 +75,4 @@ export class Occupancy {
@ManyToOne(() => Room, (r) => r.occupancies)
@JoinColumn({ name: 'room_id' })
room: Room;
}

View File

@@ -6,21 +6,29 @@ import {
UpdateDateColumn,
} from 'typeorm';
@Entity('tenants')
export class Tenant {
@Entity('organizations')
export class Organization {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'public_id', type: 'varchar', length: 36, unique: true })
publicId: string;
@Column({ length: 50, unique: true })
code: string;
@Column({ length: 100 })
name: string;
@Column({ length: 50, nullable: true })
contact: string;
@Column({ name: 'is_host', default: false })
isHost: boolean;
@Column({ name: 'contact_name', length: 50, nullable: true })
contactName: string;
@Column({ length: 30, nullable: true })
phone: string;
// 可视化颜色hex为空时由后端自动分配
@Column({ length: 20, nullable: true })
color: string;
@@ -28,7 +36,7 @@ export class Tenant {
notes: string;
@Column({ type: 'varchar', length: 20, default: 'active' })
status: string; // active / archived
status: 'active' | 'archived';
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -12,7 +12,7 @@ import {
import { Occupancy } from './occupancy.entity';
import { PersonalExpense } from './personal-expense.entity';
import { Bill } from './bill.entity';
import { Tenant } from './tenant.entity';
import { Organization } from './organization.entity';
import { User } from './user.entity';
@Entity('students')
@@ -46,9 +46,6 @@ export class Student {
@Column({ type: 'varchar', length: 20, default: 'active' })
status: string;
@Column({ length: 100, nullable: true })
organization: string;
@Column({ length: 50, nullable: true })
supervisor: string;
@@ -68,17 +65,16 @@ export class Student {
@JoinColumn({ name: 'user_id' })
user: User;
@Column({ name: 'tenant_id', type: 'integer', nullable: true })
tenantId: number;
@Column({ name: 'organization_id', type: 'integer', nullable: true })
organizationId: number;
@ManyToOne(() => Tenant, { nullable: true })
@JoinColumn({ name: 'tenant_id' })
tenant: Tenant;
@ManyToOne(() => Organization, { nullable: true })
@JoinColumn({ name: 'organization_id' })
organization: Organization;
@OneToMany(() => PersonalExpense, (e) => e.student)
personalExpenses: PersonalExpense[];
@OneToMany(() => Bill, (b) => b.student)
bills: Bill[];
}

View File

@@ -20,14 +20,14 @@ export class CheckInDto {
@IsOptional()
@IsString()
rentalType?: string;
stayType?: string;
@IsOptional()
@IsInt()
tenantId?: number;
responsibleOrganizationId?: number;
@IsOptional()
@IsInt()
bedId?: number; // 后续改 required
bedId?: number; // 后续改 required
@IsOptional()
@IsInt()

View File

@@ -96,7 +96,9 @@ export class OccupanciesController {
content: `您已入住房间 #${dto.roomId}`,
});
}
} catch (_) { /* don't block response */ }
} catch (_) {
/* don't block response */
}
return result;
}
@@ -126,7 +128,9 @@ export class OccupanciesController {
content: `您已退宿房间 #${result.roomId}`,
});
}
} catch (_) { /* don't block response */ }
} catch (_) {
/* don't block response */
}
return result;
}
@@ -215,7 +219,7 @@ export class OccupanciesController {
gender: r.student?.gender || '',
phone: r.student?.phone || '',
idNumber: r.student?.idNumber || '',
organization: r.student?.organization || '',
organization: r.student?.organization?.name || '',
supervisor: r.student?.supervisor || '',
checkInDate: r.checkInDate || '',
checkOutDate: r.checkOutDate || '',

View File

@@ -6,13 +6,18 @@ import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
import { Organization } from '../entities/organization.entity';
import { OccupanciesService } from './occupancies.service';
import { OccupanciesController } from './occupancies.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({
imports: [TypeOrmModule.forFeature([Occupancy, Room, Student, Deposit, Bed, Locker]), OperationLogsModule, NotificationsModule],
imports: [
TypeOrmModule.forFeature([Occupancy, Room, Student, Deposit, Bed, Locker, Organization]),
OperationLogsModule,
NotificationsModule,
],
controllers: [OccupanciesController],
providers: [OccupanciesService],
exports: [OccupanciesService],

View File

@@ -0,0 +1,47 @@
import { Repository, DataSource } from 'typeorm';
import { OccupanciesService } from './occupancies.service';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
describe('OccupanciesService — responsible organization', () => {
it('defaults the responsible organization to the student organization', async () => {
const occupancyRepo = {
findOne: jest.fn().mockResolvedValue(null),
count: jest.fn().mockResolvedValue(0),
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ ...value, id: 10 })),
} as any as Repository<Occupancy>;
const roomRepo = {
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4, gender: null }),
update: jest.fn(),
} as any as Repository<Room>;
const studentRepo = {
findOne: jest.fn().mockResolvedValue({ id: 3, gender: '男', organizationId: 7 }),
} as any as Repository<Student>;
const service = new OccupanciesService(
occupancyRepo,
roomRepo,
studentRepo,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
{} as DataSource,
);
await service.checkIn({
studentId: 3,
roomId: 2,
checkInDate: '2026-07-10',
});
expect(occupancyRepo.create).toHaveBeenCalledWith(
expect.objectContaining({ responsibleOrganizationId: 7 }),
);
});
});

View File

@@ -15,10 +15,11 @@ import { Student } from '../entities/student.entity';
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
import { Deposit } from '../entities/deposit.entity';
import { Organization } from '../entities/organization.entity';
import { uuidV7 } from '../common/uuid-v7';
import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
import { RoomsService } from '../rooms/rooms.service';
@Injectable()
export class OccupanciesService {
constructor(
@@ -28,6 +29,7 @@ export class OccupanciesService {
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
private dataSource: DataSource,
) {}
@@ -38,6 +40,7 @@ export class OccupanciesService {
.leftJoinAndSelect('o.room', 'room')
.leftJoinAndSelect('o.bed', 'bed')
.leftJoinAndSelect('o.locker', 'locker')
.leftJoinAndSelect('o.responsibleOrganization', 'responsibleOrganization')
.orderBy('o.checkInDate', 'DESC');
if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId });
if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId });
@@ -76,7 +79,9 @@ export class OccupanciesService {
// 柜子校验
if (dto.lockerId) {
const locker = await this.lockerRepo.findOne({ where: { id: dto.lockerId, roomId: dto.roomId } });
const locker = await this.lockerRepo.findOne({
where: { id: dto.lockerId, roomId: dto.roomId },
});
if (!locker) throw new BadRequestException('柜子不存在或不属于该宿舍');
if (locker.status !== 'available') throw new BadRequestException('该柜子已被占用或维修中');
}
@@ -86,8 +91,8 @@ export class OccupanciesService {
roomId: dto.roomId,
checkInDate: dto.checkInDate,
billingStartDate: dto.billingStartDate || dto.checkInDate,
rentalType: dto.rentalType,
tenantId: dto.tenantId,
stayType: dto.stayType,
responsibleOrganizationId: dto.responsibleOrganizationId ?? student.organizationId,
notes: dto.notes,
bedId: dto.bedId,
lockerId: dto.lockerId,
@@ -192,12 +197,16 @@ export class OccupanciesService {
// 新床位校验
if (dto.newBedId) {
const newBed = await runner.manager.findOne(Bed, { where: { id: dto.newBedId, roomId: dto.newRoomId } });
const newBed = await runner.manager.findOne(Bed, {
where: { id: dto.newBedId, roomId: dto.newRoomId },
});
if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍');
if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用');
}
if (dto.newLockerId) {
const newLocker = await runner.manager.findOne(Locker, { where: { id: dto.newLockerId, roomId: dto.newRoomId } });
const newLocker = await runner.manager.findOne(Locker, {
where: { id: dto.newLockerId, roomId: dto.newRoomId },
});
if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍');
if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用');
}
@@ -214,8 +223,8 @@ export class OccupanciesService {
roomId: dto.newRoomId,
checkInDate: dto.transferDate,
billingStartDate: dto.newBillingStartDate || defaultBillingStart,
rentalType: oldOcc.rentalType,
tenantId: oldOcc.tenantId,
stayType: oldOcc.stayType,
responsibleOrganizationId: oldOcc.responsibleOrganizationId,
notes: `${oldOcc.roomId}号房换入`,
bedId: dto.newBedId,
lockerId: dto.newLockerId,
@@ -339,7 +348,8 @@ export class OccupanciesService {
}
// 释放床位/柜子
if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' });
if (occ.lockerId) await runner.manager.update(Locker, occ.lockerId, { status: 'available' });
if (occ.lockerId)
await runner.manager.update(Locker, occ.lockerId, { status: 'available' });
success++;
}
await runner.commitTransaction();
@@ -395,7 +405,23 @@ export class OccupanciesService {
}
try {
// 1. 查找或创建学生
// 1. 解析所属机构;未填写时默认本机构
let organization = row.organization?.trim()
? await this.organizationRepo.findOne({ where: { name: row.organization.trim() } })
: await this.organizationRepo.findOne({ where: { isHost: true, status: 'active' } });
if (!organization && row.organization?.trim()) {
organization = await this.organizationRepo.save(
this.organizationRepo.create({
publicId: uuidV7(),
code: `ORG_${Date.now()}_${i}`,
name: row.organization.trim(),
isHost: false,
status: 'active',
}),
);
}
if (!organization) throw new BadRequestException('尚未配置本机构');
let student = await this.studentRepo.findOne({ where: { name: row.name.trim() } });
if (!student) {
student = await this.studentRepo.save(
@@ -407,7 +433,7 @@ export class OccupanciesService {
ethnicity: row.ethnicity?.trim() || undefined,
emergencyContact: row.emergencyContact?.trim() || undefined,
emergencyPhone: row.emergencyPhone?.trim() || undefined,
organization: row.organization?.trim() || undefined,
organizationId: organization.id,
supervisor: row.supervisor?.trim() || undefined,
}),
);
@@ -422,8 +448,7 @@ export class OccupanciesService {
updates.emergencyContact = row.emergencyContact.trim();
if (!student.emergencyPhone && row.emergencyPhone?.trim())
updates.emergencyPhone = row.emergencyPhone.trim();
if (!student.organization && row.organization?.trim())
updates.organization = row.organization.trim();
if (!student.organizationId) updates.organizationId = organization.id;
if (!student.supervisor && row.supervisor?.trim())
updates.supervisor = row.supervisor.trim();
if (Object.keys(updates).length > 0) {
@@ -486,6 +511,7 @@ export class OccupanciesService {
roomId: room.id,
checkInDate,
billingStartDate: checkInDate,
responsibleOrganizationId: student.organizationId || organization.id,
};
// 如果有退宿日期,直接记录
if (row.checkOutDate?.trim()) {

View File

@@ -1,13 +1,21 @@
import { IsOptional, IsString, IsNotEmpty, IsEnum } from 'class-validator';
import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString, Matches } from 'class-validator';
export class CreateTenantDto {
export class CreateOrganizationDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@Matches(/^[A-Z0-9_-]+$/)
code: string;
@IsOptional()
@IsBoolean()
isHost?: boolean;
@IsOptional()
@IsString()
contact?: string;
contactName?: string;
@IsOptional()
@IsString()
@@ -22,14 +30,19 @@ export class CreateTenantDto {
notes?: string;
}
export class UpdateTenantDto {
export class UpdateOrganizationDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
contact?: string;
@Matches(/^[A-Z0-9_-]+$/)
code?: string;
@IsOptional()
@IsString()
contactName?: string;
@IsOptional()
@IsString()
@@ -45,5 +58,5 @@ export class UpdateTenantDto {
@IsOptional()
@IsEnum(['active', 'archived'])
status?: string;
status?: 'active' | 'archived';
}

View File

@@ -10,45 +10,48 @@ import {
UseGuards,
Request,
} from '@nestjs/common';
import { TenantsService } from './tenants.service';
import { CreateTenantDto, UpdateTenantDto } from './dto/tenant.dto';
import { OrganizationsService } from './organizations.service';
import { CreateOrganizationDto, UpdateOrganizationDto } from './dto/organization.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { RequirePermission } from '../auth/decorators/permission.decorator';
@UseGuards(JwtAuthGuard)
@Controller('tenants')
export class TenantsController {
@Controller('organizations')
export class OrganizationsController {
constructor(
private service: TenantsService,
private service: OrganizationsService,
private logService: OperationLogsService,
) {}
@Get()
@RequirePermission('tenant:view')
findAll(@Query('includeArchived') includeArchived?: string) {
return this.service.findAll({ includeArchived: includeArchived === 'true' });
@RequirePermission('organization:view')
findAll(
@Query('includeArchived') includeArchived?: string,
@Query('scope') scope?: 'all' | 'host' | 'external',
) {
return this.service.findAll({ includeArchived: includeArchived === 'true', scope });
}
@Get(':id')
@RequirePermission('tenant:view')
@RequirePermission('organization:view')
findOne(@Param('id') id: string) {
return this.service.findOne(+id);
}
@Post()
@RequirePermission('tenant:create')
async create(@Body() dto: CreateTenantDto, @Request() req: any) {
@RequirePermission('organization:create')
async create(@Body() dto: CreateOrganizationDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '租赁方',
action: '新增租赁方',
module: '机构管理',
action: '新增机构',
targetId: result.id,
targetType: 'tenant',
targetType: 'organization',
detail: dto.name,
ipAddress,
userAgent,
@@ -57,17 +60,17 @@ export class TenantsController {
}
@Put(':id')
@RequirePermission('tenant:edit')
async update(@Param('id') id: string, @Body() dto: UpdateTenantDto, @Request() req: any) {
@RequirePermission('organization:edit')
async update(@Param('id') id: string, @Body() dto: UpdateOrganizationDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '租赁方',
action: '编辑租赁方',
module: '机构管理',
action: '编辑机构',
targetId: +id,
targetType: 'tenant',
targetType: 'organization',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
@@ -76,17 +79,17 @@ export class TenantsController {
}
@Delete(':id')
@RequirePermission('tenant:delete')
@RequirePermission('organization:delete')
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '租赁方',
action: '归档租赁方',
module: '机构管理',
action: '归档机构',
targetId: +id,
targetType: 'tenant',
targetType: 'organization',
ipAddress,
userAgent,
});

View File

@@ -0,0 +1,30 @@
import { Module, OnModuleInit } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Organization } from '../entities/organization.entity';
import { OrganizationsService } from './organizations.service';
import { OrganizationsController } from './organizations.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([Organization]), OperationLogsModule],
controllers: [OrganizationsController],
providers: [OrganizationsService],
exports: [OrganizationsService],
})
export class OrganizationsModule implements OnModuleInit {
constructor(private readonly service: OrganizationsService) {}
async onModuleInit() {
try {
await this.service.getHostOrganization();
} catch {
await this.service.create({
name: process.env.HOST_ORGANIZATION_NAME || '本机构',
code: process.env.HOST_ORGANIZATION_CODE || 'HOST',
isHost: true,
color: '#1677ff',
notes: '系统默认运营主体',
});
}
}
}

View File

@@ -0,0 +1,30 @@
import { BadRequestException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { OrganizationsService } from './organizations.service';
import { Organization } from '../entities/organization.entity';
describe('OrganizationsService — host organization rules', () => {
let repo: jest.Mocked<
Pick<Repository<Organization>, 'findOne' | 'find' | 'count' | 'create' | 'save' | 'update'>
>;
let service: OrganizationsService;
beforeEach(() => {
repo = {
findOne: jest.fn(),
find: jest.fn(),
count: jest.fn(),
create: jest.fn(),
save: jest.fn(),
update: jest.fn(),
} as any;
service = new OrganizationsService(repo as Repository<Organization>);
});
it('does not allow the host organization to be archived', async () => {
repo.findOne.mockResolvedValue({ id: 1, name: '本机构', isHost: true } as Organization);
await expect(service.remove(1)).rejects.toBeInstanceOf(BadRequestException);
expect(repo.update).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,81 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Not, Repository } from 'typeorm';
import { uuidV7 } from '../common/uuid-v7';
import { Organization } from '../entities/organization.entity';
import { CreateOrganizationDto, UpdateOrganizationDto } from './dto/organization.dto';
const COLOR_PALETTE = [
'#ff7875',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
@Injectable()
export class OrganizationsService {
constructor(@InjectRepository(Organization) private repo: Repository<Organization>) {}
async findAll(query?: { includeArchived?: boolean; scope?: 'all' | 'host' | 'external' }) {
const where: Record<string, unknown> = {};
if (!query?.includeArchived) where.status = Not('archived');
if (query?.scope === 'host') where.isHost = true;
if (query?.scope === 'external') where.isHost = false;
return this.repo.find({ where, order: { isHost: 'DESC', name: 'ASC' } });
}
async findOne(id: number) {
const organization = await this.repo.findOne({ where: { id } });
if (!organization) throw new NotFoundException('机构不存在');
return organization;
}
async getHostOrganization() {
const organization = await this.repo.findOne({ where: { isHost: true, status: 'active' } });
if (!organization) throw new NotFoundException('尚未配置本机构');
return organization;
}
async create(dto: CreateOrganizationDto) {
if (dto.isHost) {
const existingHost = await this.repo.findOne({ where: { isHost: true } });
if (existingHost) throw new BadRequestException('本机构已存在,只能配置一个本机构');
}
const color = dto.color || COLOR_PALETTE[(await this.repo.count()) % COLOR_PALETTE.length];
return this.repo.save(
this.repo.create({
...dto,
code: dto.code.trim().toUpperCase(),
publicId: uuidV7(),
color,
isHost: dto.isHost ?? false,
status: 'active',
}),
);
}
async update(id: number, dto: UpdateOrganizationDto) {
const organization = await this.findOne(id);
if (organization.isHost && dto.status === 'archived') {
throw new BadRequestException('本机构不能归档');
}
await this.repo.update(id, {
...dto,
...(dto.code ? { code: dto.code.trim().toUpperCase() } : {}),
});
return this.repo.findOne({ where: { id } });
}
async remove(id: number) {
const organization = await this.findOne(id);
if (organization.isHost) throw new BadRequestException('本机构不能归档');
await this.repo.update(id, { status: 'archived' });
return { message: '已归档' };
}
}

View File

@@ -51,10 +51,10 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
{ code: 'classroom:create', name: '新增教室', group: 'classroom' },
{ code: 'classroom:edit', name: '编辑教室', group: 'classroom' },
{ code: 'classroom:delete', name: '删除教室', group: 'classroom' },
{ code: 'tenant:view', name: '查看租赁方', group: 'tenant' },
{ code: 'tenant:create', name: '新增租赁方', group: 'tenant' },
{ code: 'tenant:edit', name: '编辑租赁方', group: 'tenant' },
{ code: 'tenant:delete', name: '删除租赁方', group: 'tenant' },
{ code: 'organization:view', name: '查看机构', group: 'organization' },
{ code: 'organization:create', name: '新增机构', group: 'organization' },
{ code: 'organization:edit', name: '编辑机构', group: 'organization' },
{ code: 'organization:delete', name: '归档机构', group: 'organization' },
{ code: 'rental:view', name: '查看租赁订单', group: 'rental' },
{ code: 'rental:create', name: '新增租赁订单', group: 'rental' },
{ code: 'rental:edit', name: '编辑租赁订单', group: 'rental' },
@@ -154,7 +154,7 @@ export const PRESET_ROLES: Array<{
code: 'institution_head',
description: '管理机构教室和课程',
isSystem: true,
permissionGroups: ['classroom', 'rental', 'tenant', 'notification', 'profile'],
permissionGroups: ['classroom', 'rental', 'organization', 'notification', 'profile'],
},
{
name: '财务',

View File

@@ -191,7 +191,7 @@ export class RoomsService {
{ checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) },
]
: { checkOutDate: IsNull() },
relations: ['student', 'tenant'],
relations: ['student', 'student.organization', 'responsibleOrganization'],
order: { checkInDate: 'ASC' },
});
@@ -212,11 +212,11 @@ export class RoomsService {
checkInDate: occ.checkInDate,
billingStartDate: occ.billingStartDate,
days,
organization: occ.student?.organization || null,
organization: occ.student?.organization?.name || null,
supervisor: occ.student?.supervisor || null,
tenantId: occ.tenantId || null,
tenantName: occ.tenant?.name || null,
tenantColor: occ.tenant?.color || null,
organizationId: occ.responsibleOrganizationId || null,
organizationName: occ.responsibleOrganization?.name || null,
organizationColor: occ.responsibleOrganization?.color || null,
});
}
@@ -250,9 +250,9 @@ export class RoomsService {
const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]);
orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`;
}
const tenantColors = [...new Set(occ.map((o: any) => o.tenantColor).filter(Boolean))];
const tenantColor: string | null = tenantColors.length === 1 ? tenantColors[0] : null;
const tenantIds = [...new Set(occ.map((o: any) => o.tenantId).filter(Boolean))];
const organizationColors = [...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean))];
const organizationColor: string | null = organizationColors.length === 1 ? organizationColors[0] : null;
const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))];
return {
id: room.id,
roomNumber: room.roomNumber,
@@ -265,16 +265,16 @@ export class RoomsService {
occupiedBeds: bedMap.get(room.id)?.occupied ?? 0,
occupants: occ,
orgLabel,
tenantColor,
tenantIds,
organizationColor,
organizationIds,
};
}),
// 当前视图内出现过的租赁方,供筛选下拉使用
tenants: [
// 当前视图内出现过的负责机构,供筛选下拉使用
organizations: [
...new Map(
occupancies
.filter((o) => o.tenantId && o.tenant)
.map((o) => [o.tenantId, { id: o.tenantId, name: o.tenant.name, color: o.tenant.color || null }]),
.filter((o) => o.responsibleOrganizationId && o.responsibleOrganization)
.map((o) => [o.responsibleOrganizationId, { id: o.responsibleOrganizationId, name: o.responsibleOrganization.name, color: o.responsibleOrganization.color || null }]),
).values(),
].sort((a, b) => a.name.localeCompare(b.name)),
};

View File

@@ -172,7 +172,7 @@ describe('SchedulesService — getClassroomOccupancy', () => {
const rentalSchedule = {
id: 2,
scheduleType: ScheduleType.RENTAL,
subject: 'Tenant A 租赁',
subject: 'Organization A 租赁',
} as ClassSchedule;
const qb = mockQueryBuilder<ClassSchedule>([internalSchedule, rentalSchedule]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);

View File

@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsEnum, IsNumber, IsInt } from 'class-validator';
import { IsString, IsOptional, IsEnum, IsInt } from 'class-validator';
export class CreateStudentDto {
@IsString()
@@ -32,19 +32,12 @@ export class CreateStudentDto {
@IsString()
emergencyPhone?: string;
@IsOptional()
@IsString()
organization?: string;
@IsOptional()
@IsNumber()
tenantId?: number;
@IsInt()
organizationId: number;
@IsOptional()
@IsString()
supervisor?: string;
@IsOptional()
@IsOptional()
@IsInt()
classId?: number;
@@ -84,12 +77,8 @@ export class UpdateStudentDto {
emergencyPhone?: string;
@IsOptional()
@IsString()
organization?: string;
@IsOptional()
@IsNumber()
tenantId?: number;
@IsInt()
organizationId?: number;
@IsOptional()
@IsString()

View File

@@ -16,7 +16,7 @@ import {
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Tenant } from '../entities/tenant.entity';
import { Organization } from '../entities/organization.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
@@ -34,7 +34,7 @@ export class StudentsController {
constructor(
private service: StudentsService,
private logService: OperationLogsService,
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
) {}
private canManageAllStudents(user: { isSuperAdmin?: boolean; permissions?: string[] }): boolean {
@@ -51,7 +51,7 @@ export class StudentsController {
@Query('name') name: string | undefined,
@Query('status') status: string | undefined,
@Query('includeArchived') includeArchived: string | undefined,
@Query('tenantId') tenantId: string | undefined,
@Query('organizationId') organizationId: string | undefined,
@Request() req: { user: { id: number; isSuperAdmin?: boolean; permissions?: string[] } },
) {
const classIds = await this.service.getAccessibleClassIds(
@@ -63,7 +63,7 @@ export class StudentsController {
name,
status,
includeArchived: includeArchived === 'true',
tenantId: tenantId ? +tenantId : undefined,
organizationId: organizationId ? +organizationId : undefined,
},
classIds,
);
@@ -94,7 +94,7 @@ export class StudentsController {
{ header: '民族', key: 'ethnicity', width: 10 },
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
{ header: '所属机构', key: 'tenant', width: 18 },
{ header: '所属机构', key: 'organization', width: 18 },
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
{ header: '状态', key: 'status', width: 10 },
];
@@ -115,7 +115,7 @@ export class StudentsController {
ethnicity: s.ethnicity || '',
emergencyContact: s.emergencyContact || '',
emergencyPhone: s.emergencyPhone || '',
tenant: s.tenant?.name || '',
organization: s.organization?.name || '',
supervisor: s.supervisor || '',
status: statusMap[s.status] || s.status,
});
@@ -152,7 +152,7 @@ export class StudentsController {
{ header: '民族', key: 'ethnicity', width: 10 },
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
{ header: '所属机构(租赁方名称', key: 'tenant', width: 18 },
{ header: '所属机构名称', key: 'organization', width: 18 },
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
];
ws.getRow(1).font = { bold: true };
@@ -165,7 +165,7 @@ export class StudentsController {
ethnicity: '汉族',
emergencyContact: '张父',
emergencyPhone: '13900000000',
tenant: 'XX教育公司',
organization: 'XX教育公司',
supervisor: '',
});
res.setHeader(
@@ -290,9 +290,9 @@ export class StudentsController {
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
tenant?: string;
organization?: string;
supervisor?: string;
tenantId?: number;
organizationId?: number;
}[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
@@ -304,16 +304,18 @@ export class StudentsController {
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
emergencyContact: String(row.getCell(6).value || '').trim() || undefined,
emergencyPhone: String(row.getCell(7).value || '').trim() || undefined,
tenant: String(row.getCell(8).value || '').trim() || undefined,
organization: String(row.getCell(8).value || '').trim() || undefined,
supervisor: String(row.getCell(9).value || '').trim() || undefined,
});
});
// Resolve tenant names to IDs
// Resolve organization names to IDs
for (const row of rows) {
if (row.tenant) {
const tenant = await this.tenantRepo.findOne({ where: { name: row.tenant } });
if (tenant) {
row.tenantId = tenant.id;
if (row.organization) {
const organization = await this.organizationRepo.findOne({
where: { name: row.organization },
});
if (organization) {
row.organizationId = organization.id;
}
}
}
@@ -348,7 +350,7 @@ export class StudentsController {
emergencyPhone?: string;
organization?: string;
supervisor?: string;
tenantId?: number;
organizationId?: number;
}[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
@@ -364,11 +366,13 @@ export class StudentsController {
supervisor: String(row.getCell(9).value || '').trim() || undefined,
});
});
// Resolve tenant names to IDs
// Resolve organization names to IDs
for (const row of rows) {
if (row.organization) {
const tenant = await this.tenantRepo.findOne({ where: { name: row.organization } });
if (tenant) row.tenantId = tenant.id;
const organization = await this.organizationRepo.findOne({
where: { name: row.organization },
});
if (organization) row.organizationId = organization.id;
}
}
const result = await this.service.matchImport(rows);

View File

@@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Student } from '../entities/student.entity';
import { Class } from '../entities/class.entity';
import { Tenant } from '../entities/tenant.entity';
import { Organization } from '../entities/organization.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
@@ -16,7 +16,7 @@ import { StudentsController } from './students.controller';
Class,
ClassStudent,
AttendanceRecord,
Tenant,
Organization,
ClassTeacher,
]),
],

View File

@@ -12,6 +12,7 @@ describe('StudentsService — teacher class scope', () => {
{} as never,
{} as never,
{} as never,
{} as never,
);
await service.findAll({}, [3, 5]);
@@ -22,7 +23,7 @@ describe('StudentsService — teacher class scope', () => {
expect(repo.find).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ id: expect.any(Object) }),
relations: ['tenant'],
relations: ['organization'],
}),
);
});
@@ -35,6 +36,7 @@ describe('StudentsService — teacher class scope', () => {
{} as never,
{} as never,
{} as never,
{} as never,
);
await expect(service.findAll({}, [])).resolves.toEqual([]);

View File

@@ -6,6 +6,7 @@ import { Class } from '../entities/class.entity';
import { ClassStudent } from '../entities/class-student.entity';
import { ClassTeacher } from '../entities/class-teacher.entity';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { Organization } from '../entities/organization.entity';
import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto';
@Injectable()
@@ -16,6 +17,7 @@ export class StudentsService {
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
) {}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
@@ -29,13 +31,13 @@ export class StudentsService {
name?: string;
status?: string;
includeArchived?: boolean;
tenantId?: number | string;
organizationId?: number | string;
},
accessibleClassIds?: number[],
) {
const where: FindOptionsWhere<Student> = {};
if (query?.name) where.name = Like(`%${query.name}%`);
if (query?.tenantId) where.tenantId = Number(query.tenantId);
if (query?.organizationId) where.organizationId = Number(query.organizationId);
if (query?.status) {
where.status = query.status;
} else if (!query?.includeArchived) {
@@ -50,7 +52,7 @@ export class StudentsService {
if (studentIds.length === 0) return [];
where.id = In(studentIds);
}
return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['tenant'] });
return this.repo.find({ where, order: { createdAt: 'DESC' }, relations: ['organization'] });
}
async findOne(id: number) {
@@ -63,11 +65,13 @@ export class StudentsService {
}
async create(dto: CreateStudentDto) {
await this.assertActiveOrganization(dto.organizationId);
return this.repo.save(this.repo.create(dto));
}
async update(id: number, dto: UpdateStudentDto) {
await this.findOne(id);
if (dto.organizationId) await this.assertActiveOrganization(dto.organizationId);
await this.repo.update(id, dto);
return this.repo.findOne({ where: { id } });
}
@@ -127,7 +131,7 @@ export class StudentsService {
emergencyPhone?: string;
organization?: string;
supervisor?: string;
tenantId?: number;
organizationId?: number;
}[],
) {
let imported = 0;
@@ -151,9 +155,8 @@ export class StudentsService {
ethnicity: row.ethnicity || undefined,
emergencyContact: row.emergencyContact || undefined,
emergencyPhone: row.emergencyPhone || undefined,
organization: row.organization || undefined,
supervisor: row.supervisor || undefined,
tenantId: row.tenantId || undefined,
organizationId: row.organizationId || (await this.getHostOrganizationId()),
}),
);
imported++;
@@ -176,7 +179,7 @@ export class StudentsService {
emergencyPhone?: string;
organization?: string;
supervisor?: string;
tenantId?: number;
organizationId?: number;
}[],
) {
let matched = 0;
@@ -208,9 +211,8 @@ export class StudentsService {
| 'ethnicity'
| 'emergencyContact'
| 'emergencyPhone'
| 'organization'
| 'supervisor'
| 'tenantId'
| 'organizationId'
>
> = {};
if (row.name?.trim()) updates.name = row.name.trim();
@@ -220,9 +222,8 @@ export class StudentsService {
if (row.ethnicity) updates.ethnicity = row.ethnicity;
if (row.emergencyContact) updates.emergencyContact = row.emergencyContact;
if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone;
if (row.organization) updates.organization = row.organization;
if (row.supervisor) updates.supervisor = row.supervisor;
if (row.tenantId) updates.tenantId = row.tenantId;
if (row.organizationId) updates.organizationId = row.organizationId;
await this.repo.update(student.id, updates as Partial<Student>);
matched++;
}
@@ -233,6 +234,19 @@ export class StudentsService {
};
}
private async assertActiveOrganization(id: number) {
const organization = await this.organizationRepo.findOne({ where: { id, status: 'active' } });
if (!organization) throw new BadRequestException('所属机构不存在或已归档');
}
private async getHostOrganizationId() {
const organization = await this.organizationRepo.findOne({
where: { isHost: true, status: 'active' },
});
if (!organization) throw new BadRequestException('尚未配置本机构');
return organization.id;
}
async compareClasses(studentId: number) {
const student = await this.repo.findOne({ where: { id: studentId } });
if (!student) throw new NotFoundException('学生不存在');

View File

@@ -1,14 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Tenant } from '../entities/tenant.entity';
import { TenantsService } from './tenants.service';
import { TenantsController } from './tenants.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([Tenant]), OperationLogsModule],
controllers: [TenantsController],
providers: [TenantsService],
exports: [TenantsService],
})
export class TenantsModule {}

View File

@@ -1,58 +0,0 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm';
import { Tenant } from '../entities/tenant.entity';
import { CreateTenantDto, UpdateTenantDto } from './dto/tenant.dto';
// 预设色板(避开红绿盲敏感色,保证差异度)
const COLOR_PALETTE = [
'#ff7875',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
@Injectable()
export class TenantsService {
constructor(@InjectRepository(Tenant) private repo: Repository<Tenant>) {}
async findAll(query?: { includeArchived?: boolean }) {
const where: any = {};
if (!query?.includeArchived) where.status = Not('archived');
return this.repo.find({ where, order: { createdAt: 'DESC' } });
}
async findOne(id: number) {
const tenant = await this.repo.findOne({ where: { id } });
if (!tenant) throw new NotFoundException('租赁方不存在');
return tenant;
}
async create(dto: CreateTenantDto) {
// 颜色未指定则自动分配(按当前租赁方数量取模)
let color = dto.color;
if (!color) {
const total = await this.repo.count();
color = COLOR_PALETTE[total % COLOR_PALETTE.length];
}
return this.repo.save(this.repo.create({ ...dto, color }));
}
async update(id: number, dto: UpdateTenantDto) {
await this.findOne(id);
await this.repo.update(id, dto);
return this.repo.findOne({ where: { id } });
}
async remove(id: number) {
await this.findOne(id);
await this.repo.update(id, { status: 'archived' });
return { message: '已归档' };
}
}