diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 9a60c4a..3bd0e2b 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -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 = () => { } /> - + + } /> diff --git a/apps/admin/src/layouts/MainLayout.tsx b/apps/admin/src/layouts/MainLayout.tsx index 2832851..d5e77cc 100644 --- a/apps/admin/src/layouts/MainLayout.tsx +++ b/apps/admin/src/layouts/MainLayout.tsx @@ -90,7 +90,7 @@ const allMenuItems: MenuItemType[] = [ { key: '/classroom-schedule', icon: , label: '排期总览', permission: 'classroom:view' }, { key: '/classrooms', icon: , label: '教室列表', permission: 'classroom:view' }, { key: '/classroom-rentals', icon: , label: '租赁订单', permission: 'rental:view' }, - { key: '/tenants', icon: , label: '租赁方', permission: 'tenant:view' }, + { key: '/organizations', icon: , label: '机构管理', permission: 'organization:view' }, ], }, { diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index db22e2e..4baa18b 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -31,7 +31,7 @@ export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) => const ClassroomRentalsPage: React.FC = () => { const [data, setData] = useState([]); const [classrooms, setClassrooms] = useState([]); - const [tenants, setTenants] = useState([]); + const [organizations, setOrganizations] = useState([]); const [loading, setLoading] = useState(false); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(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 ? ( { > setSearchText(v)} @@ -458,12 +463,35 @@ const ClassroomRentalsPage: React.FC = () => { }))} /> - + !organization.isHost && organization.status === 'active') + .map((organization) => ({ + value: organization.id, + label: organization.name, + }))} /> diff --git a/apps/admin/src/pages/ClassroomSchedule/index.tsx b/apps/admin/src/pages/ClassroomSchedule/index.tsx index 0d54e35..a095863 100644 --- a/apps/admin/src/pages/ClassroomSchedule/index.tsx +++ b/apps/admin/src/pages/ClassroomSchedule/index.tsx @@ -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>; 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 = () => { 内部排课 - {data.tenants.map((t) => ( + {data.organizations.map((t) => ( { {t.name} (租赁) ))} - 空闲 + + 空闲 + )} @@ -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 ? ' · 有合同' : ''}` } > @@ -347,21 +346,22 @@ const ClassroomSchedulePage: React.FC = () => { {detailModal.classroom?.roomType})
- 租赁方: + 承租机构: - {detailModal.tenant?.name} + {detailModal.lesseeOrganization?.name}
联系人: - {detailModal.tenant?.contact || '-'} {detailModal.tenant?.phone || ''} + {detailModal.lesseeOrganization?.contactName || '-'}{' '} + {detailModal.lesseeOrganization?.phone || ''}
起止日期: diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 625cd64..f11dfef 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -39,7 +39,7 @@ const OccupanciesPage: React.FC = () => { const [data, setData] = useState([]); const [students, setStudents] = useState([]); const [rooms, setRooms] = useState([]); - const [tenants, setTenants] = useState([]); + const [organizations, setOrganizations] = useState([]); const [loading, setLoading] = useState(false); const [checkInModal, setCheckInModal] = useState(false); const [checkOutModal, setCheckOutModal] = useState(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[]; - 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" /> - + ({ + placeholder="默认取学生所属机构" + options={organizations.map((t: { id: number; name: string }) => ({ value: t.id, label: t.name, }))} diff --git a/apps/admin/src/pages/Organizations/index.tsx b/apps/admin/src/pages/Organizations/index.tsx new file mode 100644 index 0000000..990ce27 --- /dev/null +++ b/apps/admin/src/pages/Organizations/index.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [modalOpen, setModalOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + const [searchText, setSearchText] = useState(''); + const [filterStatus, setFilterStatus] = useState(); + + 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('/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) => ( + + + {name} + {record.isHost ? ( + }> + 本机构 + + ) : ( + 外部机构 + )} + + ), + }, + { + title: '机构编码', + dataIndex: 'code', + width: 130, + render: (value: string) => {value}, + }, + { + 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) => ( + + {status === 'active' ? '正常' : '已归档'} + + ), + }, + { + title: '操作', + width: 160, + render: (_: unknown, record: OrganizationItem) => ( + + openEditor(record)} + > + 编辑 + + {!record.isHost && record.status === 'active' ? ( + { + try { + await api.delete(`/organizations/${record.id}`); + message.success('机构已归档'); + await fetchData(); + } catch (error: any) { + message.error(error?.message || '归档失败'); + } + }} + > + } + > + 归档 + + + ) : null} + + ), + }, + ]; + + return ( +
+ +
+ + setSearchText(event.target.value)} + /> + + + + form.setFieldValue('code', event.target.value.toUpperCase())} + /> + + + + + + + + + + {PRESET_COLORS.map((color) => ( +
+ ); +}; + +export default OrganizationsPage; diff --git a/apps/admin/src/pages/Permissions/index.tsx b/apps/admin/src/pages/Permissions/index.tsx index 76bce05..b34efbb 100644 --- a/apps/admin/src/pages/Permissions/index.tsx +++ b/apps/admin/src/pages/Permissions/index.tsx @@ -25,7 +25,7 @@ const PermissionsPage: React.FC = () => { bill: '账单管理', deposit: '押金管理', classroom: '教室管理', - tenant: '租赁方', + organization: '机构管理', rental: '租赁订单', log: '操作日志', user: '用户管理', diff --git a/apps/admin/src/pages/Roles/index.tsx b/apps/admin/src/pages/Roles/index.tsx index 25d03f3..1133e21 100644 --- a/apps/admin/src/pages/Roles/index.tsx +++ b/apps/admin/src/pages/Roles/index.tsx @@ -125,7 +125,7 @@ const RolesPage: React.FC = () => { bill: '账单管理', deposit: '押金管理', classroom: '教室管理', - tenant: '租赁方', + organization: '机构管理', rental: '租赁订单', log: '操作日志', user: '用户管理', diff --git a/apps/admin/src/pages/RoomVisual/index.tsx b/apps/admin/src/pages/RoomVisual/index.tsx index 435a04b..6badcb9 100644 --- a/apps/admin/src/pages/RoomVisual/index.tsx +++ b/apps/admin/src/pages/RoomVisual/index.tsx @@ -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 部分入住; } -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 (
- {tenantList.map((t) => ( + {organizationList.map((t) => ( { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [selectedBuilding, setSelectedBuilding] = useState('all'); - const [selectedTenant, setSelectedTenant] = useState('all'); + const [selectedOrganization, setSelectedOrganization] = useState('all'); const [detailRoom, setDetailRoom] = useState(null); const [asOf, setAsOf] = useState(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 (
@@ -128,12 +128,12 @@ const RoomVisualPage: React.FC = () => { ]} /> - + - } - onClick={() => { - setEditing(null); - form.resetFields(); - setModalOpen(true); - }} - > - 添加租赁方 - -
- }} - pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }} - /> - { - setModalOpen(false); - setEditing(null); - }} - okText="保存" - confirmLoading={saving} - > -
- - - - - - - - - - - - - - {PRESET_COLORS.map((c) => ( - 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', - }} - /> - ))} - - - - - - - -
- - ); -}; - -export default TenantsPage; diff --git a/apps/admin/src/test/fixtures.ts b/apps/admin/src/test/fixtures.ts index ec2fed6..9d54a01 100644 --- a/apps/admin/src/test/fixtures.ts +++ b/apps/admin/src/test/fixtures.ts @@ -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', diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 0609fa1..56745f0 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -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, diff --git a/apps/server/src/bills/bills.service.spec.ts b/apps/server/src/bills/bills.service.spec.ts index bf118aa..7b926df 100644 --- a/apps/server/src/bills/bills.service.spec.ts +++ b/apps/server/src/bills/bills.service.spec.ts @@ -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, ]), ); diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts index dc13c85..908c6a9 100644 --- a/apps/server/src/bills/bills.service.ts +++ b/apps/server/src/bills/bills.service.ts @@ -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) { diff --git a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts index 2b00e4f..f1dfc23 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts @@ -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, }); diff --git a/apps/server/src/classroom-rentals/classroom-rentals.module.ts b/apps/server/src/classroom-rentals/classroom-rentals.module.ts index cd6e53c..4b3cc5a 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.module.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.module.ts @@ -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], diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts index 2e40394..88113e8 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts @@ -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([rental]); const scheduleQb = mockQueryBuilder([]); (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([]); const scheduleQb = mockQueryBuilder([ - { 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([]); const scheduleQb = mockQueryBuilder([ - { 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, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'> + Pick< + Repository, + 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' + > >; let classroomRepo: jest.Mocked, 'findOne'>>; - let tenantRepo: jest.Mocked, 'findOne'>>; + let organizationRepo: jest.Mocked, 'findOne'>>; let scheduleRepo: jest.Mocked< - Pick, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'> + Pick< + Repository, + '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, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'> + Pick< + Repository, + 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder' + > >; classroomRepo = { findOne: jest.fn() } as jest.Mocked, 'findOne'>>; - tenantRepo = { findOne: jest.fn() } as jest.Mocked, 'findOne'>>; + organizationRepo = { findOne: jest.fn() } as jest.Mocked< + Pick, 'findOne'> + >; scheduleRepo = { findOne: jest.fn(), @@ -183,7 +216,10 @@ describe('ClassroomRentalsService — rental schedule sync', () => { delete: jest.fn(), createQueryBuilder: jest.fn(), } as jest.Mocked< - Pick, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'> + Pick< + Repository, + '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([])); scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); 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([])); @@ -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([])), + } 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([])), + } 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, + }), + ); + }); +}); diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.ts index 070b915..26f1510 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.ts @@ -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, @InjectRepository(Classroom) private classroomRepo: Repository, - @InjectRepository(Tenant) private tenantRepo: Repository, + @InjectRepository(Organization) private organizationRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, ) {} @@ -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, schedule: ClassSchedule, startDate: string, endDate: string) { + private addScheduleOccurrences( + dates: Set, + 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_schedules(schedule_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(); + const organizationMap = new Map(); const matrix: Record> = {}; 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, }; diff --git a/apps/server/src/classroom-rentals/dto/rental.dto.ts b/apps/server/src/classroom-rentals/dto/rental.dto.ts index 7a66c1c..92b34f4 100644 --- a/apps/server/src/classroom-rentals/dto/rental.dto.ts +++ b/apps/server/src/classroom-rentals/dto/rental.dto.ts @@ -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() diff --git a/apps/server/src/classrooms/classrooms.service.ts b/apps/server/src/classrooms/classrooms.service.ts index 2c776c1..d99142e 100644 --- a/apps/server/src/classrooms/classrooms.service.ts +++ b/apps/server/src/classrooms/classrooms.service.ts @@ -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') diff --git a/apps/server/src/common/uuid-v7.spec.ts b/apps/server/src/common/uuid-v7.spec.ts new file mode 100644 index 0000000..ae6a03a --- /dev/null +++ b/apps/server/src/common/uuid-v7.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/common/uuid-v7.ts b/apps/server/src/common/uuid-v7.ts new file mode 100644 index 0000000..966c73c --- /dev/null +++ b/apps/server/src/common/uuid-v7.ts @@ -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)}`; +} diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index 23b8c4a..9fe89e1 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -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 { + await this.backfillOrganizations(); await this.normalizeClassDates(); } + private async backfillOrganizations(): Promise { + 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> = + 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 { const driver = this.dataSource.options.type; const dateExpression = (column: string) => diff --git a/apps/server/src/entities/classroom-rental.entity.ts b/apps/server/src/entities/classroom-rental.entity.ts index 9afd677..9b3ff46 100644 --- a/apps/server/src/entities/classroom-rental.entity.ts +++ b/apps/server/src/entities/classroom-rental.entity.ts @@ -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; - } diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index 388dda5..41d7e68 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -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'; diff --git a/apps/server/src/entities/occupancy.entity.ts b/apps/server/src/entities/occupancy.entity.ts index a6c2ad6..a293694 100644 --- a/apps/server/src/entities/occupancy.entity.ts +++ b/apps/server/src/entities/occupancy.entity.ts @@ -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; - } diff --git a/apps/server/src/entities/tenant.entity.ts b/apps/server/src/entities/organization.entity.ts similarity index 59% rename from apps/server/src/entities/tenant.entity.ts rename to apps/server/src/entities/organization.entity.ts index 5924249..9f30351 100644 --- a/apps/server/src/entities/tenant.entity.ts +++ b/apps/server/src/entities/organization.entity.ts @@ -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; diff --git a/apps/server/src/entities/student.entity.ts b/apps/server/src/entities/student.entity.ts index d272d6b..016131b 100644 --- a/apps/server/src/entities/student.entity.ts +++ b/apps/server/src/entities/student.entity.ts @@ -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[]; - } diff --git a/apps/server/src/occupancies/dto/occupancy.dto.ts b/apps/server/src/occupancies/dto/occupancy.dto.ts index 55cd56a..a3e22db 100644 --- a/apps/server/src/occupancies/dto/occupancy.dto.ts +++ b/apps/server/src/occupancies/dto/occupancy.dto.ts @@ -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() diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index 3894ae3..4e4a5ee 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -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 || '', diff --git a/apps/server/src/occupancies/occupancies.module.ts b/apps/server/src/occupancies/occupancies.module.ts index e812e8c..fab0445 100644 --- a/apps/server/src/occupancies/occupancies.module.ts +++ b/apps/server/src/occupancies/occupancies.module.ts @@ -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], diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts new file mode 100644 index 0000000..ede4aa7 --- /dev/null +++ b/apps/server/src/occupancies/occupancies.service.spec.ts @@ -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; + const roomRepo = { + findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4, gender: null }), + update: jest.fn(), + } as any as Repository; + const studentRepo = { + findOne: jest.fn().mockResolvedValue({ id: 3, gender: '男', organizationId: 7 }), + } as any as Repository; + + const service = new OccupanciesService( + occupancyRepo, + roomRepo, + studentRepo, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as DataSource, + ); + + await service.checkIn({ + studentId: 3, + roomId: 2, + checkInDate: '2026-07-10', + }); + + expect(occupancyRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ responsibleOrganizationId: 7 }), + ); + }); +}); diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index f750672..ded817f 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -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, @InjectRepository(Bed) private bedRepo: Repository, @InjectRepository(Locker) private lockerRepo: Repository, + @InjectRepository(Organization) private organizationRepo: Repository, 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()) { diff --git a/apps/server/src/tenants/dto/tenant.dto.ts b/apps/server/src/organizations/dto/organization.dto.ts similarity index 53% rename from apps/server/src/tenants/dto/tenant.dto.ts rename to apps/server/src/organizations/dto/organization.dto.ts index 309f853..7148856 100644 --- a/apps/server/src/tenants/dto/tenant.dto.ts +++ b/apps/server/src/organizations/dto/organization.dto.ts @@ -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'; } diff --git a/apps/server/src/tenants/tenants.controller.ts b/apps/server/src/organizations/organizations.controller.ts similarity index 61% rename from apps/server/src/tenants/tenants.controller.ts rename to apps/server/src/organizations/organizations.controller.ts index 26abde8..8ab4ac5 100644 --- a/apps/server/src/tenants/tenants.controller.ts +++ b/apps/server/src/organizations/organizations.controller.ts @@ -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, }); diff --git a/apps/server/src/organizations/organizations.module.ts b/apps/server/src/organizations/organizations.module.ts new file mode 100644 index 0000000..763d34f --- /dev/null +++ b/apps/server/src/organizations/organizations.module.ts @@ -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: '系统默认运营主体', + }); + } + } +} diff --git a/apps/server/src/organizations/organizations.service.spec.ts b/apps/server/src/organizations/organizations.service.spec.ts new file mode 100644 index 0000000..d0d3b1f --- /dev/null +++ b/apps/server/src/organizations/organizations.service.spec.ts @@ -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, '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); + }); + + 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(); + }); +}); diff --git a/apps/server/src/organizations/organizations.service.ts b/apps/server/src/organizations/organizations.service.ts new file mode 100644 index 0000000..655de27 --- /dev/null +++ b/apps/server/src/organizations/organizations.service.ts @@ -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) {} + + async findAll(query?: { includeArchived?: boolean; scope?: 'all' | 'host' | 'external' }) { + const where: Record = {}; + 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: '已归档' }; + } +} diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index 6bf8b41..384c884 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -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: '财务', diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index 1afb242..706afc3 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -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)), }; diff --git a/apps/server/src/schedules/schedules.service.spec.ts b/apps/server/src/schedules/schedules.service.spec.ts index 3718301..fdb8133 100644 --- a/apps/server/src/schedules/schedules.service.spec.ts +++ b/apps/server/src/schedules/schedules.service.spec.ts @@ -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([internalSchedule, rentalSchedule]); (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); diff --git a/apps/server/src/students/dto/student.dto.ts b/apps/server/src/students/dto/student.dto.ts index 0ffe7d0..40c4bdd 100644 --- a/apps/server/src/students/dto/student.dto.ts +++ b/apps/server/src/students/dto/student.dto.ts @@ -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() diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index 8f4d5c2..9cfd2b7 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -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, + @InjectRepository(Organization) private organizationRepo: Repository, ) {} 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); diff --git a/apps/server/src/students/students.module.ts b/apps/server/src/students/students.module.ts index afc6433..f7ed3dd 100644 --- a/apps/server/src/students/students.module.ts +++ b/apps/server/src/students/students.module.ts @@ -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, ]), ], diff --git a/apps/server/src/students/students.scope.spec.ts b/apps/server/src/students/students.scope.spec.ts index 8d687e1..44c3751 100644 --- a/apps/server/src/students/students.scope.spec.ts +++ b/apps/server/src/students/students.scope.spec.ts @@ -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([]); diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index 87e7ecc..2e688e7 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -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, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, + @InjectRepository(Organization) private organizationRepo: Repository, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { @@ -29,13 +31,13 @@ export class StudentsService { name?: string; status?: string; includeArchived?: boolean; - tenantId?: number | string; + organizationId?: number | string; }, accessibleClassIds?: number[], ) { const where: FindOptionsWhere = {}; 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); 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('学生不存在'); diff --git a/apps/server/src/tenants/tenants.module.ts b/apps/server/src/tenants/tenants.module.ts deleted file mode 100644 index 1ddcb3d..0000000 --- a/apps/server/src/tenants/tenants.module.ts +++ /dev/null @@ -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 {} diff --git a/apps/server/src/tenants/tenants.service.ts b/apps/server/src/tenants/tenants.service.ts deleted file mode 100644 index 11a43ac..0000000 --- a/apps/server/src/tenants/tenants.service.ts +++ /dev/null @@ -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) {} - - 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: '已归档' }; - } -}