fix: audit remediation — SSE user scoping, FK transactional safety, UI error handling
- H4: scoped SSE import progress to exact userId match; non-HTTP events excluded from all subscribers - H2: moved PRAGMA foreign_key_check inside SQLite transaction before COMMIT; violations rollback preserving old tables - M1: removed dead axios-style error branch from extractErrorMessage (interceptor already unwraps) - M2: split handleSave try/catch — save errors vs reload errors shown distinctly - M3: added provider field validation before AI config test request - Added SSE scoping regression tests (import service + controller) - Added FK check failure rollback test (database-migrations.spec) - Updated controller spec expectations for userId parameter Co-authored-by: Code Review <branch-review>
This commit is contained in:
@@ -193,7 +193,7 @@ const App: React.FC = () => {
|
||||
<Route
|
||||
path="teachers"
|
||||
element={
|
||||
<PermissionRoute permission="user:view">
|
||||
<PermissionRoute permission="teacher:view">
|
||||
<TeachersPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
@@ -252,7 +252,7 @@ const App: React.FC = () => {
|
||||
<Route
|
||||
path="teacher-workspace"
|
||||
element={
|
||||
<PermissionRoute permission="class:view">
|
||||
<PermissionRoute permission="teacher-workspace:view">
|
||||
<TeacherWorkspacePage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
|
||||
105
apps/admin/src/auth/menu-policy.integration.test.ts
Normal file
105
apps/admin/src/auth/menu-policy.integration.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildMenu, collectMenuPaths, findRoleAwareLandingPath } from './menu-policy';
|
||||
|
||||
const teacherPermissions = [
|
||||
'teacher-workspace:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'notification:view',
|
||||
];
|
||||
const academicPermissions = [
|
||||
'dashboard:view',
|
||||
'student:view',
|
||||
'class:view',
|
||||
'teacher:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'classroom:view',
|
||||
'notification:view',
|
||||
];
|
||||
const accommodationPermissions = [
|
||||
'dashboard:view',
|
||||
'room:view',
|
||||
'occupancy:view',
|
||||
'expense:view',
|
||||
'bill:view',
|
||||
'deposit:view',
|
||||
'notification:view',
|
||||
];
|
||||
const systemPermissions = [
|
||||
'user:view',
|
||||
'role:view',
|
||||
'log:view',
|
||||
'integration:read',
|
||||
'ai:config:read',
|
||||
'notification:view',
|
||||
];
|
||||
|
||||
describe('role-aware menu policy', () => {
|
||||
it('builds a teacher flow without global student or class management', () => {
|
||||
const menu = buildMenu(['任课老师'], teacherPermissions);
|
||||
expect(menu.map((item) => item.label)).toEqual(['教学工作', '通知中心']);
|
||||
expect(collectMenuPaths(menu)).toEqual([
|
||||
'/teacher-workspace',
|
||||
'/schedules',
|
||||
'/attendance',
|
||||
'/notifications',
|
||||
]);
|
||||
expect(findRoleAwareLandingPath(['任课老师'], teacherPermissions)).toBe(
|
||||
'/teacher-workspace',
|
||||
);
|
||||
});
|
||||
|
||||
it('places schedules and attendance only once in academic management', () => {
|
||||
const menu = buildMenu(['教务管理员'], academicPermissions);
|
||||
const paths = collectMenuPaths(menu);
|
||||
expect(menu.map((item) => item.label)).toEqual(['数据面板', '教务管理', '通知中心']);
|
||||
expect(paths.filter((path) => path === '/schedules')).toHaveLength(1);
|
||||
expect(paths.filter((path) => path === '/attendance')).toHaveLength(1);
|
||||
expect(paths).not.toContain('/teacher-workspace');
|
||||
});
|
||||
|
||||
it('keeps accommodation billing in one business workspace', () => {
|
||||
const menu = buildMenu(['住宿运营管理员'], accommodationPermissions);
|
||||
expect(menu.map((item) => item.label)).toEqual(['数据面板', '住宿运营', '通知中心']);
|
||||
expect(collectMenuPaths(menu)).toEqual([
|
||||
'/dashboard',
|
||||
'/room-visual',
|
||||
'/rooms',
|
||||
'/occupancies',
|
||||
'/expenses',
|
||||
'/bills',
|
||||
'/deposits',
|
||||
'/notifications',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lands system administrators on account management rather than notifications', () => {
|
||||
expect(findRoleAwareLandingPath(['系统管理员'], systemPermissions)).toBe('/users');
|
||||
});
|
||||
|
||||
it('builds a super-admin menu with unique routes and no teacher workspace', () => {
|
||||
const allPermissions = [
|
||||
...academicPermissions,
|
||||
...accommodationPermissions,
|
||||
...systemPermissions,
|
||||
'rental:view',
|
||||
'organization:view',
|
||||
];
|
||||
const menu = buildMenu(['超级管理员'], [...new Set(allPermissions)]);
|
||||
const paths = collectMenuPaths(menu);
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
expect(paths).not.toContain('/teacher-workspace');
|
||||
expect(paths.filter((path) => path === '/attendance')).toHaveLength(1);
|
||||
expect(paths.filter((path) => path === '/schedules')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('deduplicates routes when an account has multiple business roles', () => {
|
||||
const menu = buildMenu(
|
||||
['教务管理员', '住宿运营管理员'],
|
||||
[...academicPermissions, ...accommodationPermissions],
|
||||
);
|
||||
const paths = collectMenuPaths(menu);
|
||||
expect(new Set(paths).size).toBe(paths.length);
|
||||
});
|
||||
});
|
||||
186
apps/admin/src/auth/menu-policy.ts
Normal file
186
apps/admin/src/auth/menu-policy.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
export interface AppMenuItem {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
children?: AppMenuItem[];
|
||||
}
|
||||
|
||||
interface MenuEntry extends AppMenuItem {
|
||||
permission: string;
|
||||
}
|
||||
|
||||
interface MenuSection extends AppMenuItem {
|
||||
roles: string[];
|
||||
children: MenuEntry[];
|
||||
}
|
||||
|
||||
const ROLE_ALIASES: Record<string, string> = {
|
||||
老师: 'teacher',
|
||||
任课老师: 'teacher',
|
||||
teacher: 'teacher',
|
||||
教务: 'academic',
|
||||
教务管理员: 'academic',
|
||||
academic: 'academic',
|
||||
宿管老师: 'accommodation',
|
||||
宿管: 'accommodation',
|
||||
财务: 'accommodation',
|
||||
住宿运营管理员: 'accommodation',
|
||||
accommodation_operations: 'accommodation',
|
||||
机构负责人: 'classroom',
|
||||
教室运营管理员: 'classroom',
|
||||
classroom_operations: 'classroom',
|
||||
系统管理员: 'system',
|
||||
system_admin: 'system',
|
||||
超管: 'super',
|
||||
超级管理员: 'super',
|
||||
super_admin: 'super',
|
||||
};
|
||||
|
||||
const SECTIONS: MenuSection[] = [
|
||||
{
|
||||
key: 'teaching-group',
|
||||
label: '教学工作',
|
||||
icon: 'calendar',
|
||||
roles: ['teacher'],
|
||||
children: [
|
||||
{ key: '/teacher-workspace', label: '今日教学', icon: 'workspace', permission: 'teacher-workspace:view' },
|
||||
{ key: '/schedules', label: '我的排课', icon: 'calendar', permission: 'schedule:view' },
|
||||
{ key: '/attendance', label: '课程考勤', icon: 'attendance', permission: 'attendance:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'academic-group',
|
||||
label: '教务管理',
|
||||
icon: 'academic',
|
||||
roles: ['academic', 'super'],
|
||||
children: [
|
||||
{ key: '/students', label: '学生管理', icon: 'students', permission: 'student:view' },
|
||||
{ key: '/classes', label: '班级管理', icon: 'classes', permission: 'class:view' },
|
||||
{ key: '/teachers', label: '教师管理', icon: 'teachers', permission: 'teacher:view' },
|
||||
{ key: '/schedules', label: '排课管理', icon: 'calendar', permission: 'schedule:view' },
|
||||
{ key: '/attendance', label: '历史考勤', icon: 'attendance', permission: 'attendance:view' },
|
||||
{ key: '/classrooms', label: '教室查看', icon: 'classroom', permission: 'classroom:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'accommodation-group',
|
||||
label: '住宿运营',
|
||||
icon: 'home',
|
||||
roles: ['accommodation', 'super'],
|
||||
children: [
|
||||
{ key: '/room-visual', label: '住宿总览', icon: 'overview', permission: 'room:view' },
|
||||
{ key: '/rooms', label: '房间管理', icon: 'home', permission: 'room:view' },
|
||||
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
|
||||
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
|
||||
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
|
||||
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'classroom-group',
|
||||
label: '教室运营',
|
||||
icon: 'classroom',
|
||||
roles: ['classroom', 'super'],
|
||||
children: [
|
||||
{ key: '/classroom-schedule', label: '教室排期', icon: 'calendar', permission: 'rental:view' },
|
||||
{ key: '/classrooms', label: '教室管理', icon: 'classroom', permission: 'classroom:view' },
|
||||
{ key: '/classroom-rentals', label: '租赁订单', icon: 'rental', permission: 'rental:view' },
|
||||
{ key: '/organizations', label: '机构管理', icon: 'organization', permission: 'organization:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system-group',
|
||||
label: '系统管理',
|
||||
icon: 'settings',
|
||||
roles: ['system', 'super'],
|
||||
children: [
|
||||
{ key: '/users', label: '账号管理', icon: 'users', permission: 'user:view' },
|
||||
{ key: '/roles', label: '角色管理', icon: 'role', permission: 'role:view' },
|
||||
{ key: '/permissions', label: '权限一览', icon: 'permission', permission: 'role:view' },
|
||||
{ key: '/operation-logs', label: '操作日志', icon: 'log', permission: 'log:view' },
|
||||
{ key: '/integration-config', label: '钉钉集成', icon: 'integration', permission: 'integration:read' },
|
||||
{ key: '/ai-config', label: 'AI 配置', icon: 'ai', permission: 'ai:config:read' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function getRoleDomains(roles: readonly string[], permissions: readonly string[]): Set<string> {
|
||||
const normalized = new Set(roles.map((role) => ROLE_ALIASES[role]).filter(Boolean));
|
||||
// 权限可以来自多个叠加角色,因此业务域按能力累加,而不是只选择一个。
|
||||
if (permissions.includes('student:view') || permissions.includes('class:view')) {
|
||||
normalized.add('academic');
|
||||
}
|
||||
if (
|
||||
permissions.includes('room:view') &&
|
||||
(permissions.includes('occupancy:view') || permissions.includes('expense:view'))
|
||||
) {
|
||||
normalized.add('accommodation');
|
||||
}
|
||||
if (permissions.includes('rental:view') || permissions.includes('organization:view')) {
|
||||
normalized.add('classroom');
|
||||
}
|
||||
if (permissions.includes('user:view') || permissions.includes('role:view')) {
|
||||
normalized.add('system');
|
||||
}
|
||||
const hasAdministrativeDomain = [...normalized].some((role) => role !== 'teacher');
|
||||
if (!hasAdministrativeDomain && permissions.includes('teacher-workspace:view')) {
|
||||
normalized.add('teacher');
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function dedupeMenu(items: AppMenuItem[]): AppMenuItem[] {
|
||||
const usedPaths = new Set<string>();
|
||||
const result: AppMenuItem[] = [];
|
||||
for (const item of items) {
|
||||
if (item.children) {
|
||||
const children = item.children.filter((child) => {
|
||||
if (usedPaths.has(child.key)) return false;
|
||||
usedPaths.add(child.key);
|
||||
return true;
|
||||
});
|
||||
if (children.length > 0) result.push({ ...item, children });
|
||||
continue;
|
||||
}
|
||||
if (!usedPaths.has(item.key)) {
|
||||
usedPaths.add(item.key);
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildMenu(roles: readonly string[], permissions: readonly string[]): AppMenuItem[] {
|
||||
const roleSet = getRoleDomains(roles, permissions);
|
||||
const permissionSet = new Set(permissions);
|
||||
const sections: AppMenuItem[] = [];
|
||||
|
||||
if (permissionSet.has('dashboard:view') && !roleSet.has('teacher')) {
|
||||
sections.push({ key: '/dashboard', label: '数据面板', icon: 'dashboard' });
|
||||
}
|
||||
|
||||
for (const section of SECTIONS) {
|
||||
if (!section.roles.some((role) => roleSet.has(role))) continue;
|
||||
const children = section.children
|
||||
.filter((child) => permissionSet.has(child.permission))
|
||||
.map(({ permission: _, ...child }) => child);
|
||||
if (children.length > 0) sections.push({ ...section, children, roles: undefined } as AppMenuItem);
|
||||
}
|
||||
|
||||
if (permissionSet.has('notification:view')) {
|
||||
sections.push({ key: '/notifications', label: '通知中心', icon: 'notification' });
|
||||
}
|
||||
|
||||
return dedupeMenu(sections);
|
||||
}
|
||||
|
||||
export function collectMenuPaths(items: readonly AppMenuItem[]): string[] {
|
||||
return items.flatMap((item) => (item.children ? collectMenuPaths(item.children) : [item.key]));
|
||||
}
|
||||
|
||||
export function findRoleAwareLandingPath(
|
||||
roles: readonly string[],
|
||||
permissions: readonly string[],
|
||||
): string | null {
|
||||
return collectMenuPaths(buildMenu(roles, permissions))[0] ?? null;
|
||||
}
|
||||
@@ -11,6 +11,18 @@ describe('permission navigation', () => {
|
||||
expect(findFirstAccessiblePath(permissions)).toBe('/classes');
|
||||
});
|
||||
|
||||
it('lands teachers on the teacher workspace without global student or class access', () => {
|
||||
expect(
|
||||
findFirstAccessiblePath([
|
||||
'teacher-workspace:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
]),
|
||||
).toBe('/teacher-workspace');
|
||||
expect(canAccessPath('/students', ['teacher-workspace:view'])).toBe(false);
|
||||
expect(canAccessPath('/classes', ['teacher-workspace:view'])).toBe(false);
|
||||
});
|
||||
|
||||
it('uses dashboard when it is the first accessible page', () => {
|
||||
expect(findFirstAccessiblePath(['dashboard:view', 'student:view'])).toBe('/dashboard');
|
||||
});
|
||||
|
||||
@@ -13,11 +13,11 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [
|
||||
{ path: '/room-visual', permission: 'room:view' },
|
||||
{ path: '/rooms', permission: 'room:view' },
|
||||
{ path: '/occupancies', permission: 'occupancy:view' },
|
||||
{ path: '/teacher-workspace', permission: 'teacher-workspace:view' },
|
||||
{ path: '/students', permission: 'student:view', matches: (p) => p === '/students' || /^\/students\/\d+\/profile$/.test(p) },
|
||||
{ path: '/classes', permission: 'class:view', matches: (p) => p === '/classes' || /^\/classes\/\d+$/.test(p) },
|
||||
{ path: '/attendance', permission: 'attendance:view' },
|
||||
{ path: '/schedules', permission: 'schedule:view' },
|
||||
{ path: '/teacher-workspace', permission: 'class:view' },
|
||||
{ path: '/classroom-schedule', permission: 'rental:view' },
|
||||
{ path: '/classrooms', permission: 'classroom:view' },
|
||||
{ path: '/classroom-rentals', permission: 'rental:view' },
|
||||
@@ -32,7 +32,7 @@ export const PERMISSION_PAGES: readonly PermissionPage[] = [
|
||||
{ path: '/integration-config', permission: 'integration:read' },
|
||||
{ path: '/ai-config', permission: 'ai:config:read' },
|
||||
{ path: '/users', permission: 'user:view' },
|
||||
{ path: '/teachers', permission: 'user:view' },
|
||||
{ path: '/teachers', permission: 'teacher:view' },
|
||||
] as const;
|
||||
|
||||
function matchesPage(page: PermissionPage, pathname: string): boolean {
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { filterTabsByPermission } from './permission-tabs';
|
||||
|
||||
describe('permission-aware tabs', () => {
|
||||
const tabs = [
|
||||
{ key: 'all', requiredPermission: 'deposit:view' },
|
||||
{ key: 'pending', requiredPermission: 'deposit:approve' },
|
||||
];
|
||||
const tabs = [
|
||||
{ key: 'records', requiredPermission: 'deposit:view' },
|
||||
{ key: 'refund', requiredPermission: 'deposit:refund' },
|
||||
];
|
||||
|
||||
it('hides tabs whose backing API permission is missing', () => {
|
||||
expect(filterTabsByPermission(tabs, ['deposit:view']).map((tab) => tab.key)).toEqual(['all']);
|
||||
});
|
||||
|
||||
it('shows a privileged tab only when its permission is present', () => {
|
||||
describe('permission tabs', () => {
|
||||
it('shows only tabs allowed by exact permissions', () => {
|
||||
expect(filterTabsByPermission(tabs, ['deposit:view']).map((tab) => tab.key)).toEqual([
|
||||
'records',
|
||||
]);
|
||||
expect(
|
||||
filterTabsByPermission(tabs, ['deposit:view', 'deposit:approve']).map((tab) => tab.key),
|
||||
).toEqual(['all', 'pending']);
|
||||
filterTabsByPermission(tabs, ['deposit:view', 'deposit:refund']).map((tab) => tab.key),
|
||||
).toEqual(['records', 'refund']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,18 @@ import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { Result } from 'antd';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
import { findFirstAccessiblePath } from '../auth/permission-navigation';
|
||||
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
||||
|
||||
const DefaultRoute: React.FC = () => {
|
||||
const { permissions } = usePermission();
|
||||
const firstPath = findFirstAccessiblePath(permissions);
|
||||
const roles = (() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('user') || '{}').roles || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
})();
|
||||
const firstPath = findRoleAwareLandingPath(roles, permissions);
|
||||
if (firstPath) return <Navigate to={firstPath} replace />;
|
||||
return <Result status="403" title="暂无可访问功能" subTitle="请联系管理员为当前账号分配功能权限" />;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Result, Button } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { findFirstAccessiblePath } from '../auth/permission-navigation';
|
||||
import { findRoleAwareLandingPath } from '../auth/menu-policy';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
|
||||
interface PermissionRouteProps {
|
||||
@@ -13,7 +13,13 @@ const PermissionRoute: React.FC<PermissionRouteProps> = ({ permission, children
|
||||
const { permissions, hasPermission } = usePermission();
|
||||
const navigate = useNavigate();
|
||||
if (!hasPermission(permission)) {
|
||||
const firstPath = findFirstAccessiblePath(permissions);
|
||||
let roles: string[] = [];
|
||||
try {
|
||||
roles = JSON.parse(localStorage.getItem('user') || '{}').roles || [];
|
||||
} catch {
|
||||
roles = [];
|
||||
}
|
||||
const firstPath = findRoleAwareLandingPath(roles, permissions);
|
||||
return (
|
||||
<Result
|
||||
status="403"
|
||||
|
||||
@@ -32,95 +32,37 @@ import { usePermission } from '../hooks/usePermission';
|
||||
import api from '../api';
|
||||
import { writePermissions } from '../auth/permission-store';
|
||||
import NotificationBell from '../components/NotificationBell';
|
||||
import { buildMenu, type AppMenuItem } from '../auth/menu-policy';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
interface MenuItemType {
|
||||
key: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
permission?: string;
|
||||
children?: MenuItemType[];
|
||||
}
|
||||
|
||||
const allMenuItems: MenuItemType[] = [
|
||||
{
|
||||
key: '/dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
label: '数据面板',
|
||||
permission: 'dashboard:view',
|
||||
},
|
||||
{
|
||||
key: 'dorm-group',
|
||||
icon: <HomeOutlined />,
|
||||
label: '宿舍运营',
|
||||
permission: 'room:view',
|
||||
children: [
|
||||
{ key: '/room-visual', icon: <AppstoreOutlined />, label: '宿舍总览', permission: 'room:view' },
|
||||
{ key: '/rooms', icon: <HomeOutlined />, label: '宿舍管理', permission: 'room:view' },
|
||||
{ key: '/occupancies', icon: <SwapOutlined />, label: '入住管理', permission: 'occupancy:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'student-group',
|
||||
icon: <TeamOutlined />,
|
||||
label: '学员管理',
|
||||
permission: 'student:view',
|
||||
children: [
|
||||
{ key: '/students', icon: <TeamOutlined />, label: '学生管理', permission: 'student:view' },
|
||||
{ key: '/classes', icon: <TeamOutlined />, label: '班级管理', permission: 'class:view' },
|
||||
{ key: '/attendance', icon: <CheckCircleOutlined />, label: '考勤管理', permission: 'attendance:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'academic-group',
|
||||
icon: <CalendarOutlined />,
|
||||
label: '教务管理',
|
||||
permission: 'schedule:view',
|
||||
children: [
|
||||
{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理', permission: 'schedule:view' },
|
||||
{ key: '/teacher-workspace', icon: <LaptopOutlined />, label: '教师工作台', permission: 'class:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'classroom-group',
|
||||
icon: <ReadOutlined />,
|
||||
label: '教室管理',
|
||||
permission: 'classroom:view',
|
||||
children: [
|
||||
{ key: '/classroom-schedule', icon: <CalendarOutlined />, label: '排期总览', permission: 'rental:view' },
|
||||
{ key: '/classrooms', icon: <ReadOutlined />, label: '教室列表', permission: 'classroom:view' },
|
||||
{ key: '/classroom-rentals', icon: <FileProtectOutlined />, label: '租赁订单', permission: 'rental:view' },
|
||||
{ key: '/organizations', icon: <TagsOutlined />, label: '机构管理', permission: 'organization:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'finance-group',
|
||||
icon: <DollarOutlined />,
|
||||
label: '财务管理',
|
||||
permission: 'expense:view',
|
||||
children: [
|
||||
{ key: '/expenses', icon: <DollarOutlined />, label: '费用录入', permission: 'expense:view' },
|
||||
{ key: '/deposits', icon: <WalletOutlined />, label: '押金管理', permission: 'deposit:view' },
|
||||
{ key: '/bills', icon: <FileTextOutlined />, label: '账单管理', permission: 'bill:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system-group',
|
||||
icon: <SettingOutlined />,
|
||||
label: '系统管理',
|
||||
permission: 'log:view',
|
||||
children: [
|
||||
{ key: '/notifications', icon: <BellOutlined />, label: '通知中心', permission: 'notification:view' },
|
||||
{ key: '/operation-logs', icon: <AuditOutlined />, label: '操作日志', permission: 'log:view' },
|
||||
{ key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' },
|
||||
{ key: '/permissions', icon: <KeyOutlined />, label: '权限一览', permission: 'role:view' },
|
||||
{ key: '/integration-config', icon: <ApiOutlined />, label: '钉钉集成配置', permission: 'integration:read' },
|
||||
{ key: '/ai-config', icon: <RobotOutlined />, label: 'AI 模型配置', permission: 'ai:config:read' },
|
||||
{ key: '/users', icon: <SettingOutlined />, label: '账号管理', permission: 'user:view' },
|
||||
],
|
||||
},
|
||||
];
|
||||
const iconMap: Record<string, React.ReactNode> = {
|
||||
dashboard: <DashboardOutlined />,
|
||||
calendar: <CalendarOutlined />,
|
||||
workspace: <LaptopOutlined />,
|
||||
attendance: <CheckCircleOutlined />,
|
||||
academic: <TeamOutlined />,
|
||||
students: <TeamOutlined />,
|
||||
classes: <TeamOutlined />,
|
||||
teachers: <UserOutlined />,
|
||||
home: <HomeOutlined />,
|
||||
overview: <AppstoreOutlined />,
|
||||
occupancy: <SwapOutlined />,
|
||||
expense: <DollarOutlined />,
|
||||
bill: <FileTextOutlined />,
|
||||
deposit: <WalletOutlined />,
|
||||
classroom: <ReadOutlined />,
|
||||
rental: <FileProtectOutlined />,
|
||||
organization: <TagsOutlined />,
|
||||
settings: <SettingOutlined />,
|
||||
users: <UserOutlined />,
|
||||
role: <SafetyOutlined />,
|
||||
permission: <KeyOutlined />,
|
||||
log: <AuditOutlined />,
|
||||
integration: <ApiOutlined />,
|
||||
ai: <RobotOutlined />,
|
||||
notification: <BellOutlined />,
|
||||
};
|
||||
|
||||
const MainLayout: React.FC = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
@@ -129,8 +71,10 @@ const MainLayout: React.FC = () => {
|
||||
const prevPathname = useRef('');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useMemo(() => JSON.parse(localStorage.getItem('user') || '{}'), []);
|
||||
const { hasPermission } = usePermission();
|
||||
const [user, setUser] = useState<{ name?: string; username?: string; roles?: string[] }>(() =>
|
||||
JSON.parse(localStorage.getItem('user') || '{}'),
|
||||
);
|
||||
const { permissions, hasPermission } = usePermission();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -139,7 +83,9 @@ const MainLayout: React.FC = () => {
|
||||
if (cancelled) return;
|
||||
writePermissions(profile.permissions || []);
|
||||
const cachedUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
localStorage.setItem('user', JSON.stringify({ ...cachedUser, ...profile }));
|
||||
const nextUser = { ...cachedUser, ...profile };
|
||||
localStorage.setItem('user', JSON.stringify(nextUser));
|
||||
setUser(nextUser);
|
||||
})
|
||||
.catch(() => {
|
||||
// The API interceptor handles expired/invalid sessions.
|
||||
@@ -152,22 +98,10 @@ const MainLayout: React.FC = () => {
|
||||
const isTablet = (screens.sm || screens.md) && !screens.lg; // 576-991px
|
||||
const isDesktop = !!screens.lg; // >= 992px
|
||||
|
||||
// 按 permission 过滤菜单
|
||||
const filterByPermission = (items: MenuItemType[]): MenuItemType[] => {
|
||||
return items
|
||||
.map((item) => {
|
||||
if (item.children) {
|
||||
const kids = filterByPermission(item.children);
|
||||
if (kids.length === 0) return null;
|
||||
return { ...item, children: kids };
|
||||
}
|
||||
if (!item.permission) return item;
|
||||
return hasPermission(item.permission) ? item : null;
|
||||
})
|
||||
.filter(Boolean) as MenuItemType[];
|
||||
};
|
||||
|
||||
const menuItems = useMemo(() => filterByPermission(allMenuItems), [hasPermission]);
|
||||
const menuItems = useMemo(
|
||||
() => buildMenu(user.roles ?? [], permissions),
|
||||
[user.roles, permissions],
|
||||
);
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
localStorage.removeItem('token');
|
||||
@@ -181,7 +115,7 @@ const MainLayout: React.FC = () => {
|
||||
if (isMobile) setDrawerOpen(false);
|
||||
}, [navigate, isMobile]);
|
||||
|
||||
const findSelectedKeys = (items: MenuItemType[], pathname: string): string[] => {
|
||||
const findSelectedKeys = (items: AppMenuItem[], pathname: string): string[] => {
|
||||
for (const item of items) {
|
||||
if (item.key === pathname) return [item.key];
|
||||
if (item.children) {
|
||||
@@ -192,7 +126,7 @@ const MainLayout: React.FC = () => {
|
||||
return [pathname];
|
||||
};
|
||||
|
||||
const findOpenKeys = (items: MenuItemType[], pathname: string): string[] => {
|
||||
const findOpenKeys = (items: AppMenuItem[], pathname: string): string[] => {
|
||||
for (const item of items) {
|
||||
if (item.children) {
|
||||
if (item.children.some((c) => c.key === pathname || (c.children && c.children.some((gc) => gc.key === pathname)))) {
|
||||
@@ -220,10 +154,10 @@ const MainLayout: React.FC = () => {
|
||||
|
||||
|
||||
|
||||
const transformToMenuItems = (items: MenuItemType[]): any[] => {
|
||||
const transformToMenuItems = (items: AppMenuItem[]): any[] => {
|
||||
return items.map((item) => ({
|
||||
key: item.key,
|
||||
icon: item.icon,
|
||||
icon: item.icon ? iconMap[item.icon] : undefined,
|
||||
label: item.label,
|
||||
children: item.children ? transformToMenuItems(item.children) : undefined,
|
||||
}));
|
||||
|
||||
@@ -69,10 +69,10 @@ describe('AiConfig helpers', () => {
|
||||
});
|
||||
|
||||
describe('extractErrorMessage', () => {
|
||||
it('extracts axios-style response error message', () => {
|
||||
const err = {
|
||||
response: { data: { message: 'API出错' } },
|
||||
};
|
||||
it('extracts message from server error response (interceptor unwraps to { message })', () => {
|
||||
// The Axios interceptor at api/index.ts does Promise.reject(err.response?.data || err).
|
||||
// For server errors, the rejection value is err.response.data — typically { message: '...' }.
|
||||
const err = { message: 'API出错' };
|
||||
expect(extractErrorMessage(err)).toBe('API出错');
|
||||
});
|
||||
|
||||
|
||||
@@ -60,23 +60,17 @@ export function shouldAutoSwapBaseUrl(
|
||||
return { baseUrl: currentBaseUrl, shouldSwap: false };
|
||||
}
|
||||
|
||||
/** Extract a safe user-facing error message from any caught value */
|
||||
/** Extract a safe user-facing error message from any caught value.
|
||||
*
|
||||
* The Axios interceptor at `api/index.ts` unwraps errors before rejection:
|
||||
* `Promise.reject(err.response?.data || err)`. So server errors arrive as
|
||||
* `{ message: '...' }` (the unwrapped data) and network errors as the raw
|
||||
* `Error` object — never as a raw AxiosError with a `.response` property. */
|
||||
export function extractErrorMessage(err: unknown, fallback: string = '操作失败'): string {
|
||||
let msg = '';
|
||||
|
||||
// axios-style error: { response: { data: { message: string } } }
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const resp: unknown = err.response;
|
||||
if (resp && typeof resp === 'object' && 'data' in resp) {
|
||||
const data: unknown = resp.data;
|
||||
if (data && typeof data === 'object' && 'message' in data && typeof data.message === 'string') {
|
||||
msg = data.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// standard Error or any object with a string message property
|
||||
if (!msg && err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||
msg = err.message;
|
||||
}
|
||||
|
||||
|
||||
@@ -173,9 +173,17 @@ const AiConfigPage: React.FC = () => {
|
||||
await api.put('/ai/config', body);
|
||||
message.success('配置已保存');
|
||||
form.setFieldValue('apiKey', '');
|
||||
await loadConfig();
|
||||
} catch (err: unknown) {
|
||||
message.error(extractErrorMessage(err, '保存失败'));
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload config from server (non-fatal if it fails)
|
||||
try {
|
||||
await loadConfig();
|
||||
} catch (err: unknown) {
|
||||
message.warning(extractErrorMessage(err, '配置已保存,但刷新失败'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -186,7 +194,7 @@ const AiConfigPage: React.FC = () => {
|
||||
const handleTest = useCallback(async () => {
|
||||
try {
|
||||
// Validated fields: compatible requires baseUrl
|
||||
const fieldsToValidate = ['timeoutMs'] as string[];
|
||||
const fieldsToValidate = ['provider', 'timeoutMs'] as string[];
|
||||
if (currentProvider === 'OPENAI_COMPATIBLE') {
|
||||
fieldsToValidate.push('baseUrl');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
canPullAttendance,
|
||||
getAttendanceExperience,
|
||||
getSchedulePhase,
|
||||
summarizeAttendance,
|
||||
} from './attendance-workspace';
|
||||
|
||||
describe('attendance role experience', () => {
|
||||
it('routes class-scoped teachers to the teaching workspace', () => {
|
||||
expect(
|
||||
getAttendanceExperience(
|
||||
['attendance:view', 'attendance:create', 'schedule:create'],
|
||||
['老师'],
|
||||
),
|
||||
).toBe('teacher');
|
||||
});
|
||||
|
||||
it('routes users with attendance administration permission to history management', () => {
|
||||
expect(getAttendanceExperience(['attendance:view', 'attendance:edit'], ['教务管理员'])).toBe(
|
||||
'admin',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('teacher schedule phase', () => {
|
||||
it('marks a finished lesson as ready for attendance review', () => {
|
||||
expect(getSchedulePhase('08:00', '09:00', new Date('2026-07-11T10:00:00'))).toBe('ended');
|
||||
});
|
||||
|
||||
it('keeps future lessons read-only', () => {
|
||||
expect(getSchedulePhase('14:00', '15:00', new Date('2026-07-11T10:00:00'))).toBe('upcoming');
|
||||
});
|
||||
|
||||
it('allows attendance pulls once the lesson starts', () => {
|
||||
expect(canPullAttendance('ongoing')).toBe(true);
|
||||
expect(canPullAttendance('ended')).toBe(true);
|
||||
expect(canPullAttendance('upcoming')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attendance summary', () => {
|
||||
it('summarizes status counts for an administrator history view', () => {
|
||||
expect(
|
||||
summarizeAttendance([
|
||||
{ status: 'present' },
|
||||
{ status: 'present' },
|
||||
{ status: 'late' },
|
||||
{ status: 'absent' },
|
||||
]),
|
||||
).toEqual({ total: 4, present: 2, late: 1, absent: 1, leave: 0, pending: 0 });
|
||||
});
|
||||
});
|
||||
65
apps/admin/src/pages/Attendance/attendance-workspace.ts
Normal file
65
apps/admin/src/pages/Attendance/attendance-workspace.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export type AttendanceExperience = 'teacher' | 'admin';
|
||||
export type SchedulePhase = 'upcoming' | 'ongoing' | 'ended';
|
||||
|
||||
import { getRoleDomains } from '../../auth/menu-policy';
|
||||
|
||||
export function getAttendanceExperience(
|
||||
permissions: readonly string[],
|
||||
roles: readonly string[],
|
||||
): AttendanceExperience {
|
||||
const domains = getRoleDomains(roles, permissions);
|
||||
if (
|
||||
permissions.includes('attendance:manage') ||
|
||||
domains.has('academic') ||
|
||||
domains.has('super')
|
||||
) {
|
||||
return 'admin';
|
||||
}
|
||||
return 'teacher';
|
||||
}
|
||||
|
||||
function toMinuteOfDay(time: string): number {
|
||||
const [hour = 0, minute = 0] = time.split(':').map(Number);
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
export function getSchedulePhase(
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
now = new Date(),
|
||||
): SchedulePhase {
|
||||
const current = now.getHours() * 60 + now.getMinutes();
|
||||
if (current < toMinuteOfDay(startTime)) return 'upcoming';
|
||||
if (current <= toMinuteOfDay(endTime)) return 'ongoing';
|
||||
return 'ended';
|
||||
}
|
||||
|
||||
export function canPullAttendance(phase: SchedulePhase): boolean {
|
||||
return phase !== 'upcoming';
|
||||
}
|
||||
|
||||
export interface AttendanceSummary {
|
||||
total: number;
|
||||
present: number;
|
||||
late: number;
|
||||
absent: number;
|
||||
leave: number;
|
||||
pending: number;
|
||||
}
|
||||
|
||||
export function summarizeAttendance(records: readonly { status: string }[]): AttendanceSummary {
|
||||
const summary: AttendanceSummary = {
|
||||
total: records.length,
|
||||
present: 0,
|
||||
late: 0,
|
||||
absent: 0,
|
||||
leave: 0,
|
||||
pending: 0,
|
||||
};
|
||||
for (const record of records) {
|
||||
if (record.status in summary && record.status !== 'total') {
|
||||
summary[record.status as Exclude<keyof AttendanceSummary, 'total'>] += 1;
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
504
apps/admin/src/pages/Attendance/attendance.css
Normal file
504
apps/admin/src/pages/Attendance/attendance.css
Normal file
@@ -0,0 +1,504 @@
|
||||
.attendance-page {
|
||||
--ink: #172033;
|
||||
--muted: #667085;
|
||||
--line: #e6eaf0;
|
||||
--blue: #1677ff;
|
||||
color: var(--ink);
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
|
||||
.attendance-hero {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
min-height: 188px;
|
||||
padding: 34px 38px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.attendance-hero::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
right: -105px;
|
||||
top: -165px;
|
||||
border: 64px solid rgb(255 255 255 / 8%);
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.attendance-hero--teacher {
|
||||
color: white;
|
||||
background: linear-gradient(125deg, #122c5a 0%, #174c93 58%, #1e78c8 100%);
|
||||
box-shadow: 0 14px 34px rgb(24 76 147 / 18%);
|
||||
}
|
||||
|
||||
.attendance-hero--admin {
|
||||
color: #172033;
|
||||
background: linear-gradient(120deg, #f7f9fc 0%, #eef3f9 100%);
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.attendance-hero h1 {
|
||||
margin: 7px 0 8px;
|
||||
font-size: clamp(28px, 3vw, 40px);
|
||||
line-height: 1.15;
|
||||
letter-spacing: -1.2px;
|
||||
}
|
||||
|
||||
.attendance-hero p {
|
||||
max-width: 650px;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
opacity: 0.72;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.attendance-eyebrow {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.7px;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.teacher-overview {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.teacher-kpi {
|
||||
height: 112px;
|
||||
padding: 21px 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: white;
|
||||
box-shadow: 0 5px 16px rgb(19 33 68 / 5%);
|
||||
}
|
||||
|
||||
.teacher-kpi > span,
|
||||
.teacher-kpi > small {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.teacher-kpi > strong {
|
||||
display: inline-block;
|
||||
margin: 6px 7px 0 0;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.teacher-kpi--next {
|
||||
border-color: #cfe1fb;
|
||||
background: #f4f8ff;
|
||||
}
|
||||
|
||||
.attendance-section-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
margin: 0 2px 14px;
|
||||
}
|
||||
|
||||
.attendance-section-heading span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.attendance-section-heading h2 {
|
||||
margin: 2px 0 0;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.lesson-timeline {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.lesson-card {
|
||||
display: grid;
|
||||
grid-template-columns: 54px 120px minmax(220px, 1fr) auto;
|
||||
align-items: center;
|
||||
min-height: 116px;
|
||||
padding: 18px 20px 18px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-left: 4px solid #c9d2df;
|
||||
border-radius: 14px;
|
||||
background: white;
|
||||
transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease;
|
||||
}
|
||||
|
||||
.lesson-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 10px 26px rgb(24 39 75 / 9%);
|
||||
}
|
||||
|
||||
.lesson-card--ongoing {
|
||||
border-left-color: #1677ff;
|
||||
background: linear-gradient(90deg, #f6f9ff, #fff 32%);
|
||||
}
|
||||
|
||||
.lesson-card--ended {
|
||||
border-left-color: #32a46d;
|
||||
}
|
||||
|
||||
.lesson-sequence {
|
||||
align-self: start;
|
||||
padding-top: 3px;
|
||||
color: #a6b0bf;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.lesson-time {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1px auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-right: 22px;
|
||||
}
|
||||
|
||||
.lesson-time strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.lesson-time span {
|
||||
height: 28px;
|
||||
background: #d9e0e9;
|
||||
}
|
||||
|
||||
.lesson-main {
|
||||
padding-left: 24px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.lesson-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lesson-title-row h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.lesson-main p {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lesson-action {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.attendance-empty-card {
|
||||
padding: 28px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.attendance-empty-card strong {
|
||||
display: block;
|
||||
color: var(--ink);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.attendance-empty-card p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lesson-record-header {
|
||||
padding: 8px 0 22px;
|
||||
}
|
||||
|
||||
.lesson-record-header h2 {
|
||||
margin: 6px 0;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.lesson-record-header p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.attendance-summary-strip {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1.35fr) repeat(4, minmax(100px, 1fr));
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
min-height: 106px;
|
||||
margin-bottom: 18px;
|
||||
padding: 16px 8px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: white;
|
||||
box-shadow: 0 5px 18px rgb(23 32 51 / 4%);
|
||||
}
|
||||
|
||||
.attendance-rate,
|
||||
.attendance-summary-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 66px;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.attendance-rate {
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.attendance-rate span,
|
||||
.attendance-summary-cell span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.attendance-rate strong,
|
||||
.attendance-summary-cell strong {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.attendance-summary-icon {
|
||||
display: grid !important;
|
||||
flex: 0 0 auto;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.is-present { color: #198754 !important; background: #eaf8f1; }
|
||||
.is-late { color: #b56b00 !important; background: #fff4db; }
|
||||
.is-absent { color: #cf3030 !important; background: #fff0f0; }
|
||||
.is-leave { color: #2874c6 !important; background: #edf5ff; }
|
||||
.is-pending { color: #667085 !important; background: #f1f3f6; }
|
||||
|
||||
.attendance-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 4px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.attendance-status__dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.archive-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 13px 16px;
|
||||
color: #8c5d05;
|
||||
border: 1px solid #f3d79c;
|
||||
border-radius: 12px;
|
||||
background: #fffbf0;
|
||||
}
|
||||
|
||||
.archive-alert > span.anticon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.archive-alert div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.archive-alert strong,
|
||||
.archive-alert span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.archive-alert span {
|
||||
margin-top: 2px;
|
||||
color: #8a7452;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.archive-card {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 6px 24px rgb(23 32 51 / 5%);
|
||||
}
|
||||
|
||||
.archive-card .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.archive-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 18px 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: #fbfcfe;
|
||||
}
|
||||
|
||||
.archive-toolbar__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.archive-toolbar__title > span.anticon {
|
||||
color: var(--blue);
|
||||
font-size: 21px;
|
||||
}
|
||||
|
||||
.archive-toolbar__title strong,
|
||||
.archive-toolbar__title span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.archive-toolbar__title span {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.archive-card .ant-table-wrapper {
|
||||
padding: 0 20px 10px;
|
||||
}
|
||||
|
||||
.archive-card .ant-table-thead > tr > th {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.student-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.student-cell .ant-avatar {
|
||||
color: #245b9e;
|
||||
background: #e8f1fd;
|
||||
}
|
||||
|
||||
.student-cell strong,
|
||||
.student-cell span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.student-cell span {
|
||||
margin-top: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.muted-text {
|
||||
color: #a1a9b5;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.attendance-hero,
|
||||
.archive-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.attendance-summary-strip {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.attendance-rate {
|
||||
grid-column: 1 / -1;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.lesson-card {
|
||||
grid-template-columns: 42px 1fr auto;
|
||||
}
|
||||
|
||||
.lesson-time {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.lesson-main {
|
||||
grid-column: 2 / -1;
|
||||
margin-top: 12px;
|
||||
padding: 12px 0 0;
|
||||
border-top: 1px solid var(--line);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.lesson-action {
|
||||
grid-column: 2 / -1;
|
||||
padding: 14px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.attendance-hero {
|
||||
padding: 26px 22px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.attendance-hero h1 {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.attendance-summary-strip {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.attendance-summary-cell {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.lesson-card {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.lesson-sequence {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lesson-time,
|
||||
.lesson-main,
|
||||
.lesson-action {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.lesson-main p {
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.attendance-marking-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.attendance-marking-actions .ant-btn {
|
||||
min-width: 54px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,18 +10,16 @@ import {
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Tabs,
|
||||
List,
|
||||
Card,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, DollarOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
paid: { text: '已缴', color: 'green' },
|
||||
@@ -31,8 +29,8 @@ const statusMap: Record<string, { text: string; color: string }> = {
|
||||
};
|
||||
|
||||
const refundStatusMap: Record<string, { text: string; color: string }> = {
|
||||
pending: { text: '待班主任审批', color: 'orange' },
|
||||
head_teacher_approved: { text: '待财务审批', color: 'blue' },
|
||||
pending: { text: '历史退款处理中', color: 'orange' },
|
||||
head_teacher_approved: { text: '历史退款处理中', color: 'blue' },
|
||||
finance_approved: { text: '已退款', color: 'green' },
|
||||
refunded: { text: '已退款', color: 'green' },
|
||||
};
|
||||
@@ -42,17 +40,8 @@ const installmentStatusMap: Record<string, { text: string; color: string }> = {
|
||||
paid: { text: '已缴', color: 'green' },
|
||||
};
|
||||
|
||||
interface PendingRefund {
|
||||
id: number;
|
||||
student?: { name?: string };
|
||||
amount?: number;
|
||||
paidDate?: string;
|
||||
refundStatus?: string;
|
||||
refundRequestedAt?: string;
|
||||
}
|
||||
|
||||
const DepositsPage: React.FC = () => {
|
||||
const { hasPermission } = usePermission();
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [students, setStudents] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -60,16 +49,11 @@ const DepositsPage: React.FC = () => {
|
||||
const [refundModal, setRefundModal] = useState<any>(null);
|
||||
const [detailModal, setDetailModal] = useState<any>(null);
|
||||
const [installmentModal, setInstallmentModal] = useState<number | null>(null);
|
||||
const [pendingRefunds, setPendingRefunds] = useState<PendingRefund[]>([]);
|
||||
const [pendingLoading, setPendingLoading] = useState(false);
|
||||
const [rejectModal, setRejectModal] = useState<any>(null);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [createForm] = Form.useForm();
|
||||
const [refundForm] = Form.useForm();
|
||||
const [installmentForm] = Form.useForm();
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
const [activeTab, setActiveTab] = useState<string>('all');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
@@ -77,7 +61,7 @@ const DepositsPage: React.FC = () => {
|
||||
try {
|
||||
const [d, s]: any[] = await Promise.all([
|
||||
api.get('/deposits'),
|
||||
hasPermission('deposit:create') ? api.get('/deposits/student-lookups') : Promise.resolve([]),
|
||||
api.get('/deposits/student-lookups'),
|
||||
]);
|
||||
setData(d);
|
||||
setStudents(s);
|
||||
@@ -87,20 +71,11 @@ const DepositsPage: React.FC = () => {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchPendingRefunds = async () => {
|
||||
setPendingLoading(true);
|
||||
try {
|
||||
const res = await api.get<PendingRefund[]>('/deposits/pending-refunds');
|
||||
setPendingRefunds(res || []);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setPendingLoading(false);
|
||||
};
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [hasPermission]);
|
||||
}, []);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
return data.filter((d: any) => {
|
||||
@@ -166,44 +141,6 @@ const DepositsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestRefund = async (record: any) => {
|
||||
try {
|
||||
await api.post(`/deposits/${record.id}/request-refund`);
|
||||
message.success('退款申请已提交');
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveRefund = async (record: any) => {
|
||||
try {
|
||||
await api.put(`/deposits/${record.id}/approve-refund`);
|
||||
message.success('审批通过');
|
||||
fetchPendingRefunds();
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleRejectRefund = async () => {
|
||||
if (!rejectModal) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.put(`/deposits/${rejectModal.id}/reject-refund`, { reason: rejectReason || '未说明原因' });
|
||||
message.success('已驳回退款申请');
|
||||
setRejectModal(null);
|
||||
setRejectReason('');
|
||||
fetchPendingRefunds();
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '驳回失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const handleAddInstallment = async () => {
|
||||
if (installmentModal == null) return;
|
||||
const values = await installmentForm.validateFields();
|
||||
@@ -254,7 +191,7 @@ const DepositsPage: React.FC = () => {
|
||||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '退款审批',
|
||||
title: '退款状态',
|
||||
dataIndex: 'refundStatus',
|
||||
render: (s: string) =>
|
||||
s ? <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag> : '-',
|
||||
@@ -289,7 +226,7 @@ const DepositsPage: React.FC = () => {
|
||||
{record.status === 'paid' && !record.refundStatus && (
|
||||
<>
|
||||
<PermissionButton
|
||||
permission="deposit:edit"
|
||||
permission="deposit:refund"
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
@@ -299,13 +236,6 @@ const DepositsPage: React.FC = () => {
|
||||
>
|
||||
退还
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="deposit:edit"
|
||||
size="small"
|
||||
onClick={() => handleRequestRefund(record)}
|
||||
>
|
||||
申请退款
|
||||
</PermissionButton>
|
||||
</>
|
||||
)}
|
||||
<Popconfirm
|
||||
@@ -332,141 +262,67 @@ const DepositsPage: React.FC = () => {
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [handleRequestRefund, fetchData]);
|
||||
], [fetchData]);
|
||||
|
||||
|
||||
const pendingColumns = [
|
||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{ title: '押金金额', dataIndex: 'amount', width: 110, render: (v: number) => `¥${Number(v).toFixed(2)}` },
|
||||
{ title: '缴纳日期', dataIndex: 'paidDate', width: 110 },
|
||||
{
|
||||
title: '审批状态', width: 120,
|
||||
dataIndex: 'refundStatus',
|
||||
render: (s: string) => <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '申请时间', width: 160,
|
||||
dataIndex: 'refundRequestedAt',
|
||||
render: (v: any) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作', width: 200,
|
||||
render: (_: unknown, record: PendingRefund) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="deposit:approve"
|
||||
size="small"
|
||||
type="primary"
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => handleApproveRefund(record)}
|
||||
>
|
||||
审批通过
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="deposit:approve"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
setRejectModal(record);
|
||||
setRejectReason('');
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={(key) => {
|
||||
setActiveTab(key);
|
||||
if (key === 'pending') fetchPendingRefunds();
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索学生姓名"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
onSearch={(v) => setSearchText(v)}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) setSearchText('');
|
||||
}}
|
||||
items={[
|
||||
{
|
||||
key: 'all',
|
||||
label: '押金列表',
|
||||
children: (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索学生姓名"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
onSearch={(v) => setSearchText(v)}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) setSearchText('');
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态筛选"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={filterStatus}
|
||||
onChange={(v) => setFilterStatus(v)}
|
||||
options={[
|
||||
{ value: 'paid', label: '已缴' },
|
||||
{ value: 'refunded', label: '已全退' },
|
||||
{ value: 'partial_refund', label: '部分退还' },
|
||||
{ value: 'deducted', label: '已全扣' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<PermissionButton
|
||||
permission="deposit:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
|
||||
setCreateModal(true);
|
||||
}}
|
||||
>
|
||||
收取押金
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
...(hasPermission('deposit:approve')
|
||||
? [{
|
||||
key: 'pending',
|
||||
label: '待审批退款',
|
||||
children: (
|
||||
<Table
|
||||
columns={pendingColumns}
|
||||
dataSource={pendingRefunds}
|
||||
rowKey="id"
|
||||
loading={pendingLoading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
/>
|
||||
),
|
||||
}]
|
||||
: []),
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态筛选"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={filterStatus}
|
||||
onChange={(v) => setFilterStatus(v)}
|
||||
options={[
|
||||
{ value: 'paid', label: '已缴' },
|
||||
{ value: 'refunded', label: '已全退' },
|
||||
{ value: 'partial_refund', label: '部分退还' },
|
||||
{ value: 'deducted', label: '已全扣' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<PermissionButton
|
||||
permission="deposit:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
createForm.resetFields();
|
||||
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
|
||||
setCreateModal(true);
|
||||
}}
|
||||
>
|
||||
收取押金
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
/>
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal
|
||||
@@ -556,7 +412,7 @@ const DepositsPage: React.FC = () => {
|
||||
</p>
|
||||
{detailModal.refundStatus && (
|
||||
<p>
|
||||
<strong>退款审批:</strong>{' '}
|
||||
<strong>退款状态:</strong>{' '}
|
||||
<Tag color={refundStatusMap[detailModal.refundStatus]?.color}>
|
||||
{refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus}
|
||||
</Tag>
|
||||
@@ -644,23 +500,7 @@ const DepositsPage: React.FC = () => {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{/* Reject Refund Modal */}
|
||||
<Modal
|
||||
title={`驳回退款申请 - ${rejectModal?.student?.name || ''}`}
|
||||
open={!!rejectModal}
|
||||
onOk={handleRejectRefund}
|
||||
onCancel={() => { setRejectModal(null); setRejectReason(''); }}
|
||||
okText="确认驳回"
|
||||
okButtonProps={{ danger: true }}
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Input.TextArea
|
||||
aria-label="驳回原因"
|
||||
value={rejectReason}
|
||||
onChange={(e) => setRejectReason(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
SyncOutlined, BankOutlined, UserOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import type { TreeSelectProps } from 'antd/es/tree-select';
|
||||
@@ -64,6 +65,28 @@ interface ImportResult {
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
interface DingTalkAttendanceGroup {
|
||||
group_id: number;
|
||||
group_name: string;
|
||||
type: string;
|
||||
member_count: number;
|
||||
}
|
||||
|
||||
interface AttendanceGroupResponse {
|
||||
success: boolean;
|
||||
data: DingTalkAttendanceGroup[];
|
||||
}
|
||||
|
||||
interface DeleteAttendanceGroupsResponse {
|
||||
success: boolean;
|
||||
data: {
|
||||
total: number;
|
||||
deleted: Array<{ groupId: number; groupName: string }>;
|
||||
failed: Array<{ groupId: number; groupName: string; error: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const IntegrationConfigPage: React.FC = () => {
|
||||
const { hasAllPermissions } = usePermission();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -85,6 +108,10 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [classForm] = Form.useForm();
|
||||
const [classModalOpen, setClassModalOpen] = useState(false);
|
||||
const [attendanceGroups, setAttendanceGroups] = useState<DingTalkAttendanceGroup[]>([]);
|
||||
const [deleteGroupsOpen, setDeleteGroupsOpen] = useState(false);
|
||||
const [loadingGroups, setLoadingGroups] = useState(false);
|
||||
const [deletingGroups, setDeletingGroups] = useState(false);
|
||||
|
||||
|
||||
const fetchConfig = async () => {
|
||||
@@ -194,6 +221,7 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
|
||||
return nodes.map((node) => {
|
||||
const users = node.users ?? [];
|
||||
@@ -282,6 +310,41 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openDeleteAllGroups = async () => {
|
||||
setLoadingGroups(true);
|
||||
try {
|
||||
const response = await api.get<AttendanceGroupResponse>('/sync/dingtalk/attendance-groups');
|
||||
setAttendanceGroups(response.data);
|
||||
setDeleteGroupsOpen(true);
|
||||
} catch (error: unknown) {
|
||||
message.error(error instanceof Error ? error.message : '获取钉钉考勤组失败');
|
||||
} finally {
|
||||
setLoadingGroups(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAllGroups = async () => {
|
||||
setDeletingGroups(true);
|
||||
try {
|
||||
const response = await api.post<DeleteAttendanceGroupsResponse>(
|
||||
'/sync/dingtalk/attendance-groups/delete-all',
|
||||
);
|
||||
setDeleteGroupsOpen(false);
|
||||
setAttendanceGroups([]);
|
||||
if (response.data.failed.length > 0) {
|
||||
message.warning(
|
||||
`已删除 ${response.data.deleted.length} 个,失败 ${response.data.failed.length} 个`,
|
||||
);
|
||||
} else {
|
||||
message.success(`已删除钉钉全部 ${response.data.deleted.length} 个考勤组`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
message.error(error instanceof Error ? error.message : '删除钉钉考勤组失败');
|
||||
} finally {
|
||||
setDeletingGroups(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncTabItems = config && hasAllPermissions('sync:read', 'class:view', 'class:edit')
|
||||
? [
|
||||
{
|
||||
@@ -314,6 +377,15 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
>
|
||||
获取组织架构
|
||||
</Button>
|
||||
<PermissionButton
|
||||
permission="sync:trigger"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
loading={loadingGroups}
|
||||
onClick={openDeleteAllGroups}
|
||||
>
|
||||
删除钉钉全部考勤组
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
|
||||
{drawerOpen && (
|
||||
@@ -414,6 +486,38 @@ const IntegrationConfigPage: React.FC = () => {
|
||||
</Modal>
|
||||
</Drawer>
|
||||
)}
|
||||
<Modal
|
||||
title="确认删除钉钉全部考勤组"
|
||||
open={deleteGroupsOpen}
|
||||
okText="确认全部删除"
|
||||
okButtonProps={{ danger: true, disabled: attendanceGroups.length === 0 }}
|
||||
cancelText="取消"
|
||||
confirmLoading={deletingGroups}
|
||||
onOk={deleteAllGroups}
|
||||
onCancel={() => setDeleteGroupsOpen(false)}
|
||||
>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={`将永久删除钉钉上的 ${attendanceGroups.length} 个考勤组`}
|
||||
description="本地班级和排课不会删除。删除后需在排课管理中重新同步,才能重建考勤组。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<List
|
||||
size="small"
|
||||
bordered
|
||||
dataSource={attendanceGroups}
|
||||
style={{ maxHeight: 280, overflow: 'auto' }}
|
||||
renderItem={(group) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={group.group_name}
|
||||
description={`ID ${group.group_id} · ${group.member_count} 人`}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -5,7 +5,7 @@ import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { writePermissions } from '../../auth/permission-store';
|
||||
import { findFirstAccessiblePath } from '../../auth/permission-navigation';
|
||||
import { findRoleAwareLandingPath } from '../../auth/menu-policy';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -22,7 +22,7 @@ const LoginPage: React.FC = () => {
|
||||
const permissions = res.user.permissions || [];
|
||||
writePermissions(permissions);
|
||||
message.success('登录成功');
|
||||
navigate(findFirstAccessiblePath(permissions) || '/', { replace: true });
|
||||
navigate(findRoleAwareLandingPath(res.user.roles || [], permissions) || '/', { replace: true });
|
||||
} catch (err: any) {
|
||||
message.error(err?.message || '登录失败');
|
||||
} finally {
|
||||
|
||||
@@ -65,7 +65,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
try {
|
||||
const [occRes, stuRes, rmRes, tnRes] = (await Promise.allSettled([
|
||||
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('/students/basic-lookups'),
|
||||
api.get('/rooms/overview'),
|
||||
api.get('/organizations'),
|
||||
])) as PromiseSettledResult<any>[];
|
||||
|
||||
@@ -19,6 +19,10 @@ const PermissionsPage: React.FC = () => {
|
||||
const groupNames: Record<string, string> = {
|
||||
dashboard: '数据面板',
|
||||
student: '学生管理',
|
||||
'student-scope': '学生数据范围',
|
||||
teacher: '教师管理',
|
||||
'teacher-workspace': '教师工作台',
|
||||
'attendance-scope': '考勤数据范围',
|
||||
room: '宿舍管理',
|
||||
occupancy: '入住管理',
|
||||
expense: '费用管理',
|
||||
|
||||
@@ -41,12 +41,14 @@ import {
|
||||
scheduleToFormValues,
|
||||
type ScheduleFormValues,
|
||||
} from './schedule-form';
|
||||
import { filterSchedulesForClass, isMaskedSchedule } from './schedule-visibility';
|
||||
import { classifySyncResult } from './sync-result';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
interface ClassScheduleItem {
|
||||
id: number;
|
||||
classId: number;
|
||||
id: number | null;
|
||||
classId: number | null;
|
||||
classroomId: number;
|
||||
weekDay: number;
|
||||
startTime: string;
|
||||
@@ -60,6 +62,7 @@ interface ClassScheduleItem {
|
||||
notes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
canViewDetails?: boolean;
|
||||
}
|
||||
|
||||
interface ClassroomItem {
|
||||
@@ -91,6 +94,9 @@ interface ScheduleSyncResult {
|
||||
groupCount: number;
|
||||
syncedItems: number;
|
||||
skippedNoMapping: number;
|
||||
failedBatchCount: number;
|
||||
failedItems: number;
|
||||
errors: string[];
|
||||
groups: Array<{ className: string; groupId: number; itemCount: number }>;
|
||||
}
|
||||
|
||||
@@ -144,6 +150,9 @@ const SchedulesPage: React.FC = () => {
|
||||
groupCount: number;
|
||||
syncedItems: number;
|
||||
skippedNoMapping: number;
|
||||
failedBatchCount: number;
|
||||
failedItems: number;
|
||||
errors: string[];
|
||||
groups: Array<{ className: string; groupId: number; itemCount: number }>;
|
||||
} | null>(null);
|
||||
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
|
||||
@@ -180,7 +189,14 @@ const SchedulesPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
setSyncResult(res.data);
|
||||
message.success(`同步完成:${res.data.syncedItems} 条排班已写入钉钉`);
|
||||
const classification = classifySyncResult(res.data);
|
||||
if (classification.level === 'error') {
|
||||
message.error(classification.message);
|
||||
} else if (classification.level === 'warning') {
|
||||
message.warning(classification.message);
|
||||
} else {
|
||||
message.success(classification.message);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '同步失败');
|
||||
@@ -238,7 +254,10 @@ const SchedulesPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [lookups, schedulesRes] = await Promise.all([
|
||||
api.get('/class-schedules/lookups') as Promise<{ classrooms: ClassroomItem[]; classes: ClassItem[] }>,
|
||||
api.get('/class-schedules/lookups') as Promise<{
|
||||
classrooms: ClassroomItem[];
|
||||
classes: ClassItem[];
|
||||
}>,
|
||||
api.get('/class-schedules/weekly', {
|
||||
params: {
|
||||
startDate: startDateStr,
|
||||
@@ -289,7 +308,7 @@ const SchedulesPage: React.FC = () => {
|
||||
const classroomId = Number(cId);
|
||||
filtered[classroomId] = {};
|
||||
for (const [wd, schedules] of Object.entries(dayMap)) {
|
||||
const matched = schedules.filter((s) => s.classId === filterClassId);
|
||||
const matched = filterSchedulesForClass(schedules, filterClassId);
|
||||
if (matched.length > 0) {
|
||||
filtered[classroomId][Number(wd)] = matched;
|
||||
}
|
||||
@@ -422,19 +441,22 @@ const SchedulesPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const openEditSchedule = (schedule: ClassScheduleItem) => {
|
||||
if (isMaskedSchedule(schedule) || schedule.id === null || schedule.classId === null) return;
|
||||
if (schedule.scheduleType === 'RENTAL') {
|
||||
message.warning('租赁排课请在租赁订单中修改');
|
||||
return;
|
||||
}
|
||||
const editableSchedule = { ...schedule, id: schedule.id, classId: schedule.classId };
|
||||
setEditingSchedule(schedule);
|
||||
setModalMode('edit');
|
||||
form.setFieldsValue(scheduleToFormValues(schedule));
|
||||
void loadClassTeachers(schedule.classId);
|
||||
form.setFieldsValue(scheduleToFormValues(editableSchedule));
|
||||
void loadClassTeachers(editableSchedule.classId);
|
||||
};
|
||||
|
||||
// ---- Delete schedule ----
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const handleDelete = async (id: number | null) => {
|
||||
if (id === null) return;
|
||||
try {
|
||||
await api.delete(`/class-schedules/${id}`);
|
||||
message.success('排课已删除');
|
||||
@@ -681,20 +703,31 @@ const SchedulesPage: React.FC = () => {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{schedules.map((s) => (
|
||||
<Tooltip
|
||||
key={s.id}
|
||||
title={`${s.subject} · ${s.startTime}-${s.endTime} · ${s.startDate}~${s.endDate}`}
|
||||
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
|
||||
title={
|
||||
isMaskedSchedule(s)
|
||||
? `已占用 · ${s.startTime}-${s.endTime}`
|
||||
: `${s.subject} · ${s.startTime}-${s.endTime} · ${s.startDate}~${s.endDate}`
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#e6f4ff',
|
||||
border: '1px solid #91caff',
|
||||
background: isMaskedSchedule(s) ? '#f5f5f5' : '#e6f4ff',
|
||||
border: isMaskedSchedule(s)
|
||||
? '1px solid #d9d9d9'
|
||||
: '1px solid #91caff',
|
||||
borderRadius: 4,
|
||||
padding: '2px 6px',
|
||||
fontSize: 12,
|
||||
lineHeight: '18px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600, color: '#1677ff' }}>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
color: isMaskedSchedule(s) ? '#595959' : '#1677ff',
|
||||
}}
|
||||
>
|
||||
{s.subject}
|
||||
</div>
|
||||
<div style={{ color: '#595959' }}>
|
||||
@@ -973,7 +1006,7 @@ const SchedulesPage: React.FC = () => {
|
||||
) : (
|
||||
selectedSchedules.map((s) => (
|
||||
<Card
|
||||
key={s.id}
|
||||
key={`${s.id ?? 'busy'}-${s.classroomId}-${s.weekDay}-${s.startTime}-${s.endTime}`}
|
||||
size="small"
|
||||
style={{ marginBottom: 8 }}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
@@ -987,14 +1020,16 @@ const SchedulesPage: React.FC = () => {
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<strong>科目:</strong>
|
||||
<Tag color="blue">{s.subject}</Tag>
|
||||
<strong>{isMaskedSchedule(s) ? '状态:' : '科目:'}</strong>
|
||||
<Tag color={isMaskedSchedule(s) ? 'default' : 'blue'}>{s.subject}</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>班级:</strong>
|
||||
{classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`}
|
||||
</div>
|
||||
{s.teacherId != null && (
|
||||
{!isMaskedSchedule(s) && (
|
||||
<div>
|
||||
<strong>班级:</strong>
|
||||
{classes.find((c) => c.id === s.classId)?.name || `#${s.classId}`}
|
||||
</div>
|
||||
)}
|
||||
{!isMaskedSchedule(s) && s.teacherId != null && (
|
||||
<div>
|
||||
<strong>教师:</strong>
|
||||
{classTeachers.find((u) => u.userId === s.teacherId)?.name ||
|
||||
@@ -1010,46 +1045,50 @@ const SchedulesPage: React.FC = () => {
|
||||
<strong>日期:</strong>
|
||||
{s.startDate} ~ {s.endDate}
|
||||
</div>
|
||||
{s.notes && (
|
||||
{!isMaskedSchedule(s) && s.notes && (
|
||||
<div>
|
||||
<strong>备注:</strong>
|
||||
{s.notes}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Tag color={s.scheduleType === 'RENTAL' ? 'orange' : 'green'}>
|
||||
{s.scheduleType === 'RENTAL' ? '租赁' : '内部'}
|
||||
</Tag>
|
||||
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<Space>
|
||||
{s.scheduleType !== 'RENTAL' && (
|
||||
<PermissionButton
|
||||
permission="schedule:edit"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditSchedule(s)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
{!isMaskedSchedule(s) && (
|
||||
<div>
|
||||
<Tag color={s.scheduleType === 'RENTAL' ? 'orange' : 'green'}>
|
||||
{s.scheduleType === 'RENTAL' ? '租赁' : '内部'}
|
||||
</Tag>
|
||||
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
|
||||
</div>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确认删除该排课?"
|
||||
onConfirm={() => handleDelete(s.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton
|
||||
permission="schedule:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
</div>
|
||||
{!isMaskedSchedule(s) && (
|
||||
<Space>
|
||||
{s.scheduleType !== 'RENTAL' && (
|
||||
<PermissionButton
|
||||
permission="schedule:edit"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditSchedule(s)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确认删除该排课?"
|
||||
onConfirm={() => handleDelete(s.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
<PermissionButton
|
||||
permission="schedule:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
@@ -1133,6 +1172,30 @@ const SchedulesPage: React.FC = () => {
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
{syncResult.failedBatchCount > 0 && (
|
||||
<>
|
||||
<Alert
|
||||
type="error"
|
||||
message={`${syncResult.failedBatchCount} 批写入失败,共 ${syncResult.failedItems} 条`}
|
||||
description={
|
||||
syncResult.errors.length > 0
|
||||
? syncResult.errors.slice(0, 5).map((err, i) => (
|
||||
<div key={i} style={{ wordBreak: 'break-all' }}>
|
||||
{err}
|
||||
</div>
|
||||
))
|
||||
: undefined
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
{syncResult.errors.length > 5 && (
|
||||
<div style={{ fontSize: 12, color: '#999', marginBottom: 16, marginTop: -12 }}>
|
||||
...以及其他 {syncResult.errors.length - 5} 条错误
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{syncResult.groups.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>按班级分组:</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ export interface ScheduleFormValues {
|
||||
}
|
||||
|
||||
export interface EditableSchedule {
|
||||
id: number;
|
||||
id?: number;
|
||||
classId: number;
|
||||
classroomId: number;
|
||||
weekDay: number;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
filterSchedulesForClass,
|
||||
isMaskedSchedule,
|
||||
type VisibleSchedule,
|
||||
} from './schedule-visibility';
|
||||
|
||||
const ownSchedule: VisibleSchedule = {
|
||||
id: 1,
|
||||
classId: 8,
|
||||
canViewDetails: true,
|
||||
};
|
||||
const otherClassBusyBlock: VisibleSchedule = {
|
||||
id: null,
|
||||
classId: null,
|
||||
canViewDetails: false,
|
||||
};
|
||||
const anotherOwnVisibleSchedule: VisibleSchedule = {
|
||||
id: 2,
|
||||
classId: 9,
|
||||
canViewDetails: true,
|
||||
};
|
||||
|
||||
describe('schedule visibility helpers', () => {
|
||||
it('keeps masked shared-room occupancy visible while filtering to one class', () => {
|
||||
expect(
|
||||
filterSchedulesForClass([ownSchedule, otherClassBusyBlock, anotherOwnVisibleSchedule], 8),
|
||||
).toEqual([ownSchedule, otherClassBusyBlock]);
|
||||
});
|
||||
|
||||
it('keeps a rental schedule with explicit detail access unmasked', () => {
|
||||
expect(isMaskedSchedule({ id: 3, classId: null, canViewDetails: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('treats nullable IDs as masked for responses without an explicit detail flag', () => {
|
||||
expect(isMaskedSchedule({ id: null, classId: null })).toBe(true);
|
||||
expect(isMaskedSchedule(ownSchedule)).toBe(false);
|
||||
});
|
||||
});
|
||||
19
apps/admin/src/pages/Schedules/schedule-visibility.ts
Normal file
19
apps/admin/src/pages/Schedules/schedule-visibility.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export interface VisibleSchedule {
|
||||
id: number | null;
|
||||
classId: number | null;
|
||||
canViewDetails?: boolean;
|
||||
}
|
||||
|
||||
export const isMaskedSchedule = (
|
||||
schedule: Pick<VisibleSchedule, 'id' | 'classId' | 'canViewDetails'>,
|
||||
) =>
|
||||
schedule.canViewDetails === false ||
|
||||
(schedule.canViewDetails === undefined && (schedule.id === null || schedule.classId === null));
|
||||
|
||||
export const filterSchedulesForClass = <T extends VisibleSchedule>(
|
||||
schedules: T[],
|
||||
classId?: number,
|
||||
): T[] => {
|
||||
if (classId == null) return schedules;
|
||||
return schedules.filter((schedule) => isMaskedSchedule(schedule) || schedule.classId === classId);
|
||||
};
|
||||
125
apps/admin/src/pages/Schedules/sync-result.integration.test.ts
Normal file
125
apps/admin/src/pages/Schedules/sync-result.integration.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifySyncResult, errorSummary } from './sync-result';
|
||||
|
||||
describe('classifySyncResult', () => {
|
||||
const baseResult = {
|
||||
scheduleCount: 10,
|
||||
syncedItems: 50,
|
||||
skippedNoMapping: 0,
|
||||
failedBatchCount: 0,
|
||||
failedItems: 0,
|
||||
errors: [] as string[],
|
||||
};
|
||||
|
||||
it('returns success when no failures', () => {
|
||||
const result = classifySyncResult(baseResult);
|
||||
expect(result.hasFailure).toBe(false);
|
||||
expect(result.level).toBe('success');
|
||||
expect(result.message).toContain('同步完成');
|
||||
expect(result.message).toContain('50');
|
||||
});
|
||||
|
||||
it('returns warning when partial failure (some synced, some failed)', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 30,
|
||||
failedBatchCount: 2,
|
||||
failedItems: 20,
|
||||
errors: ['err1', 'err2'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('warning');
|
||||
expect(result.message).toContain('部分失败');
|
||||
expect(result.message).toContain('30');
|
||||
expect(result.message).toContain('20');
|
||||
});
|
||||
|
||||
it('returns error when total failure (nothing synced)', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 0,
|
||||
failedBatchCount: 1,
|
||||
failedItems: 50,
|
||||
errors: ['全部失败'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('error');
|
||||
expect(result.message).toContain('全部写入失败');
|
||||
expect(result.message).toContain('50');
|
||||
});
|
||||
|
||||
it('classifies attendance group failure as failure, not success', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 45,
|
||||
failedBatchCount: 1,
|
||||
failedItems: 5,
|
||||
errors: ['考勤组 排课_强化班 创建/更新失败: permission denied'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('warning');
|
||||
expect(result.message).toContain('部分失败');
|
||||
// must NOT be success level
|
||||
expect(result.level).not.toBe('success');
|
||||
});
|
||||
|
||||
it('returns error when all shifts failed (errors present, nothing synced)', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 0,
|
||||
failedBatchCount: 2,
|
||||
failedItems: 10,
|
||||
errors: [
|
||||
'创建班次 排课_09:00-11:00 失败: permission denied',
|
||||
'创建班次 排课_14:00-16:00 失败: permission denied',
|
||||
],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('error');
|
||||
expect(result.message).toContain('全部写入失败');
|
||||
});
|
||||
|
||||
it('returns warning when errors-only with some syncedItems (no failedBatchCount)', () => {
|
||||
// Edge case: errors from shift creation that don't bump failedBatchCount
|
||||
// but do leave errors[] non-empty, yet some items synced from other classes
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 30,
|
||||
failedBatchCount: 0,
|
||||
failedItems: 0,
|
||||
errors: ['创建班次 排课_09:00-11:00 失败: network error'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('warning');
|
||||
expect(result.message).toContain('部分失败');
|
||||
});
|
||||
|
||||
it('returns error when errors-only with zero syncedItems (no failedBatchCount)', () => {
|
||||
const result = classifySyncResult({
|
||||
...baseResult,
|
||||
syncedItems: 0,
|
||||
failedBatchCount: 0,
|
||||
failedItems: 0,
|
||||
errors: ['创建班次 排课_09:00-11:00 失败: permission denied'],
|
||||
});
|
||||
expect(result.hasFailure).toBe(true);
|
||||
expect(result.level).toBe('error');
|
||||
expect(result.message).toContain('全部写入失败');
|
||||
});
|
||||
});
|
||||
|
||||
describe('errorSummary', () => {
|
||||
it('returns all errors when 5 or fewer', () => {
|
||||
const errors = ['e1', 'e2', 'e3'];
|
||||
expect(errorSummary(errors)).toEqual(['e1', 'e2', 'e3']);
|
||||
});
|
||||
|
||||
it('returns first 5 when more than 5', () => {
|
||||
const errors = ['e1', 'e2', 'e3', 'e4', 'e5', 'e6', 'e7'];
|
||||
expect(errorSummary(errors)).toEqual(['e1', 'e2', 'e3', 'e4', 'e5']);
|
||||
});
|
||||
|
||||
it('returns empty for empty errors', () => {
|
||||
expect(errorSummary([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
58
apps/admin/src/pages/Schedules/sync-result.ts
Normal file
58
apps/admin/src/pages/Schedules/sync-result.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/** 同步结果分类 — 从组件中提取以便测试。 */
|
||||
|
||||
export interface SyncResultInput {
|
||||
scheduleCount: number;
|
||||
syncedItems: number;
|
||||
skippedNoMapping: number;
|
||||
failedBatchCount: number;
|
||||
failedItems: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface SyncResultClassification {
|
||||
/** 是否有任何失败 */
|
||||
hasFailure: boolean;
|
||||
/** 通知级别:'success' | 'warning' | 'error' */
|
||||
level: 'success' | 'warning' | 'error';
|
||||
/** 适合展示给用户的消息文本 */
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将同步结果分类为成功/部分失败/全部失败。
|
||||
* - (failedBatchCount > 0 || errors 非空) 且 syncedItems > 0 → warning(部分失败)
|
||||
* - (failedBatchCount > 0 || errors 非空) 且 syncedItems === 0 → error(全部失败)
|
||||
* - 否则 → success
|
||||
*/
|
||||
export function classifySyncResult(result: SyncResultInput): SyncResultClassification {
|
||||
const hasFailure = result.failedBatchCount > 0 || result.errors.length > 0;
|
||||
|
||||
if (!hasFailure) {
|
||||
return {
|
||||
hasFailure: false,
|
||||
level: 'success',
|
||||
message: `同步完成:${result.syncedItems} 条排班已写入钉钉`,
|
||||
};
|
||||
}
|
||||
|
||||
if (result.syncedItems > 0) {
|
||||
return {
|
||||
hasFailure: true,
|
||||
level: 'warning',
|
||||
message: `同步部分失败:${result.syncedItems} 条成功,${result.failedItems} 条失败`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
hasFailure: true,
|
||||
level: 'error',
|
||||
message: `同步失败:${result.failedItems} 条全部写入失败`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 从错误列表中提取摘要(最多 5 条)。
|
||||
*/
|
||||
export function errorSummary(errors: string[]): string[] {
|
||||
return errors.slice(0, 5);
|
||||
}
|
||||
@@ -28,8 +28,12 @@ interface ProfileFormValues {
|
||||
}
|
||||
|
||||
const ROLE_LABELS: Record<string, string> = {
|
||||
super_admin: '超管',
|
||||
teacher: '老师',
|
||||
super_admin: '超级管理员',
|
||||
teacher: '任课老师',
|
||||
academic: '教务管理员',
|
||||
accommodation_operations: '住宿运营管理员',
|
||||
classroom_operations: '教室运营管理员',
|
||||
system_admin: '系统管理员',
|
||||
class_teacher: '班主任',
|
||||
dormitory_supervisor: '宿管',
|
||||
institution_head: '机构负责人',
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ClassTeacher,
|
||||
ClassSchedule,
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
DingAttendanceRaw,
|
||||
SyncLog,
|
||||
SyncState,
|
||||
@@ -116,6 +117,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
Role,
|
||||
ClassSchedule,
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
DingAttendanceRaw,
|
||||
Notification,
|
||||
StudentProfile,
|
||||
|
||||
@@ -125,4 +125,101 @@ describe('AttendanceImportService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
it('auto-matches previously imported duplicate records', async () => {
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||
{
|
||||
userId: 'ding-1',
|
||||
userName: '张三',
|
||||
workDate: '2026-07-01',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||
checkId: 'check-1',
|
||||
checkType: 'OnDuty',
|
||||
},
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([{ dingId: 'check-1' }]);
|
||||
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
|
||||
|
||||
const result = await service.importFromDingTalk({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: ['ding-1'],
|
||||
autoMatch: true,
|
||||
});
|
||||
|
||||
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
|
||||
expect(result.matched).toBe(1);
|
||||
});
|
||||
|
||||
it('scopes SSE progress events to the importing user', async () => {
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||
{
|
||||
userId: 'ding-1',
|
||||
userName: '李四',
|
||||
workDate: '2026-07-01',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-01T09:00:00.000Z',
|
||||
checkId: 'check-2',
|
||||
checkType: 'OnDuty',
|
||||
},
|
||||
]);
|
||||
|
||||
const events: Array<{ phase: string; userId?: number }> = [];
|
||||
const sub = service.progress$.subscribe((event) => {
|
||||
events.push({ phase: event.phase, userId: event.userId });
|
||||
});
|
||||
|
||||
await service.importFromDingTalk({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: ['ding-1'],
|
||||
userId: 42,
|
||||
});
|
||||
|
||||
sub.unsubscribe();
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
for (const event of events) {
|
||||
expect(event.userId).toBe(42);
|
||||
}
|
||||
});
|
||||
|
||||
it('emits userId undefined when import has no HTTP user', async () => {
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||
{
|
||||
userId: 'ding-2',
|
||||
userName: '王五',
|
||||
workDate: '2026-07-02',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-02T10:00:00.000Z',
|
||||
checkId: 'check-3',
|
||||
checkType: 'OnDuty',
|
||||
},
|
||||
]);
|
||||
|
||||
const events: Array<{ phase: string; userId?: number }> = [];
|
||||
const sub = service.progress$.subscribe((event) => {
|
||||
events.push({ phase: event.phase, userId: event.userId });
|
||||
});
|
||||
|
||||
await service.importFromDingTalk({
|
||||
startDate: '2026-07-02',
|
||||
endDate: '2026-07-02',
|
||||
userIds: ['ding-2'],
|
||||
});
|
||||
|
||||
sub.unsubscribe();
|
||||
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
for (const event of events) {
|
||||
expect(event.userId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ export class AttendanceImportService {
|
||||
/** RxJS Subject emitting live progress during import */
|
||||
private progressSubject = new Subject<ImportProgressEvent>();
|
||||
private isRunning = false;
|
||||
/** ID of the user who triggered the current import (for SSE scoping) */
|
||||
private importingUserId?: number;
|
||||
constructor(
|
||||
@InjectRepository(DingAttendanceRaw)
|
||||
private readonly dingRawRepo: Repository<DingAttendanceRaw>,
|
||||
@@ -60,7 +62,6 @@ export class AttendanceImportService {
|
||||
* 1. Fetch attendance results from DingTalk (paginated)
|
||||
* 2. Parse and validate each record
|
||||
* 3. Deduplicate by `dingId` (unique in DB)
|
||||
* 4. Batch-save to `ding_attendance_raw`
|
||||
* 5. Optionally auto-match to students by name
|
||||
*/
|
||||
async importFromDingTalk(params: {
|
||||
@@ -68,6 +69,8 @@ export class AttendanceImportService {
|
||||
endDate: string;
|
||||
userIds?: string[];
|
||||
autoMatch?: boolean;
|
||||
/** ID of the HTTP user triggering the import (for SSE event scoping) */
|
||||
userId?: number;
|
||||
}): Promise<ImportResult> {
|
||||
if (this.isRunning) {
|
||||
throw new Error('An import is already in progress');
|
||||
@@ -75,6 +78,7 @@ export class AttendanceImportService {
|
||||
|
||||
const startedAt = Date.now();
|
||||
this.isRunning = true;
|
||||
this.importingUserId = params.userId;
|
||||
|
||||
// Safety timeout: auto-reset isRunning after 30 minutes in case of
|
||||
// an unhandled exception that bypasses the finally block (extremely rare).
|
||||
@@ -106,6 +110,7 @@ export class AttendanceImportService {
|
||||
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
|
||||
|
||||
if (newRecords.length === 0) {
|
||||
if (params.autoMatch) matched = await this.autoMatchUnmatched();
|
||||
this.emit('complete', imported + skipped, total, 'Nothing new to import');
|
||||
return { success: true, imported, skipped, matched, errors, duration: Date.now() - startedAt };
|
||||
}
|
||||
@@ -147,6 +152,7 @@ export class AttendanceImportService {
|
||||
} finally {
|
||||
clearTimeout(safetyTimer);
|
||||
this.isRunning = false;
|
||||
this.importingUserId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +318,6 @@ export class AttendanceImportService {
|
||||
message: string,
|
||||
error?: string,
|
||||
): void {
|
||||
this.progressSubject.next({ phase, current, total, message, error });
|
||||
this.progressSubject.next({ phase, current, total, message, error, userId: this.importingUserId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { Subject } from 'rxjs';
|
||||
import { AttendanceController } from './attendance.controller';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
@@ -11,6 +12,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
||||
};
|
||||
const importService = {
|
||||
importFromDingTalk: jest.fn(),
|
||||
progress$: undefined as unknown,
|
||||
};
|
||||
const logService = {
|
||||
log: jest.fn(),
|
||||
@@ -52,6 +54,7 @@ describe('AttendanceController — DingTalk import scope', () => {
|
||||
endDate: '2026-07-10',
|
||||
userIds: ['ding-today'],
|
||||
autoMatch: true,
|
||||
userId: 21,
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
@@ -64,12 +67,12 @@ describe('AttendanceController — DingTalk import scope', () => {
|
||||
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
|
||||
);
|
||||
|
||||
expect(attendanceService.getTeacherClassDingUserIds).toHaveBeenCalledWith(21, 8, false);
|
||||
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-02',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
autoMatch: true,
|
||||
userId: 21,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -144,12 +147,223 @@ describe('AttendanceController — DingTalk import scope', () => {
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(importService.importFromDingTalk).toHaveBeenLastCalledWith({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-02',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
autoMatch: true,
|
||||
userId: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttendanceController — write data scope', () => {
|
||||
const attendanceService = {
|
||||
assertClassAccess: jest.fn(),
|
||||
getAccessibleClassIds: jest.fn(),
|
||||
getTeacherClassDingUserIds: jest.fn(),
|
||||
batchCreate: jest.fn(),
|
||||
generateFromSchedules: jest.fn(),
|
||||
findAttendanceRecord: jest.fn(),
|
||||
update: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
getLessonAttendance: jest.fn(),
|
||||
createLessonAttendanceFromDingTalk: jest.fn(),
|
||||
findAttendanceSession: jest.fn(),
|
||||
completeLessonAttendance: jest.fn(),
|
||||
};
|
||||
const importService = { importFromDingTalk: jest.fn() };
|
||||
const logService = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const authzService = { can: jest.fn().mockReturnValue(false) };
|
||||
const req = {
|
||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false, roles: ['teacher'] },
|
||||
headers: {},
|
||||
};
|
||||
let controller: AttendanceController;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
authzService.can.mockReturnValue(false);
|
||||
controller = new AttendanceController(
|
||||
attendanceService as unknown as AttendanceService,
|
||||
importService as unknown as AttendanceImportService,
|
||||
logService as unknown as OperationLogsService,
|
||||
authzService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('checks every distinct class in a manual attendance batch', async () => {
|
||||
attendanceService.batchCreate.mockResolvedValue({ count: 2, records: [] });
|
||||
const dto = {
|
||||
records: [
|
||||
{
|
||||
studentId: 1,
|
||||
classId: 8,
|
||||
attendanceDate: '2026-07-01',
|
||||
session: 'morning',
|
||||
status: 'present',
|
||||
},
|
||||
{
|
||||
studentId: 2,
|
||||
classId: 9,
|
||||
attendanceDate: '2026-07-01',
|
||||
session: 'morning',
|
||||
status: 'present',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await controller.batchCreate(dto, req as never);
|
||||
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 9, false);
|
||||
});
|
||||
|
||||
it('rejects classless manual attendance records for a class-scoped teacher', async () => {
|
||||
attendanceService.batchCreate.mockResolvedValue({ count: 1, records: [] });
|
||||
const dto = {
|
||||
records: [
|
||||
{
|
||||
studentId: 1,
|
||||
attendanceDate: '2026-07-01',
|
||||
session: 'morning',
|
||||
status: 'present',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await expect(controller.batchCreate(dto, req as never)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
expect(attendanceService.batchCreate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('checks the schedule class before starting and completing lesson attendance', async () => {
|
||||
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1']);
|
||||
importService.importFromDingTalk.mockResolvedValue({
|
||||
success: true,
|
||||
imported: 1,
|
||||
skipped: 0,
|
||||
matched: 1,
|
||||
errors: [],
|
||||
duration: 10,
|
||||
});
|
||||
attendanceService.getLessonAttendance.mockResolvedValue({
|
||||
schedule: { id: 4, classId: 8 },
|
||||
session: null,
|
||||
records: [],
|
||||
});
|
||||
attendanceService.createLessonAttendanceFromDingTalk.mockResolvedValue({
|
||||
schedule: { id: 4, classId: 8 },
|
||||
session: { id: 90, classId: 8 },
|
||||
records: [],
|
||||
});
|
||||
attendanceService.findAttendanceSession.mockResolvedValue({ id: 90, classId: 8 });
|
||||
attendanceService.completeLessonAttendance.mockResolvedValue({
|
||||
session: { id: 90, classId: 8, status: 'completed' },
|
||||
records: [],
|
||||
});
|
||||
|
||||
await controller.pullLessonAttendance('4', { date: '2026-07-11' }, req as never);
|
||||
await controller.completeLessonAttendance('90', req as never);
|
||||
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 8, false);
|
||||
});
|
||||
|
||||
it('checks class access before generating attendance from schedules', async () => {
|
||||
attendanceService.generateFromSchedules.mockResolvedValue({ count: 0, records: [] });
|
||||
|
||||
await controller.generateFromSchedules({ classId: 8 }, req as never);
|
||||
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
});
|
||||
|
||||
it('checks the attendance record owning class before update and delete', async () => {
|
||||
attendanceService.findAttendanceRecord.mockResolvedValue({ id: 4, classId: 8 });
|
||||
attendanceService.update.mockResolvedValue({ id: 4, classId: 8, status: 'late' });
|
||||
attendanceService.remove.mockResolvedValue({ deleted: true });
|
||||
|
||||
await controller.update('4', { status: 'late' }, req as never);
|
||||
await controller.remove('4', req as never);
|
||||
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenCalledTimes(2);
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
|
||||
expect(attendanceService.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 8, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttendanceController — SSE progress scoping', () => {
|
||||
let progressSubject: Subject<{ phase: string; userId?: number }>;
|
||||
const importService = {
|
||||
importFromDingTalk: jest.fn(),
|
||||
get progress$() { return progressSubject.asObservable(); },
|
||||
};
|
||||
const attendanceService = {} as unknown as AttendanceService;
|
||||
const logService = {} as unknown as OperationLogsService;
|
||||
const authzService = {} as never;
|
||||
|
||||
let controller: AttendanceController;
|
||||
|
||||
beforeEach(() => {
|
||||
progressSubject = new Subject<{ phase: string; userId?: number }>();
|
||||
controller = new AttendanceController(
|
||||
attendanceService,
|
||||
importService as unknown as AttendanceImportService,
|
||||
logService,
|
||||
authzService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
progressSubject.complete();
|
||||
});
|
||||
|
||||
it('delivers events matching the requesting user id', () => {
|
||||
const received: Array<{ phase: string; userId?: number }> = [];
|
||||
const sub = controller.importProgressStream({
|
||||
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
|
||||
} as never).subscribe({
|
||||
next: (e) => received.push(JSON.parse(e.data as string)),
|
||||
});
|
||||
|
||||
progressSubject.next({ phase: 'fetching', userId: 42 });
|
||||
progressSubject.next({ phase: 'complete', userId: 42 });
|
||||
|
||||
sub.unsubscribe();
|
||||
expect(received).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('excludes events from a different user', () => {
|
||||
const received: Array<{ phase: string; userId?: number }> = [];
|
||||
const sub = controller.importProgressStream({
|
||||
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
|
||||
} as never).subscribe({
|
||||
next: (e) => received.push(JSON.parse(e.data as string)),
|
||||
});
|
||||
|
||||
progressSubject.next({ phase: 'fetching', userId: 99 }); // different user
|
||||
progressSubject.next({ phase: 'complete', userId: 42 });
|
||||
|
||||
sub.unsubscribe();
|
||||
// Only the matching event should arrive
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].userId).toBe(42);
|
||||
});
|
||||
|
||||
it('excludes events with undefined userId (non-HTTP callers)', () => {
|
||||
const received: Array<{ phase: string; userId?: number }> = [];
|
||||
const sub = controller.importProgressStream({
|
||||
user: { id: 42, username: 'alice', permissions: ['attendance:view'], isSuperAdmin: false, roles: [] },
|
||||
} as never).subscribe({
|
||||
next: (e) => received.push(JSON.parse(e.data as string)),
|
||||
});
|
||||
progressSubject.next({ phase: 'fetching' }); // no userId
|
||||
progressSubject.next({ phase: 'complete', userId: 42 });
|
||||
|
||||
sub.unsubscribe();
|
||||
// Only the event with matching userId should arrive
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0].userId).toBe(42);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { Observable, filter } from 'rxjs';
|
||||
import type { Request as ExpressRequest, Response } from 'express';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
AttendanceReportQueryDto,
|
||||
UpdateAttendanceRecordDto,
|
||||
GenerateFromSchedulesDto,
|
||||
LessonAttendanceQueryDto,
|
||||
StartLessonAttendanceDto,
|
||||
} from './dto/attendance.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
@@ -88,11 +90,94 @@ export class AttendanceController {
|
||||
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllAttendance(req));
|
||||
}
|
||||
|
||||
@Get('attendance-lessons/schedules/:scheduleId')
|
||||
@RequirePermission('attendance:view')
|
||||
async getLessonAttendance(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Query() query: LessonAttendanceQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const result = await this.service.getLessonAttendance(+scheduleId, query.date);
|
||||
await this.assertClassAccess(req, result.schedule.classId!);
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('attendance-lessons/schedules/:scheduleId/pull')
|
||||
@RequirePermission('attendance:create')
|
||||
async pullLessonAttendance(
|
||||
@Param('scheduleId') scheduleId: string,
|
||||
@Body() dto: StartLessonAttendanceDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const schedule = await this.service.getLessonAttendance(+scheduleId, dto.date);
|
||||
await this.assertClassAccess(req, schedule.schedule.classId!);
|
||||
const importClassIds = await this.service.getTeacherClassDingUserIds(
|
||||
req.user.id,
|
||||
schedule.schedule.classId!,
|
||||
this.canManageAllAttendance(req),
|
||||
);
|
||||
const importResult = await this.importService.importFromDingTalk({
|
||||
startDate: dto.date,
|
||||
endDate: dto.date,
|
||||
userIds: importClassIds,
|
||||
autoMatch: true,
|
||||
userId: req.user.id,
|
||||
});
|
||||
const result = await this.service.createLessonAttendanceFromDingTalk(
|
||||
+scheduleId,
|
||||
dto.date,
|
||||
req.user.id,
|
||||
);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考勤管理',
|
||||
action: schedule.session ? '查看已拉取课程考勤' : '拉取钉钉课程考勤',
|
||||
targetId: result.session.id,
|
||||
targetType: 'attendanceSession',
|
||||
detail: `排课${scheduleId} 日期${dto.date},钉钉新增${importResult.imported}条,匹配${importResult.matched}条`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('attendance-lessons/:sessionId/complete')
|
||||
@RequirePermission('attendance:create')
|
||||
async completeLessonAttendance(
|
||||
@Param('sessionId') sessionId: string,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const session = await this.service.findAttendanceSession(+sessionId);
|
||||
await this.assertClassAccess(req, session.classId);
|
||||
const result = await this.service.completeLessonAttendance(+sessionId, req.user.id);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考勤管理',
|
||||
action: '完成课程点名',
|
||||
targetId: +sessionId,
|
||||
targetType: 'attendanceSession',
|
||||
detail: `班级${session.classId} 日期${session.lessonDate}`,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
@Post('attendance-records/batch')
|
||||
@RequirePermission('attendance:create')
|
||||
async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const canManageAll = this.canManageAllAttendance(req);
|
||||
if (!canManageAll && dto.records.some((record) => record.classId == null)) {
|
||||
throw new ForbiddenException('教师录入考勤时必须关联自己任教的班级');
|
||||
}
|
||||
const classIds = [
|
||||
...new Set(
|
||||
dto.records.map((record) => record.classId).filter((id): id is number => id != null),
|
||||
),
|
||||
];
|
||||
for (const classId of classIds) {
|
||||
await this.service.assertClassAccess(req.user.id, classId, canManageAll);
|
||||
}
|
||||
const result = await this.service.batchCreate(dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
@@ -111,6 +196,7 @@ export class AttendanceController {
|
||||
@RequirePermission('attendance:create')
|
||||
async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.assertClassAccess(req, dto.classId);
|
||||
const result = await this.service.generateFromSchedules(dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
@@ -190,13 +276,18 @@ export class AttendanceController {
|
||||
|
||||
// ── Update a single attendance record ──
|
||||
@Put('attendance-records/:id')
|
||||
@RequirePermission('attendance:edit')
|
||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateAttendanceRecordDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const existing = await this.service.findAttendanceRecord(+id);
|
||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||
throw new ForbiddenException('无权修改未关联班级的考勤记录');
|
||||
}
|
||||
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
||||
const result = await this.service.update(+id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
@@ -214,9 +305,14 @@ export class AttendanceController {
|
||||
|
||||
// ── Delete a single attendance record ──
|
||||
@Delete('attendance-records/:id')
|
||||
@RequirePermission('attendance:edit')
|
||||
@RequirePermission('attendance:edit', 'attendance:self-edit')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const existing = await this.service.findAttendanceRecord(+id);
|
||||
if (existing.classId == null && !this.canManageAllAttendance(req)) {
|
||||
throw new ForbiddenException('无权删除未关联班级的考勤记录');
|
||||
}
|
||||
if (existing.classId != null) await this.assertClassAccess(req, existing.classId);
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
@@ -421,12 +517,12 @@ export class AttendanceController {
|
||||
|
||||
const startDate = dto.start ?? this.getTodayDateOnly();
|
||||
const endDate = dto.end ?? startDate;
|
||||
|
||||
const result = await this.importService.importFromDingTalk({
|
||||
startDate,
|
||||
endDate,
|
||||
userIds,
|
||||
autoMatch: true,
|
||||
userId: req.user.id,
|
||||
});
|
||||
|
||||
await this.logService.log({
|
||||
@@ -452,17 +548,22 @@ export class AttendanceController {
|
||||
*/
|
||||
@Sse('attendance-records/import/dingtalk/stream')
|
||||
@RequirePermission('attendance:view')
|
||||
importProgressStream(): Observable<SseEvent> {
|
||||
importProgressStream(@Request() req: { user: RequestUser }): Observable<SseEvent> {
|
||||
const userId = req.user.id;
|
||||
return new Observable<SseEvent>((subscriber) => {
|
||||
const subscription = this.importService.progress$.subscribe({
|
||||
next: (event) => {
|
||||
subscriber.next({ data: JSON.stringify(event) });
|
||||
if (event.phase === 'complete' || event.phase === 'error') {
|
||||
subscriber.complete();
|
||||
}
|
||||
},
|
||||
error: (err: unknown) => subscriber.error(err),
|
||||
});
|
||||
const subscription = this.importService.progress$
|
||||
.pipe(
|
||||
filter((event) => event.userId === userId),
|
||||
)
|
||||
.subscribe({
|
||||
next: (event) => {
|
||||
subscriber.next({ data: JSON.stringify(event) });
|
||||
if (event.phase === 'complete' || event.phase === 'error') {
|
||||
subscriber.complete();
|
||||
}
|
||||
},
|
||||
error: (err: unknown) => subscriber.error(err),
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
492
apps/server/src/attendance/attendance.lesson-session.spec.ts
Normal file
492
apps/server/src/attendance/attendance.lesson-session.spec.ts
Normal file
@@ -0,0 +1,492 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceSession } from '../entities/attendance-session.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
|
||||
const createService = () => {
|
||||
const attendanceRepo = {
|
||||
create: jest.fn((value: Record<string, unknown>) => value),
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(async (records: unknown) => records),
|
||||
remove: jest.fn(async (record: unknown) => record),
|
||||
count: jest.fn(),
|
||||
};
|
||||
const dingRawRepo = { find: jest.fn() };
|
||||
const scheduleRepo = { findOne: jest.fn() };
|
||||
const classStudentRepo = { find: jest.fn() };
|
||||
const sessionRepo = {
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn((value: Record<string, unknown>) => ({ id: 90, ...value })),
|
||||
save: jest.fn(async (value: unknown) => value),
|
||||
};
|
||||
const dataSource = {
|
||||
transaction: jest.fn(
|
||||
async (cb: (manager: { getRepository: jest.Mock }) => Promise<unknown>) => {
|
||||
const managerGetRepo = jest.fn((entity: { name: string }) => {
|
||||
if (entity.name === AttendanceSession.name) return sessionRepo;
|
||||
if (entity.name === AttendanceRecord.name) return attendanceRepo;
|
||||
return {};
|
||||
});
|
||||
return cb({ getRepository: managerGetRepo });
|
||||
},
|
||||
),
|
||||
};
|
||||
const service = new AttendanceService(
|
||||
attendanceRepo as never,
|
||||
dingRawRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
scheduleRepo as never,
|
||||
classStudentRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
sessionRepo as never,
|
||||
dataSource as unknown as DataSource,
|
||||
);
|
||||
return { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo, dataSource };
|
||||
};
|
||||
|
||||
const endedSchedule = {
|
||||
id: 4,
|
||||
classId: 8,
|
||||
weekDay: 6,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '\u6570\u5B66',
|
||||
status: 'active',
|
||||
scheduleType: 'INTERNAL',
|
||||
};
|
||||
|
||||
describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
it('creates one course session from DingTalk results after the lesson', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue(null);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
|
||||
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
||||
{ studentId: 3, student: { id: 3, name: '\u738B\u4E94' } },
|
||||
{ studentId: 4, student: { id: 4, name: '\u8D75\u516D' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
|
||||
{ matchedStudentId: 3, attendanceType: 'OnDuty', timeResult: 'NotSigned' },
|
||||
]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(sessionRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'in_progress',
|
||||
}),
|
||||
);
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, status: 'present', source: 'dingtalk' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'late', source: 'dingtalk' }),
|
||||
expect.objectContaining({ studentId: 3, status: 'absent', source: 'dingtalk' }),
|
||||
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
|
||||
]);
|
||||
expect(result.records).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('returns student relations after the first pull', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue(null);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ student: { id: 1, name: '张三' } }),
|
||||
]);
|
||||
expect(result.records[0].student.name).toBe('张三');
|
||||
});
|
||||
|
||||
it('uses the DingTalk punch nearest to this lesson start when a student has multiple shifts', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue(null);
|
||||
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } }]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{
|
||||
matchedStudentId: 1,
|
||||
attendanceType: 'OnDuty',
|
||||
timeResult: 'Late',
|
||||
checkInTime: new Date('2026-07-11T02:00:00+08:00'),
|
||||
},
|
||||
{
|
||||
matchedStudentId: 1,
|
||||
attendanceType: 'OnDuty',
|
||||
timeResult: 'Normal',
|
||||
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||
},
|
||||
]);
|
||||
|
||||
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, status: 'present' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates local attendance after the lesson starts', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
const now = new Date();
|
||||
const weekDay = now.getDay() === 0 ? 7 : now.getDay();
|
||||
scheduleRepo.findOne.mockResolvedValue({
|
||||
...endedSchedule,
|
||||
weekDay,
|
||||
startTime: '00:00',
|
||||
endTime: '23:59',
|
||||
startDate: '2026-01-01',
|
||||
endDate: '2026-12-31',
|
||||
});
|
||||
sessionRepo.findOne.mockResolvedValue(null);
|
||||
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
const today = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, today, 21);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalled();
|
||||
expect(result.records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a completed session as-is without refreshing', async () => {
|
||||
const { service, attendanceRepo, scheduleRepo, sessionRepo } = createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'completed',
|
||||
});
|
||||
attendanceRepo.find.mockResolvedValue([{ id: 1, attendanceSessionId: 90, status: 'present' }]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(sessionRepo.save).not.toHaveBeenCalled();
|
||||
expect(attendanceRepo.save).not.toHaveBeenCalled();
|
||||
expect(result.records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refreshes an in_progress session from latest DingTalk data', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'in_progress',
|
||||
});
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
|
||||
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
|
||||
]);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
|
||||
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
|
||||
]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(sessionRepo.create).not.toHaveBeenCalled();
|
||||
expect(attendanceRepo.save).toHaveBeenCalled();
|
||||
|
||||
const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
|
||||
const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
|
||||
expect(savedRecords).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ studentId: 1, status: 'present' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'late' }),
|
||||
]),
|
||||
);
|
||||
expect(result.records).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
it('restores students missing from an existing empty session', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'in_progress',
|
||||
});
|
||||
attendanceRepo.find.mockResolvedValue([]);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||
{ studentId: 2, student: { id: 2, name: '李四' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, student: { id: 1, name: '张三' } }),
|
||||
expect.objectContaining({ studentId: 2, student: { id: 2, name: '李四' } }),
|
||||
]);
|
||||
expect(result.records).toHaveLength(2);
|
||||
});
|
||||
it('preserves manually corrected records when refreshing an in_progress session', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'in_progress',
|
||||
});
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'leave', source: 'manual' },
|
||||
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
|
||||
]);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
|
||||
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Late' },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
|
||||
]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
const callArgs = (attendanceRepo.save as jest.Mock).mock.calls[0];
|
||||
const savedRecords = callArgs[0] as Array<{ studentId: number; status: string }>;
|
||||
expect(savedRecords).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ studentId: 1, status: 'leave' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'late' }),
|
||||
]),
|
||||
);
|
||||
expect(result.records).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('rejects completion when pending records exist', async () => {
|
||||
const { service, sessionRepo, attendanceRepo } = createService();
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
status: 'in_progress',
|
||||
});
|
||||
attendanceRepo.count.mockResolvedValue(1);
|
||||
|
||||
await expect(service.completeLessonAttendance(90, 21)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('completes a pulled attendance session after teacher review', async () => {
|
||||
const { service, sessionRepo, attendanceRepo } = createService();
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
status: 'in_progress',
|
||||
});
|
||||
attendanceRepo.count.mockResolvedValue(0);
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 1, status: 'present' },
|
||||
{ id: 2, status: 'absent' },
|
||||
]);
|
||||
|
||||
const result = await service.completeLessonAttendance(90, 21);
|
||||
|
||||
expect(sessionRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 90, status: 'completed', completedBy: 21 }),
|
||||
);
|
||||
expect(result.session.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('throws when querying a missing schedule attendance session', async () => {
|
||||
const { service, scheduleRepo } = createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(null);
|
||||
await expect(service.getLessonAttendance(999, '2026-07-11')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('update() marks record source as manual so refresh preserves the correction', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'in_progress',
|
||||
});
|
||||
// Initial state: dingtalk-sourced record
|
||||
attendanceRepo.findOne.mockResolvedValue({
|
||||
id: 101,
|
||||
studentId: 1,
|
||||
attendanceSessionId: 90,
|
||||
status: 'present',
|
||||
source: 'dingtalk',
|
||||
});
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'manual' },
|
||||
{ id: 102, studentId: 2, attendanceSessionId: 90, status: 'present', source: 'dingtalk' },
|
||||
]);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||
{ studentId: 2, student: { id: 2, name: '李四' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Normal' },
|
||||
]);
|
||||
|
||||
// Step 1: update the record to absent via generic update()
|
||||
const updated = await service.update(101, { status: 'absent' });
|
||||
expect(updated.source).toBe('manual');
|
||||
|
||||
// Step 2: refresh in_progress session — manual record status must stay absent
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
const savedRecords = (attendanceRepo.save as jest.Mock).mock.calls[
|
||||
(attendanceRepo.save as jest.Mock).mock.calls.length - 1
|
||||
][0] as Array<{ studentId: number; status: string }>;
|
||||
expect(savedRecords).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ studentId: 1, status: 'absent' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'present' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('recovers from concurrent unique constraint on first session creation', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne
|
||||
.mockResolvedValueOnce(null) // first check: no existing session
|
||||
.mockResolvedValueOnce({
|
||||
// recovery: the winning session
|
||||
id: 77,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'in_progress',
|
||||
});
|
||||
// Simulate unique constraint on save
|
||||
sessionRepo.save.mockRejectedValueOnce(
|
||||
Object.assign(new Error('UNIQUE constraint failed'), {
|
||||
code: 'SQLITE_CONSTRAINT',
|
||||
errno: undefined,
|
||||
}),
|
||||
);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 201, studentId: 1, attendanceSessionId: 77, status: 'present', source: 'dingtalk' },
|
||||
]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(result.session.id).toBe(77);
|
||||
expect(result.records).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('completeLessonAttendance runs inside a transaction', async () => {
|
||||
const { service, sessionRepo, attendanceRepo, dataSource } = createService();
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
status: 'in_progress',
|
||||
});
|
||||
attendanceRepo.count.mockResolvedValue(0);
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 1, status: 'present' },
|
||||
]);
|
||||
|
||||
await service.completeLessonAttendance(90, 21);
|
||||
|
||||
expect(dataSource.transaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update() throws when the parent session is completed', async () => {
|
||||
const { service, attendanceRepo, sessionRepo } = createService();
|
||||
attendanceRepo.findOne.mockResolvedValue({
|
||||
id: 101,
|
||||
attendanceSessionId: 90,
|
||||
status: 'present',
|
||||
});
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await expect(service.update(101, { status: 'absent' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('remove() throws when the parent session is completed', async () => {
|
||||
const { service, attendanceRepo, sessionRepo } = createService();
|
||||
attendanceRepo.findOne.mockResolvedValue({
|
||||
id: 101,
|
||||
attendanceSessionId: 90,
|
||||
});
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
status: 'completed',
|
||||
});
|
||||
|
||||
await expect(service.remove(101)).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('update() allows modification when the parent session is in_progress', async () => {
|
||||
const { service, attendanceRepo, sessionRepo } = createService();
|
||||
attendanceRepo.findOne.mockResolvedValue({
|
||||
id: 101,
|
||||
attendanceSessionId: 90,
|
||||
status: 'present',
|
||||
});
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
status: 'in_progress',
|
||||
});
|
||||
|
||||
const result = await service.update(101, { status: 'absent' });
|
||||
expect(result.source).toBe('manual');
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { AttendanceController } from './attendance.controller';
|
||||
@@ -9,7 +9,7 @@ import { IntegrationModule } from '../integration/integration.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
OperationLogsModule,
|
||||
IntegrationModule,
|
||||
],
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { AttendanceSession } from '../entities/attendance-session.entity';
|
||||
import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity';
|
||||
import { Class } from '../entities/class.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
@@ -53,6 +55,8 @@ describe('AttendanceService — batchCreate', () => {
|
||||
{ provide: getRepositoryToken(StudentDingMapping), useValue: mockStudentDingMappingRepo },
|
||||
{ provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo },
|
||||
{ provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } },
|
||||
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
|
||||
{ provide: getDataSourceToken(), useValue: { transaction: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -130,6 +134,8 @@ describe('AttendanceService — teacher DingTalk class scope', () => {
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classTeacherRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -203,6 +209,8 @@ describe('AttendanceService — DingTalk raw query', () => {
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
@@ -219,3 +227,205 @@ describe('AttendanceService — DingTalk raw query', () => {
|
||||
expect(qb.take).toHaveBeenCalledWith(10);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ── Session serialization tests ──
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T | PromiseLike<T>) => void;
|
||||
reject: (reason?: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('AttendanceService — session serialization', () => {
|
||||
const sessionId = 100;
|
||||
const recordId = 200;
|
||||
const sessionInProgress = { id: sessionId, status: 'in_progress' };
|
||||
const sessionCompleted = { id: sessionId, status: 'completed' };
|
||||
const record: Record<string, unknown> = {
|
||||
id: recordId,
|
||||
attendanceSessionId: sessionId,
|
||||
studentId: 1,
|
||||
status: 'present',
|
||||
source: 'dingtalk',
|
||||
attendanceDate: '2026-07-01',
|
||||
session: 'morning',
|
||||
};
|
||||
|
||||
function makeService(
|
||||
dataSourceMock: { transaction: jest.Mock },
|
||||
attendanceRepoOverrides?: Record<string, jest.Mock>,
|
||||
) {
|
||||
const defaultAttendanceRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(record),
|
||||
find: jest.fn().mockResolvedValue([record]),
|
||||
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
create: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
const attendanceRepo = { ...defaultAttendanceRepo, ...attendanceRepoOverrides };
|
||||
|
||||
return new AttendanceService(
|
||||
attendanceRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
dataSourceMock as never,
|
||||
);
|
||||
}
|
||||
|
||||
function makeTxManager(sessionStatus: string) {
|
||||
const session = sessionStatus === 'completed' ? { ...sessionCompleted } : { ...sessionInProgress };
|
||||
|
||||
const sessionRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(session),
|
||||
save: jest.fn().mockImplementation((s: unknown) => Promise.resolve(s)),
|
||||
};
|
||||
|
||||
const recordRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(record),
|
||||
find: jest.fn().mockResolvedValue([record]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
|
||||
remove: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
return {
|
||||
getRepository: jest.fn((entity: unknown) => {
|
||||
if (entity === AttendanceSession) return sessionRepo;
|
||||
if (entity === AttendanceRecord) return recordRepo;
|
||||
throw new Error('Unexpected entity');
|
||||
}),
|
||||
sessionRepo,
|
||||
recordRepo,
|
||||
};
|
||||
}
|
||||
|
||||
it('complete holds lock; queued update is rejected after session becomes completed', async () => {
|
||||
// For this test, the manager has a completed session
|
||||
// The complete callback is stalled, update queues and then finds completed
|
||||
const manager = makeTxManager('completed');
|
||||
const completeStall = deferred<unknown>();
|
||||
|
||||
const txMock = jest
|
||||
.fn()
|
||||
.mockImplementationOnce(() => completeStall.promise)
|
||||
.mockImplementationOnce((cb: (m: unknown) => unknown) => cb(manager));
|
||||
|
||||
const svc = makeService({ transaction: txMock });
|
||||
|
||||
const completeP = svc.completeLessonAttendance(sessionId, 1);
|
||||
const updateP = svc.update(recordId, { status: 'absent' });
|
||||
|
||||
completeStall.resolve({ session: sessionCompleted, records: [record] });
|
||||
|
||||
await expect(completeP).resolves.toEqual({ session: sessionCompleted, records: [record] });
|
||||
await expect(updateP).rejects.toThrow(BadRequestException);
|
||||
|
||||
expect(manager.sessionRepo.findOne).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('update holds lock; complete reads current state after update finishes', async () => {
|
||||
const manager = makeTxManager('in_progress');
|
||||
const updateStall = deferred<unknown>();
|
||||
|
||||
// Track call order
|
||||
const callOrder: string[] = [];
|
||||
let transactionsRun = 0;
|
||||
|
||||
const txMock = jest.fn().mockImplementation((cb: (m: unknown) => unknown) => {
|
||||
transactionsRun++;
|
||||
if (transactionsRun === 1) {
|
||||
// update: stall
|
||||
callOrder.push('update-tx-started');
|
||||
return updateStall.promise.then((v) => {
|
||||
callOrder.push('update-tx-resolved');
|
||||
return v;
|
||||
});
|
||||
}
|
||||
// complete: actually run the callback
|
||||
callOrder.push('complete-tx-started');
|
||||
return cb(manager);
|
||||
});
|
||||
|
||||
const svc = makeService({ transaction: txMock });
|
||||
|
||||
// Start update but DON'T await — it will stall
|
||||
const updateP = svc.update(recordId, { status: 'absent' });
|
||||
|
||||
// Give update time to enter the mutex and transaction
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
// Start complete while update is stalled
|
||||
const completeP = svc.completeLessonAttendance(sessionId, 1);
|
||||
|
||||
// Another tick
|
||||
await Promise.resolve();
|
||||
|
||||
// Resolve update's stalled transaction
|
||||
updateStall.resolve(record);
|
||||
|
||||
const updateResult = await updateP;
|
||||
expect(updateResult).toEqual(record);
|
||||
|
||||
const completeResult = await completeP;
|
||||
|
||||
// Verify the callback was actually run correctly
|
||||
expect(callOrder).toContain('update-tx-started');
|
||||
expect(callOrder).toContain('complete-tx-started');
|
||||
|
||||
expect(completeResult).toHaveProperty('session');
|
||||
expect(completeResult).toHaveProperty('records');
|
||||
});
|
||||
|
||||
it('records without attendanceSessionId bypass the mutex and keep original behaviour', async () => {
|
||||
const noSessionRecord = { ...record, attendanceSessionId: null };
|
||||
|
||||
const overrides = {
|
||||
findOne: jest.fn().mockResolvedValue(noSessionRecord),
|
||||
save: jest.fn().mockImplementation((r: unknown) => Promise.resolve(r)),
|
||||
};
|
||||
|
||||
const txMock = jest.fn();
|
||||
const svc = makeService({ transaction: txMock }, overrides);
|
||||
|
||||
const result = await svc.update(recordId, { status: 'absent' });
|
||||
expect(result).toEqual(noSessionRecord);
|
||||
expect(txMock).not.toHaveBeenCalled();
|
||||
|
||||
const removeResult = await svc.remove(recordId);
|
||||
expect(removeResult).toEqual({ deleted: true });
|
||||
});
|
||||
|
||||
it('complete is idempotent: returns current state when session already completed', async () => {
|
||||
const manager = makeTxManager('completed');
|
||||
|
||||
const txMock = jest
|
||||
.fn()
|
||||
.mockImplementation((cb: (m: unknown) => unknown) => cb(manager));
|
||||
|
||||
const svc = makeService({ transaction: txMock });
|
||||
|
||||
const result = await svc.completeLessonAttendance(sessionId, 1);
|
||||
|
||||
expect(result).toHaveProperty('session');
|
||||
expect(result).toHaveProperty('records');
|
||||
expect(manager.recordRepo.count).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ClassTeacher, ScheduleType, StudentDingMapping } from '../entities';
|
||||
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual, DataSource } from 'typeorm';
|
||||
import {
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
DingAttendanceRaw,
|
||||
Class,
|
||||
Student,
|
||||
ClassSchedule,
|
||||
ClassStudent,
|
||||
ClassTeacher,
|
||||
ScheduleType,
|
||||
StudentDingMapping,
|
||||
} from '../entities';
|
||||
import {
|
||||
BatchCreateAttendanceDto,
|
||||
AttendanceSummaryQueryDto,
|
||||
@@ -18,6 +25,26 @@ import {
|
||||
GenerateFromSchedulesDto,
|
||||
} from './dto/attendance.dto';
|
||||
|
||||
/** Keyed mutex serializing operations on the same attendance session. */
|
||||
class SessionMutex {
|
||||
private queueTails = new Map<number, Promise<void>>();
|
||||
|
||||
async runExclusive<T>(sessionId: number, fn: () => Promise<T>): Promise<T> {
|
||||
const tail = this.queueTails.get(sessionId) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const newTail = new Promise<void>((resolve) => { release = resolve; });
|
||||
this.queueTails.set(sessionId, newTail);
|
||||
await tail;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
release();
|
||||
if (this.queueTails.get(sessionId) === newTail) {
|
||||
this.queueTails.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@Injectable()
|
||||
export class AttendanceService {
|
||||
constructor(
|
||||
@@ -37,8 +64,13 @@ export class AttendanceService {
|
||||
private studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
@InjectRepository(ClassTeacher)
|
||||
private classTeacherRepo: Repository<ClassTeacher>,
|
||||
@InjectRepository(AttendanceSession)
|
||||
private attendanceSessionRepo: Repository<AttendanceSession>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
private sessionMutex = new SessionMutex();
|
||||
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
if (canManageAll) return undefined;
|
||||
const assignments = await this.classTeacherRepo.find({ where: { userId } });
|
||||
@@ -112,13 +144,315 @@ export class AttendanceService {
|
||||
return userIds.sort();
|
||||
}
|
||||
|
||||
private async getScheduleOccurrence(scheduleId: number, lessonDate: string) {
|
||||
const schedule = await this.scheduleRepo.findOne({ where: { id: scheduleId } });
|
||||
if (!schedule) throw new NotFoundException('排课记录不存在');
|
||||
if (schedule.scheduleType !== ScheduleType.INTERNAL || schedule.status !== 'active') {
|
||||
throw new BadRequestException('该排课不能进行课程考勤');
|
||||
}
|
||||
if (schedule.classId == null) throw new BadRequestException('该排课未关联班级');
|
||||
if (lessonDate < schedule.startDate || lessonDate > schedule.endDate) {
|
||||
throw new BadRequestException('所选日期不在排课有效期内');
|
||||
}
|
||||
const date = new Date(`${lessonDate}T00:00:00`);
|
||||
const weekDay = date.getDay() === 0 ? 7 : date.getDay();
|
||||
if (weekDay !== schedule.weekDay) throw new BadRequestException('所选日期不是该课程的上课日');
|
||||
return schedule;
|
||||
}
|
||||
|
||||
async getLessonAttendance(scheduleId: number, lessonDate: string) {
|
||||
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
|
||||
const session = await this.attendanceSessionRepo.findOne({
|
||||
where: { scheduleId, lessonDate },
|
||||
});
|
||||
const records = session
|
||||
? await this.attendanceRepo.find({
|
||||
where: { attendanceSessionId: session.id },
|
||||
relations: ['student'],
|
||||
order: { studentId: 'ASC' },
|
||||
})
|
||||
: [];
|
||||
return { schedule, session, records };
|
||||
}
|
||||
|
||||
private selectDingTalkRecordsForLesson(
|
||||
records: DingAttendanceRaw[],
|
||||
lessonDate: string,
|
||||
startTime: string,
|
||||
endTime: string,
|
||||
): DingAttendanceRaw[] {
|
||||
const [startHour, startMinute] = startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = endTime.split(':').map(Number);
|
||||
const start = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
|
||||
let end = new Date(`${lessonDate}T${endTime}:00+08:00`).getTime();
|
||||
if (endHour * 60 + endMinute <= startHour * 60 + startMinute) end += 24 * 60 * 60 * 1000;
|
||||
const windowStart = start - 3 * 60 * 60 * 1000;
|
||||
const windowEnd = end + 3 * 60 * 60 * 1000;
|
||||
const timed = records.filter((record) => {
|
||||
const time = record.checkInTime ?? record.checkOutTime;
|
||||
return time && time.getTime() >= windowStart && time.getTime() <= windowEnd;
|
||||
});
|
||||
return timed.length > 0 ? timed : records.filter((record) => !record.checkInTime && !record.checkOutTime);
|
||||
}
|
||||
|
||||
private mapDingTalkStatus(records: DingAttendanceRaw[]): string {
|
||||
const results = new Set(records.map((record) => record.timeResult?.toLowerCase()));
|
||||
if (results.has('late') || results.has('seriouslate')) return 'late';
|
||||
if (
|
||||
results.has('notsigned') ||
|
||||
results.has('absenteeism') ||
|
||||
results.has('absent')
|
||||
) {
|
||||
return 'absent';
|
||||
}
|
||||
if (results.has('leave') || results.has('vacation')) return 'leave';
|
||||
if (results.has('normal')) return 'present';
|
||||
return 'pending';
|
||||
}
|
||||
async createLessonAttendanceFromDingTalk(
|
||||
scheduleId: number,
|
||||
lessonDate: string,
|
||||
userId: number,
|
||||
) {
|
||||
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
|
||||
const now = new Date();
|
||||
const today = [
|
||||
now.getFullYear(),
|
||||
String(now.getMonth() + 1).padStart(2, '0'),
|
||||
String(now.getDate()).padStart(2, '0'),
|
||||
].join('-');
|
||||
if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤');
|
||||
if (lessonDate === today) {
|
||||
const [hour, minute] = schedule.startTime.split(':').map(Number);
|
||||
const startMinute = hour * 60 + minute;
|
||||
const currentMinute = now.getHours() * 60 + now.getMinutes();
|
||||
if (currentMinute < startMinute) {
|
||||
throw new BadRequestException('课程尚未开始,不能拉取考勤');
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await this.attendanceSessionRepo.findOne({
|
||||
where: { scheduleId, lessonDate },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === 'completed') {
|
||||
const records = await this.attendanceRepo.find({
|
||||
where: { attendanceSessionId: existing.id },
|
||||
relations: ['student'],
|
||||
order: { studentId: 'ASC' },
|
||||
});
|
||||
return { schedule, session: existing, records };
|
||||
}
|
||||
|
||||
// Refresh in_progress session from latest DingTalk data
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
|
||||
const existingRecords = await recordRepo.find({
|
||||
where: { attendanceSessionId: existing.id },
|
||||
order: { studentId: 'ASC' },
|
||||
});
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId: schedule.classId!, status: 'active' },
|
||||
relations: ['student'],
|
||||
});
|
||||
const studentsById = new Map(
|
||||
classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
|
||||
);
|
||||
const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
|
||||
|
||||
const updatedRecords = existingRecords.map((record) => {
|
||||
record.student = studentsById.get(record.studentId)!;
|
||||
// Preserve manually corrected records.
|
||||
if (record.source !== 'dingtalk') return record;
|
||||
|
||||
const raw = this.selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(record.studentId) ?? [],
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
schedule.endTime,
|
||||
);
|
||||
record.status = this.mapDingTalkStatus(raw);
|
||||
record.remark = raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : null;
|
||||
return record;
|
||||
});
|
||||
for (const classStudent of classStudents) {
|
||||
if (existingStudentIds.has(classStudent.studentId)) continue;
|
||||
const raw = this.selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(classStudent.studentId) ?? [],
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
schedule.endTime,
|
||||
);
|
||||
updatedRecords.push(
|
||||
recordRepo.create({
|
||||
studentId: classStudent.studentId,
|
||||
student: classStudent.student,
|
||||
classId: schedule.classId!,
|
||||
scheduleId,
|
||||
attendanceSessionId: existing.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||
status: this.mapDingTalkStatus(raw),
|
||||
source: 'dingtalk',
|
||||
remark: raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const saved = await recordRepo.save(updatedRecords);
|
||||
return { schedule, session: existing, records: saved };
|
||||
});
|
||||
}
|
||||
|
||||
// First pull: create session and records atomically
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const sessionRepo = manager.getRepository(AttendanceSession);
|
||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
|
||||
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId: schedule.classId!, status: 'active' },
|
||||
relations: ['student'],
|
||||
});
|
||||
if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生');
|
||||
|
||||
let session: AttendanceSession;
|
||||
try {
|
||||
session = await sessionRepo.save(
|
||||
sessionRepo.create({
|
||||
scheduleId,
|
||||
classId: schedule.classId!,
|
||||
lessonDate,
|
||||
status: 'in_progress',
|
||||
startedBy: userId,
|
||||
startedAt: new Date(),
|
||||
}),
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const code = (err as Record<string, unknown>).code;
|
||||
const errno = (err as Record<string, unknown>).errno;
|
||||
// MySQL: ER_DUP_ENTRY or errno 1062; SQLite: SQLITE_CONSTRAINT
|
||||
if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') {
|
||||
const existing = await sessionRepo.findOne({
|
||||
where: { scheduleId, lessonDate },
|
||||
});
|
||||
if (existing) {
|
||||
session = existing;
|
||||
const existingRecords = await recordRepo.find({
|
||||
where: { attendanceSessionId: session.id },
|
||||
relations: ['student'],
|
||||
order: { studentId: 'ASC' },
|
||||
});
|
||||
return { schedule, session, records: existingRecords };
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const records = classStudents.map((classStudent) => {
|
||||
const raw = this.selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(classStudent.studentId) ?? [],
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
schedule.endTime,
|
||||
);
|
||||
return recordRepo.create({
|
||||
studentId: classStudent.studentId,
|
||||
student: classStudent.student,
|
||||
classId: schedule.classId!,
|
||||
scheduleId,
|
||||
attendanceSessionId: session.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||
status: this.mapDingTalkStatus(raw),
|
||||
source: 'dingtalk',
|
||||
remark: raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : undefined,
|
||||
});
|
||||
});
|
||||
const saved = await recordRepo.save(records);
|
||||
return { schedule, session, records: saved };
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchDingTalkRawByStudent(
|
||||
classId: number,
|
||||
lessonDate: string,
|
||||
): Promise<Map<number, DingAttendanceRaw[]>> {
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId, status: 'active' },
|
||||
});
|
||||
if (classStudents.length === 0) return new Map();
|
||||
const studentIds = classStudents.map((cs) => cs.studentId);
|
||||
const rawRecords = await this.dingRawRepo.find({
|
||||
where: {
|
||||
attendanceDate: lessonDate,
|
||||
matchedStudentId: In(studentIds),
|
||||
},
|
||||
});
|
||||
const rawByStudent = new Map<number, DingAttendanceRaw[]>();
|
||||
for (const raw of rawRecords) {
|
||||
if (raw.matchedStudentId == null) continue;
|
||||
const arr = rawByStudent.get(raw.matchedStudentId) ?? [];
|
||||
arr.push(raw);
|
||||
rawByStudent.set(raw.matchedStudentId, arr);
|
||||
}
|
||||
return rawByStudent;
|
||||
}
|
||||
|
||||
async completeLessonAttendance(sessionId: number, userId: number) {
|
||||
return this.sessionMutex.runExclusive(sessionId, () =>
|
||||
this.dataSource.transaction(async (manager) => {
|
||||
const sessionRepo = manager.getRepository(AttendanceSession);
|
||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||
|
||||
const session = await sessionRepo.findOne({ where: { id: sessionId } });
|
||||
if (!session) throw new NotFoundException('课程考勤场次不存在');
|
||||
|
||||
// Re-check under lock: if already completed, return current state idempotently
|
||||
if (session.status === 'completed') {
|
||||
const records = await recordRepo.find({
|
||||
where: { attendanceSessionId: sessionId },
|
||||
relations: ['student'],
|
||||
order: { studentId: 'ASC' },
|
||||
});
|
||||
return { session, records };
|
||||
}
|
||||
|
||||
const pendingRecords = await recordRepo.count({
|
||||
where: { attendanceSessionId: sessionId, status: 'pending' },
|
||||
});
|
||||
if (pendingRecords > 0) {
|
||||
throw new BadRequestException('存在未处理的考勤记录,无法完成考勤');
|
||||
}
|
||||
|
||||
session.status = 'completed';
|
||||
session.completedBy = userId;
|
||||
session.completedAt = new Date();
|
||||
const savedSession = await sessionRepo.save(session);
|
||||
const records = await recordRepo.find({
|
||||
where: { attendanceSessionId: sessionId },
|
||||
relations: ['student'],
|
||||
order: { studentId: 'ASC' },
|
||||
});
|
||||
return { session: savedSession, records };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async findAttendanceSession(id: number) {
|
||||
const session = await this.attendanceSessionRepo.findOne({ where: { id } });
|
||||
if (!session) throw new NotFoundException('课程考勤场次不存在');
|
||||
return session;
|
||||
}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
async batchCreate(dto: BatchCreateAttendanceDto) {
|
||||
if (!dto.records || dto.records.length === 0) {
|
||||
throw new BadRequestException('records array must not be empty');
|
||||
}
|
||||
|
||||
|
||||
const entities = dto.records.map((r) => {
|
||||
const entity = this.attendanceRepo.create({
|
||||
studentId: r.studentId,
|
||||
@@ -209,7 +543,9 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
// ── Generate attendance records from schedules (optional date range, defaults to current week) ──
|
||||
async generateFromSchedules(dto: GenerateFromSchedulesDto): Promise<{ count: number; records: AttendanceRecord[] }> {
|
||||
async generateFromSchedules(
|
||||
dto: GenerateFromSchedulesDto,
|
||||
): Promise<{ count: number; records: AttendanceRecord[] }> {
|
||||
const { classId, startDate, endDate } = dto;
|
||||
|
||||
// Default to current week (Monday–Sunday)
|
||||
@@ -233,7 +569,6 @@ export class AttendanceService {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
private mapScheduleTimeToSession(startTime: string): string {
|
||||
const hour = parseInt(startTime.slice(0, 2), 10);
|
||||
if (hour < 8) return 'morning_reading';
|
||||
@@ -243,14 +578,15 @@ export class AttendanceService {
|
||||
return 'night_check';
|
||||
}
|
||||
|
||||
|
||||
// ── Attendance summary ──
|
||||
async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return { total: 0, present: 0, late: 0, absent: 0, leave: 0, presentRate: 0 };
|
||||
} else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0)
|
||||
return { total: 0, present: 0, late: 0, absent: 0, leave: 0, presentRate: 0 };
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
@@ -337,27 +673,32 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
// ── List attendance records with filters ──
|
||||
async findAll(query: {
|
||||
classId?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
session?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}, accessibleClassIds?: number[]) {
|
||||
async findAll(
|
||||
query: {
|
||||
classId?: number;
|
||||
scheduleId?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
session?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
accessibleClassIds?: number[],
|
||||
) {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 20;
|
||||
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
|
||||
qb.leftJoinAndSelect('ar.student', 'student')
|
||||
.leftJoinAndSelect('ar.class', 'class');
|
||||
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
|
||||
if (query.scheduleId) {
|
||||
qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId });
|
||||
}
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
} else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
@@ -391,7 +732,6 @@ export class AttendanceService {
|
||||
.select('DISTINCT ar.classId', 'classId')
|
||||
.where('ar.classId IS NOT NULL');
|
||||
|
||||
|
||||
const rows = accessibleClassIds
|
||||
? accessibleClassIds.map((classId) => ({ classId }))
|
||||
: await qb.orderBy('ar.classId', 'ASC').getRawMany();
|
||||
@@ -432,7 +772,9 @@ export class AttendanceService {
|
||||
const mappings = await this.studentDingMappingRepo.find({
|
||||
where: { studentId: In(studentIds) },
|
||||
});
|
||||
const dingUserIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
|
||||
const dingUserIds = [
|
||||
...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean)),
|
||||
];
|
||||
if (dingUserIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||
qb.andWhere('ar.dingUserId IN (:...dingUserIds)', { dingUserIds });
|
||||
}
|
||||
@@ -488,22 +830,23 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
// ── Export all attendance records with filters (no pagination) ──
|
||||
async findAllForExport(query: {
|
||||
classId?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
session?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
}, accessibleClassIds?: number[]) {
|
||||
async findAllForExport(
|
||||
query: {
|
||||
classId?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
session?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
},
|
||||
accessibleClassIds?: number[],
|
||||
) {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
|
||||
qb.leftJoinAndSelect('ar.student', 'student')
|
||||
.leftJoinAndSelect('ar.class', 'class');
|
||||
qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class');
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
} else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
@@ -528,34 +871,85 @@ export class AttendanceService {
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
// ── Update a single attendance record ──
|
||||
async update(id: number, dto: UpdateAttendanceRecordDto) {
|
||||
async findAttendanceRecord(id: number) {
|
||||
const record = await this.attendanceRepo.findOne({ where: { id } });
|
||||
if (!record) {
|
||||
throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||||
}
|
||||
|
||||
|
||||
if (dto.status !== undefined) {
|
||||
record.status = dto.status;
|
||||
}
|
||||
if (dto.remark !== undefined) {
|
||||
record.remark = dto.remark;
|
||||
}
|
||||
|
||||
return this.attendanceRepo.save(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
// ── Delete a single attendance record ──
|
||||
async remove(id: number) {
|
||||
const record = await this.attendanceRepo.findOne({ where: { id } });
|
||||
if (!record) {
|
||||
throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||||
// ── Update a single attendance record ──
|
||||
async update(id: number, dto: UpdateAttendanceRecordDto) {
|
||||
const record = await this.findAttendanceRecord(id);
|
||||
|
||||
// Records without a lesson session keep original behaviour
|
||||
if (record.attendanceSessionId == null) {
|
||||
if (dto.status !== undefined) {
|
||||
record.status = dto.status;
|
||||
record.source = 'manual';
|
||||
}
|
||||
if (dto.remark !== undefined) {
|
||||
record.remark = dto.remark;
|
||||
record.source = 'manual';
|
||||
}
|
||||
return this.attendanceRepo.save(record);
|
||||
}
|
||||
|
||||
return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
|
||||
this.dataSource.transaction(async (manager) => {
|
||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||
const sessionRepo = manager.getRepository(AttendanceSession);
|
||||
|
||||
await this.attendanceRepo.remove(record);
|
||||
return { deleted: true };
|
||||
// Re-check session status inside the transaction while holding the lock
|
||||
const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
|
||||
if (!session || session.status === 'completed') {
|
||||
throw new BadRequestException('已完成考勤的记录不允许修改或删除');
|
||||
}
|
||||
|
||||
const freshRecord = await recordRepo.findOne({ where: { id } });
|
||||
if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||||
|
||||
if (dto.status !== undefined) {
|
||||
freshRecord.status = dto.status;
|
||||
freshRecord.source = 'manual';
|
||||
}
|
||||
if (dto.remark !== undefined) {
|
||||
freshRecord.remark = dto.remark;
|
||||
freshRecord.source = 'manual';
|
||||
}
|
||||
return recordRepo.save(freshRecord);
|
||||
}),
|
||||
);
|
||||
}
|
||||
// ── Delete a single attendance record ──
|
||||
async remove(id: number) {
|
||||
const record = await this.findAttendanceRecord(id);
|
||||
|
||||
// Records without a lesson session keep original behaviour
|
||||
if (record.attendanceSessionId == null) {
|
||||
await this.attendanceRepo.remove(record);
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
return this.sessionMutex.runExclusive(record.attendanceSessionId, () =>
|
||||
this.dataSource.transaction(async (manager) => {
|
||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||
const sessionRepo = manager.getRepository(AttendanceSession);
|
||||
|
||||
// Re-check session status inside the transaction while holding the lock
|
||||
const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } });
|
||||
if (!session || session.status === 'completed') {
|
||||
throw new BadRequestException('已完成考勤的记录不允许修改或删除');
|
||||
}
|
||||
|
||||
const freshRecord = await recordRepo.findOne({ where: { id } });
|
||||
if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||||
|
||||
await recordRepo.remove(freshRecord);
|
||||
return { deleted: true };
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ── Class-based attendance report ──
|
||||
@@ -569,8 +963,7 @@ export class AttendanceService {
|
||||
.addSelect('COUNT(*)', 'count');
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
} else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
@@ -585,14 +978,17 @@ export class AttendanceService {
|
||||
const rawRows = await qb.getRawMany();
|
||||
|
||||
// Aggregate by class
|
||||
const classMap = new Map<number, {
|
||||
classId: number;
|
||||
className: string;
|
||||
present: number;
|
||||
absent: number;
|
||||
late: number;
|
||||
leave: number;
|
||||
}>();
|
||||
const classMap = new Map<
|
||||
number,
|
||||
{
|
||||
classId: number;
|
||||
className: string;
|
||||
present: number;
|
||||
absent: number;
|
||||
late: number;
|
||||
leave: number;
|
||||
}
|
||||
>();
|
||||
|
||||
for (const row of rawRows) {
|
||||
if (!row.classId) continue;
|
||||
@@ -637,9 +1033,10 @@ export class AttendanceService {
|
||||
.leftJoinAndSelect('a.student', 'student')
|
||||
.leftJoinAndSelect('a.class', 'class');
|
||||
|
||||
|
||||
qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr })
|
||||
.andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] });
|
||||
qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr }).andWhere(
|
||||
'a.status IN (:...statuses)',
|
||||
{ statuses: ['absent', 'late'] },
|
||||
);
|
||||
if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
qb.andWhere('a.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
@@ -650,11 +1047,15 @@ export class AttendanceService {
|
||||
.getMany();
|
||||
|
||||
const alerts: Array<{
|
||||
studentId: number; studentName: string; className: string;
|
||||
type: string; count: number; lastDate: string;
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
className: string;
|
||||
type: string;
|
||||
count: number;
|
||||
lastDate: string;
|
||||
}> = [];
|
||||
|
||||
let current: typeof alerts[0] | null = null;
|
||||
let current: (typeof alerts)[0] | null = null;
|
||||
for (const r of records) {
|
||||
const name = (r.student as any)?.name || '';
|
||||
const className = (r.class as any)?.name || '';
|
||||
@@ -664,10 +1065,17 @@ export class AttendanceService {
|
||||
if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate;
|
||||
} else {
|
||||
if (current && current.count >= threshold) alerts.push({ ...current });
|
||||
current = { studentId: r.studentId, studentName: name, className, type: status, count: 1, lastDate: r.attendanceDate };
|
||||
current = {
|
||||
studentId: r.studentId,
|
||||
studentName: name,
|
||||
className,
|
||||
type: status,
|
||||
count: 1,
|
||||
lastDate: r.attendanceDate,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (current && current.count >= threshold) alerts.push(current);
|
||||
return alerts;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,5 +55,33 @@ describe('DingTalkService — attendance records', () => {
|
||||
).rejects.toThrow('userIds');
|
||||
expect(global.fetch).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps DingTalk work dates in China local time', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
recordresult: [
|
||||
{
|
||||
id: 1,
|
||||
userId: 'ding-1',
|
||||
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
|
||||
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
|
||||
sourceType: 'USER',
|
||||
checkType: 'OnDuty',
|
||||
timeResult: 'Normal',
|
||||
},
|
||||
],
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
const [record] = await service.fetchAttendanceResults({
|
||||
startDate: '2026-07-12',
|
||||
endDate: '2026-07-12',
|
||||
userIds: ['ding-1'],
|
||||
});
|
||||
|
||||
expect(record.workDate).toBe('2026-07-12');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -111,6 +111,11 @@ export class QueryAttendanceRecordsDto {
|
||||
@Type(() => Number)
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
scheduleId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
@@ -204,3 +209,15 @@ export class GenerateFromSchedulesDto {
|
||||
@IsDateString()
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export class LessonAttendanceQueryDto {
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
date: string;
|
||||
}
|
||||
|
||||
export class StartLessonAttendanceDto {
|
||||
@IsDateString()
|
||||
@IsNotEmpty()
|
||||
date: string;
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface ImportProgressEvent {
|
||||
message: string;
|
||||
/** Error message (only when phase === 'error') */
|
||||
error?: string;
|
||||
/** ID of the user who triggered the import (undefined for non-HTTP callers) */
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Student, StudentDingMapping } from '../entities';
|
||||
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping } from '../entities';
|
||||
import { ClassesService } from './classes.service';
|
||||
import { ClassesController } from './classes.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule],
|
||||
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule],
|
||||
controllers: [ClassesController],
|
||||
providers: [ClassesService],
|
||||
exports: [ClassesService],
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
ClassTeacher,
|
||||
ClassSchedule,
|
||||
AttendanceRecord,
|
||||
AttendanceSession,
|
||||
Classroom,
|
||||
Student,
|
||||
StudentDingMapping,
|
||||
@@ -45,6 +47,8 @@ export class ClassesService {
|
||||
private scheduleRepo: Repository<ClassSchedule>,
|
||||
@InjectRepository(AttendanceRecord)
|
||||
private attendanceRepo: Repository<AttendanceRecord>,
|
||||
@InjectRepository(AttendanceSession)
|
||||
private attendanceSessionRepo: Repository<AttendanceSession>,
|
||||
@InjectRepository(Student)
|
||||
private studentRepo: Repository<Student>,
|
||||
@InjectRepository(StudentDingMapping)
|
||||
@@ -288,6 +292,16 @@ export class ClassesService {
|
||||
const cls = await this.classRepo.findOne({ where: { id } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
if (!cls.isArchived) throw new BadRequestException('请先归档再删除');
|
||||
|
||||
const sessionCount = await this.attendanceSessionRepo.count({
|
||||
where: { classId: id },
|
||||
});
|
||||
if (sessionCount > 0) {
|
||||
throw new ConflictException(
|
||||
`无法删除已产生 ${sessionCount} 个考勤场次的班级。请先取消或停用班级以保护历史考勤数据。`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.classRepo.remove(cls);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
380
apps/server/src/database/attendance-fk-restrict.spec.ts
Normal file
380
apps/server/src/database/attendance-fk-restrict.spec.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import Database from 'better-sqlite3';
|
||||
type SqliteDB = InstanceType<typeof Database>;
|
||||
|
||||
/**
|
||||
* Real SQLite foreign-key constraint tests.
|
||||
*
|
||||
* These tests use the `better-sqlite3` driver directly (in-memory) to verify
|
||||
* that ON DELETE RESTRICT is enforced at the database level, not just in
|
||||
* application-layer guards.
|
||||
*/
|
||||
describe('attendance_sessions FK RESTRICT — real SQLite', () => {
|
||||
let db: SqliteDB;
|
||||
|
||||
function createSchema(): void {
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS classes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
is_archived INTEGER DEFAULT 0
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS class_schedule (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
class_id INTEGER,
|
||||
week_day INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attendance_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
schedule_id INTEGER NOT NULL,
|
||||
class_id INTEGER NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
status TEXT DEFAULT 'in_progress',
|
||||
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
createSchema();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('blocks class deletion when attendance sessions reference it', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
|
||||
db.exec(
|
||||
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM classes WHERE id = 1');
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('allows class deletion when no attendance sessions reference it', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM classes WHERE id = 1');
|
||||
}).not.toThrow();
|
||||
|
||||
const remaining = db.prepare('SELECT COUNT(*) as cnt FROM classes').get() as {
|
||||
cnt: number;
|
||||
};
|
||||
expect(remaining.cnt).toBe(0);
|
||||
});
|
||||
|
||||
it('blocks schedule deletion when attendance sessions reference it', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
|
||||
db.exec(
|
||||
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
|
||||
);
|
||||
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM class_schedule WHERE id = 1');
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it('PRAGMA foreign_key_list confirms both FKs are present', () => {
|
||||
// Use raw SQL PRAGMA to avoid better-sqlite3 pragma API quirks
|
||||
const rows = db.prepare("PRAGMA foreign_key_list('attendance_sessions')").all() as Array<{
|
||||
id: number;
|
||||
seq: number;
|
||||
table: string;
|
||||
from: string;
|
||||
to: string;
|
||||
on_update: string;
|
||||
on_delete: string;
|
||||
match: string;
|
||||
}>;
|
||||
|
||||
expect(rows.length).toBe(2);
|
||||
|
||||
const scheduleFk = rows.find((fk) => fk.from === 'schedule_id');
|
||||
expect(scheduleFk).toBeDefined();
|
||||
expect(scheduleFk!.table).toBe('class_schedule');
|
||||
expect(scheduleFk!.on_delete).toBe('RESTRICT');
|
||||
|
||||
const classFk = rows.find((fk) => fk.from === 'class_id');
|
||||
expect(classFk).toBeDefined();
|
||||
expect(classFk!.table).toBe('classes');
|
||||
expect(classFk!.on_delete).toBe('RESTRICT');
|
||||
});
|
||||
|
||||
it('FK pragma respects ON DELETE RESTRICT for class_id — data survives failed delete', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
|
||||
db.exec(
|
||||
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
|
||||
);
|
||||
|
||||
// Verify the session exists
|
||||
const session = db
|
||||
.prepare('SELECT * FROM attendance_sessions WHERE class_id = 1')
|
||||
.get() as Record<string, unknown>;
|
||||
expect(session).toBeDefined();
|
||||
|
||||
// Delete should fail
|
||||
expect(() => db.exec('DELETE FROM classes WHERE id = 1')).toThrow();
|
||||
|
||||
// Session should still exist after failed delete
|
||||
const after = db
|
||||
.prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE class_id = 1')
|
||||
.get() as { cnt: number };
|
||||
expect(after.cnt).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Integration test: simulate the protectAttendanceHistory SQLite migration.
|
||||
*
|
||||
* Creates tables WITHOUT foreign keys (pre-migration state), inserts parent
|
||||
* session and child attendance_record, runs the table-rebuild migration
|
||||
* (PRAGMA foreign_keys=OFF, rebuild both tables, PRAGMA foreign_keys=ON,
|
||||
* foreign_key_check), then verifies:
|
||||
* - attendance_record.attendance_session_id is preserved
|
||||
* - RESTRICT still blocks class/schedule deletion
|
||||
*/
|
||||
describe('protectAttendanceHistory SQLite migration — integration', () => {
|
||||
let db: SqliteDB;
|
||||
|
||||
function createPreMigrationSchema(): void {
|
||||
// Schema WITHOUT foreign keys on attendance_sessions (pre-migration)
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS classes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
is_archived INTEGER DEFAULT 0
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS class_schedule (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
class_id INTEGER,
|
||||
week_day INTEGER NOT NULL
|
||||
)
|
||||
`);
|
||||
// attendance_sessions WITHOUT foreign keys
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attendance_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
schedule_id INTEGER NOT NULL,
|
||||
class_id INTEGER NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
status TEXT DEFAULT 'in_progress',
|
||||
started_by INTEGER,
|
||||
started_at DATETIME,
|
||||
completed_by INTEGER,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
// Legacy columns came first; course-attendance columns were appended later.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attendance_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
student_id INTEGER NOT NULL,
|
||||
class_id INTEGER,
|
||||
attendance_date DATE NOT NULL,
|
||||
session VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
remark VARCHAR(200),
|
||||
source VARCHAR(20) DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
schedule_id INTEGER,
|
||||
attendance_session_id INTEGER
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function runMigration(): void {
|
||||
// Step 1: PRAGMA foreign_keys = OFF outside transaction
|
||||
db.exec('PRAGMA foreign_keys = OFF');
|
||||
try {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
// Rebuild attendance_sessions with FKs
|
||||
db.exec(`
|
||||
CREATE TABLE attendance_sessions_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
schedule_id INTEGER NOT NULL,
|
||||
class_id INTEGER NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
|
||||
started_by INTEGER,
|
||||
started_at DATETIME,
|
||||
completed_by INTEGER,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
db.exec(
|
||||
'INSERT INTO attendance_sessions_new SELECT * FROM attendance_sessions',
|
||||
);
|
||||
db.exec('DROP TABLE attendance_sessions');
|
||||
db.exec(
|
||||
'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions',
|
||||
);
|
||||
db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
|
||||
);
|
||||
|
||||
// Rebuild attendance_records with FK on attendance_session_id
|
||||
const recordsFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_records')")
|
||||
.all() as Array<{ from: string }>;
|
||||
const hasSessionFk = recordsFk.some((r) => r.from === 'attendance_session_id');
|
||||
if (!hasSessionFk) {
|
||||
db.exec(`
|
||||
CREATE TABLE attendance_records_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
student_id INTEGER NOT NULL,
|
||||
class_id INTEGER,
|
||||
schedule_id INTEGER,
|
||||
attendance_session_id INTEGER,
|
||||
attendance_date DATE NOT NULL,
|
||||
session VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
remark VARCHAR(200),
|
||||
source VARCHAR(20) DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
db.exec(`
|
||||
INSERT INTO attendance_records_new (
|
||||
id, student_id, class_id, schedule_id, attendance_session_id,
|
||||
attendance_date, session, status, remark, source, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, student_id, class_id, schedule_id, attendance_session_id,
|
||||
attendance_date, session, status, remark, source, created_at, updated_at
|
||||
FROM attendance_records
|
||||
`);
|
||||
db.exec('DROP TABLE attendance_records');
|
||||
db.exec(
|
||||
'ALTER TABLE attendance_records_new RENAME TO attendance_records',
|
||||
);
|
||||
db.exec(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
|
||||
);
|
||||
}
|
||||
|
||||
db.exec('COMMIT');
|
||||
} catch (err) {
|
||||
db.exec('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
db.exec('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
|
||||
// Run foreign_key_check — should be clean
|
||||
const checkRows = db.prepare('PRAGMA foreign_key_check').all();
|
||||
if (checkRows.length > 0) {
|
||||
throw new Error(
|
||||
`外键一致性检查失败: ${checkRows.length} 行违反外键约束`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
createPreMigrationSchema();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
db.close();
|
||||
});
|
||||
|
||||
it('preserves attendance_record.session_id after migration', () => {
|
||||
db.exec("INSERT INTO classes (id, name) VALUES (1, 'Test Class')");
|
||||
db.exec("INSERT INTO class_schedule (id, class_id, week_day) VALUES (1, 1, 1)");
|
||||
db.exec(
|
||||
"INSERT INTO attendance_sessions (id, schedule_id, class_id, lesson_date) VALUES (1, 1, 1, '2026-01-01')",
|
||||
);
|
||||
db.exec(
|
||||
"INSERT INTO attendance_records (id, student_id, class_id, attendance_session_id, attendance_date, session, status) VALUES (1, 1, 1, 1, '2026-01-01', 'morning', 'present')",
|
||||
);
|
||||
|
||||
// Verify pre-migration state
|
||||
const preSessionFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_sessions')")
|
||||
.all();
|
||||
expect(preSessionFk.length).toBe(0);
|
||||
|
||||
const preRecordsFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_records')")
|
||||
.all();
|
||||
expect(preRecordsFk.length).toBe(0);
|
||||
|
||||
// Run migration
|
||||
runMigration();
|
||||
|
||||
// Verify attendance_record still has correct attendance_session_id
|
||||
const record = db
|
||||
.prepare('SELECT * FROM attendance_records WHERE id = 1')
|
||||
.get() as Record<string, unknown>;
|
||||
expect(record).toBeDefined();
|
||||
expect(record.attendance_session_id).toBe(1);
|
||||
expect(record.attendance_date).toBe('2026-01-01');
|
||||
expect(record.session).toBe('morning');
|
||||
expect(record.status).toBe('present');
|
||||
|
||||
// Verify FKs now exist on both tables
|
||||
const postSessionFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_sessions')")
|
||||
.all();
|
||||
expect(postSessionFk.length).toBe(2);
|
||||
|
||||
const postRecordsFk = db
|
||||
.prepare("PRAGMA foreign_key_list('attendance_records')")
|
||||
.all() as Array<{ from: string; table: string; on_delete: string }>;
|
||||
const sessionFk = postRecordsFk.find((r) => r.from === 'attendance_session_id');
|
||||
expect(sessionFk).toBeDefined();
|
||||
expect(sessionFk!.table).toBe('attendance_sessions');
|
||||
expect(sessionFk!.on_delete).toBe('SET NULL');
|
||||
|
||||
// RESTRICT still blocks class/schedule deletion
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM classes WHERE id = 1');
|
||||
}).toThrow();
|
||||
expect(() => {
|
||||
db.exec('DELETE FROM class_schedule WHERE id = 1');
|
||||
}).toThrow();
|
||||
|
||||
// Verify data survived the failed deletes
|
||||
const sessionAfter = db
|
||||
.prepare('SELECT COUNT(*) as cnt FROM attendance_sessions WHERE id = 1')
|
||||
.get() as { cnt: number };
|
||||
expect(sessionAfter.cnt).toBe(1);
|
||||
|
||||
const recordAfter = db
|
||||
.prepare('SELECT COUNT(*) as cnt FROM attendance_records WHERE id = 1')
|
||||
.get() as { cnt: number };
|
||||
expect(recordAfter.cnt).toBe(1);
|
||||
|
||||
const classAfter = db
|
||||
.prepare('SELECT COUNT(*) as cnt FROM classes WHERE id = 1')
|
||||
.get() as { cnt: number };
|
||||
expect(classAfter.cnt).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DataSource, QueryRunner } from 'typeorm';
|
||||
import { uuidV7 } from '../common/uuid-v7';
|
||||
|
||||
@Injectable()
|
||||
@@ -10,8 +10,10 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
await this.ensureAiConfigTable();
|
||||
await this.ensureCourseAttendanceSchema();
|
||||
await this.backfillOrganizations();
|
||||
await this.normalizeClassDates();
|
||||
await this.protectAttendanceHistory();
|
||||
}
|
||||
|
||||
private async ensureAiConfigTable(): Promise<void> {
|
||||
@@ -100,6 +102,70 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureCourseAttendanceSchema(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const tables = await runner.getTables(['attendance_records', 'attendance_sessions']);
|
||||
const tableNames = new Set(tables.map((table) => table.name));
|
||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
||||
|
||||
if (!tableNames.has('attendance_sessions')) {
|
||||
const pkDef = isMySQL
|
||||
? 'id INTEGER PRIMARY KEY AUTO_INCREMENT'
|
||||
: 'id INTEGER PRIMARY KEY AUTOINCREMENT';
|
||||
await runner.query(`
|
||||
CREATE TABLE attendance_sessions (
|
||||
${pkDef},
|
||||
schedule_id INTEGER NOT NULL,
|
||||
class_id INTEGER NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
|
||||
started_by INTEGER,
|
||||
started_at DATETIME,
|
||||
completed_by INTEGER,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
const attendanceTable = await runner.getTable('attendance_records');
|
||||
const columnNames = new Set(attendanceTable?.columns.map((column) => column.name) ?? []);
|
||||
if (!columnNames.has('schedule_id')) {
|
||||
await runner.query('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER');
|
||||
}
|
||||
if (!columnNames.has('attendance_session_id')) {
|
||||
await runner.query(
|
||||
'ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER',
|
||||
);
|
||||
}
|
||||
|
||||
const createIndex = async (sql: string) => {
|
||||
try {
|
||||
await runner.query(sql);
|
||||
} catch {
|
||||
// Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.
|
||||
}
|
||||
};
|
||||
await createIndex(
|
||||
isMySQL
|
||||
? 'CREATE UNIQUE INDEX uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)'
|
||||
: 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
|
||||
);
|
||||
await createIndex(
|
||||
isMySQL
|
||||
? 'CREATE UNIQUE INDEX uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)'
|
||||
: 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
|
||||
);
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async backfillOrganizations(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
@@ -246,4 +312,191 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
const affected = typeof result?.changes === 'number' ? result.changes : result?.affectedRows;
|
||||
if (affected) this.logger.log(`已规范化 ${affected} 条班级日期数据`);
|
||||
}
|
||||
private async protectAttendanceHistory(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const tables = await runner.getTables(['attendance_sessions']);
|
||||
if (tables.length === 0) return;
|
||||
|
||||
const isMySQL = this.dataSource.options.type === 'mysql';
|
||||
if (isMySQL) {
|
||||
await this.migrateMySQLAttendanceFKs(runner);
|
||||
} else {
|
||||
await this.migrateSQLiteAttendanceFKs(runner);
|
||||
}
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateMySQLAttendanceFKs(runner: QueryRunner): Promise<void> {
|
||||
// Drop any existing FK constraint on schedule_id or class_id
|
||||
const fkColumns = ['schedule_id', 'class_id'];
|
||||
for (const col of fkColumns) {
|
||||
const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(`
|
||||
SELECT CONSTRAINT_NAME
|
||||
FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'attendance_sessions'
|
||||
AND COLUMN_NAME = ?
|
||||
AND REFERENCED_TABLE_NAME IS NOT NULL
|
||||
`, [col]);
|
||||
|
||||
for (const row of fkRows) {
|
||||
try {
|
||||
await runner.query(
|
||||
`ALTER TABLE attendance_sessions DROP FOREIGN KEY \`${row.CONSTRAINT_NAME}\``,
|
||||
);
|
||||
this.logger.log(`已移除考勤场次 FK 约束: ${row.CONSTRAINT_NAME}`);
|
||||
} catch {
|
||||
// constraint may have already been dropped
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const constraints: Array<{ name: string; col: string; ref: string }> = [
|
||||
{ name: 'fk_as_schedule_protect', col: 'schedule_id', ref: 'class_schedule(id)' },
|
||||
{ name: 'fk_as_class_protect', col: 'class_id', ref: 'classes(id)' },
|
||||
];
|
||||
for (const c of constraints) {
|
||||
// Only skip if RESTRICT constraint is already confirmed via information_schema
|
||||
const existing: Array<{ DELETE_RULE: string }> = await runner.query(`
|
||||
SELECT DELETE_RULE
|
||||
FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS
|
||||
WHERE CONSTRAINT_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'attendance_sessions'
|
||||
AND CONSTRAINT_NAME = ?
|
||||
`, [c.name]);
|
||||
|
||||
if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') {
|
||||
this.logger.log(`考勤场次删除保护约束已存在: ${c.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// ADD RESTRICT must throw on failure — no catch
|
||||
await runner.query(`
|
||||
ALTER TABLE attendance_sessions
|
||||
ADD CONSTRAINT ${c.name}
|
||||
FOREIGN KEY (${c.col}) REFERENCES ${c.ref}
|
||||
ON DELETE RESTRICT
|
||||
`);
|
||||
this.logger.log(`已添加考勤场次删除保护约束: ${c.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
private async migrateSQLiteAttendanceFKs(runner: QueryRunner): Promise<void> {
|
||||
// SQLite cannot ALTER TABLE to add foreign keys.
|
||||
// Rebuild the table inside a transaction: create a new table with FK constraints,
|
||||
// copy all rows, drop old, rename new, then recreate indexes.
|
||||
const fkRows: Array<{ id: number }> = await runner.query(
|
||||
"PRAGMA foreign_key_list('attendance_sessions')",
|
||||
);
|
||||
if (fkRows.length > 0) return; // FKs already present
|
||||
|
||||
this.logger.log('正在重建 attendance_sessions 表以添加外键保护…');
|
||||
|
||||
// PRAGMA foreign_keys=OFF must be issued outside the transaction
|
||||
await runner.query('PRAGMA foreign_keys = OFF');
|
||||
try {
|
||||
await runner.query('BEGIN');
|
||||
try {
|
||||
await runner.query(`
|
||||
CREATE TABLE attendance_sessions_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
schedule_id INTEGER NOT NULL,
|
||||
class_id INTEGER NOT NULL,
|
||||
lesson_date DATE NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'in_progress',
|
||||
started_by INTEGER,
|
||||
started_at DATETIME,
|
||||
completed_by INTEGER,
|
||||
completed_at DATETIME,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT
|
||||
)
|
||||
`);
|
||||
await runner.query(`
|
||||
INSERT INTO attendance_sessions_new (
|
||||
id, schedule_id, class_id, lesson_date, status,
|
||||
started_by, started_at, completed_by, completed_at, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, schedule_id, class_id, lesson_date, status,
|
||||
started_by, started_at, completed_by, completed_at, created_at, updated_at
|
||||
FROM attendance_sessions
|
||||
`);
|
||||
await runner.query('DROP TABLE attendance_sessions');
|
||||
await runner.query(
|
||||
'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions',
|
||||
);
|
||||
await runner.query(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)',
|
||||
);
|
||||
|
||||
// Rebuild attendance_records to add/protect FK on attendance_session_id
|
||||
const recordsFk = await runner.query(
|
||||
"PRAGMA foreign_key_list('attendance_records')",
|
||||
);
|
||||
const hasSessionFk = recordsFk.some(
|
||||
(r: { from: string }) => r.from === 'attendance_session_id',
|
||||
);
|
||||
if (!hasSessionFk) {
|
||||
await runner.query(`
|
||||
CREATE TABLE attendance_records_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
student_id INTEGER NOT NULL,
|
||||
class_id INTEGER,
|
||||
schedule_id INTEGER,
|
||||
attendance_session_id INTEGER,
|
||||
attendance_date DATE NOT NULL,
|
||||
session VARCHAR(20) NOT NULL,
|
||||
status VARCHAR(20) NOT NULL,
|
||||
remark VARCHAR(200),
|
||||
source VARCHAR(20) DEFAULT 'manual',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (attendance_session_id) REFERENCES attendance_sessions(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
await runner.query(`
|
||||
INSERT INTO attendance_records_new (
|
||||
id, student_id, class_id, schedule_id, attendance_session_id,
|
||||
attendance_date, session, status, remark, source, created_at, updated_at
|
||||
)
|
||||
SELECT
|
||||
id, student_id, class_id, schedule_id, attendance_session_id,
|
||||
attendance_date, session, status, remark, source, created_at, updated_at
|
||||
FROM attendance_records
|
||||
`);
|
||||
await runner.query('DROP TABLE attendance_records');
|
||||
await runner.query(
|
||||
'ALTER TABLE attendance_records_new RENAME TO attendance_records',
|
||||
);
|
||||
await runner.query(
|
||||
'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)',
|
||||
);
|
||||
}
|
||||
|
||||
// Verify foreign key integrity BEFORE committing the transaction.
|
||||
// If violations exist, the transaction rolls back and old tables are preserved.
|
||||
const checkRows = await runner.query('PRAGMA foreign_key_check');
|
||||
if (checkRows.length > 0) {
|
||||
throw new Error(
|
||||
`外键一致性检查失败: ${checkRows.length} 行违反外键约束`,
|
||||
);
|
||||
}
|
||||
|
||||
await runner.query('COMMIT');
|
||||
this.logger.log('attendance_sessions 表外键保护重建完成');
|
||||
} catch (err) {
|
||||
await runner.query('ROLLBACK');
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
await runner.query('PRAGMA foreign_keys = ON');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ function mockRunner(overrides: {
|
||||
} = {}) {
|
||||
const release = jest.fn();
|
||||
const connect = jest.fn();
|
||||
const query = jest.fn();
|
||||
const query = jest.fn().mockResolvedValue([]);
|
||||
const getTables = jest.fn().mockResolvedValue(overrides.getTables ?? []);
|
||||
const getTable = jest.fn().mockResolvedValue(
|
||||
overrides.getTable ?? { name: 'ai_config', columns: [] },
|
||||
@@ -31,19 +31,21 @@ function mockRunner(overrides: {
|
||||
return { release, connect, query, getTables, getTable };
|
||||
}
|
||||
|
||||
function createDataSource(runner: ReturnType<typeof mockRunner>) {
|
||||
function createDataSource(runner: ReturnType<typeof mockRunner>, dbType: string = 'better-sqlite3') {
|
||||
return {
|
||||
options: { type: 'better-sqlite3' },
|
||||
options: { type: dbType },
|
||||
createQueryRunner: jest.fn().mockReturnValue(runner),
|
||||
transaction: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
// Type to reach the private ensureAiConfigTable for testing
|
||||
// Type to reach private migration methods for testing
|
||||
interface MigrationsPrivate {
|
||||
ensureAiConfigTable(): Promise<void>;
|
||||
backfillOrganizations(): Promise<void>;
|
||||
normalizeClassDates(): Promise<void>;
|
||||
ensureCourseAttendanceSchema(): Promise<void>;
|
||||
protectAttendanceHistory(): Promise<void>;
|
||||
}
|
||||
|
||||
describe('DatabaseMigrationsService — ensureAiConfigTable', () => {
|
||||
@@ -176,3 +178,251 @@ describe('DatabaseMigrationsService — bootstrap failure handling', () => {
|
||||
expect(normalize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatabaseMigrationsService — course attendance schema', () => {
|
||||
it('adds schedule linkage columns to an existing attendance_records table', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [
|
||||
{ name: 'attendance_records', columns: [{ name: 'id' }] },
|
||||
{ name: 'attendance_sessions', columns: [{ name: 'id' }] },
|
||||
],
|
||||
getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
|
||||
});
|
||||
await bootstrapCourseAttendance(runner);
|
||||
|
||||
await service.ensureCourseAttendanceSchema();
|
||||
|
||||
expect(runner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN schedule_id INTEGER'),
|
||||
);
|
||||
expect(runner.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('ALTER TABLE attendance_records ADD COLUMN attendance_session_id INTEGER'),
|
||||
);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates attendance_sessions with FK RESTRICT constraints when table is missing', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_records', columns: [{ name: 'id' }] }],
|
||||
getTable: { name: 'attendance_records', columns: [{ name: 'id' }] },
|
||||
});
|
||||
await bootstrapCourseAttendance(runner);
|
||||
await service.ensureCourseAttendanceSchema();
|
||||
|
||||
const createSql: string = (runner.query as jest.Mock).mock.calls
|
||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''))
|
||||
.find((s: string) => s.includes('CREATE TABLE attendance_sessions')) ?? '';
|
||||
expect(createSql).toContain('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT');
|
||||
expect(createSql).toContain('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DatabaseMigrationsService — protectAttendanceHistory', () => {
|
||||
let service: MigrationsPrivate & DatabaseMigrationsService;
|
||||
|
||||
async function bootstrap(runner: ReturnType<typeof mockRunner>, dbType: string = 'better-sqlite3') {
|
||||
const dataSource = createDataSource(runner, dbType);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DatabaseMigrationsService,
|
||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & MigrationsPrivate;
|
||||
}
|
||||
|
||||
it('skips when attendance_sessions table is absent', async () => {
|
||||
const runner = mockRunner({ getTables: [] });
|
||||
await bootstrap(runner);
|
||||
await service.protectAttendanceHistory();
|
||||
expect(runner.query).not.toHaveBeenCalled();
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SQLite: exits early when FKs already exist', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
||||
});
|
||||
runner.query.mockResolvedValueOnce([{ id: 0 }]); // PRAGMA foreign_key_list returns rows
|
||||
await bootstrap(runner);
|
||||
await service.protectAttendanceHistory();
|
||||
|
||||
// Should not run any TABLE creation (rebuild)
|
||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
||||
expect(queries.filter((q: string) => q.includes('CREATE TABLE'))).toHaveLength(0);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SQLite: rebuilds table with FK constraints when FKs are absent', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
||||
});
|
||||
// PRAGMA foreign_key_list for attendance_sessions → empty
|
||||
runner.query.mockResolvedValueOnce([]);
|
||||
// PRAGMA foreign_key_list for attendance_records → also empty (no FK yet)
|
||||
runner.query.mockResolvedValueOnce([]);
|
||||
await bootstrap(runner);
|
||||
await service.protectAttendanceHistory();
|
||||
|
||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
||||
|
||||
// PRAGMA foreign_keys = OFF outside the transaction
|
||||
expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = OFF'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_sessions_new'))).toBe(true);
|
||||
expect(queries.some((q: string) =>
|
||||
q.includes('FOREIGN KEY (schedule_id) REFERENCES class_schedule(id) ON DELETE RESTRICT')
|
||||
)).toBe(true);
|
||||
expect(queries.some((q: string) =>
|
||||
q.includes('FOREIGN KEY (class_id) REFERENCES classes(id) ON DELETE RESTRICT')
|
||||
)).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('INSERT INTO attendance_sessions_new'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('DROP TABLE attendance_sessions'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('RENAME TO attendance_sessions'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('uq_attendance_session_schedule_date'))).toBe(true);
|
||||
// attendance_records rebuilt with FK
|
||||
expect(queries.some((q: string) => q.includes('CREATE TABLE attendance_records_new'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('INSERT INTO attendance_records_new'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('DROP TABLE attendance_records'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('uq_attendance_session_student'))).toBe(true);
|
||||
// PRAGMA foreign_keys restored to ON and foreign_key_check runs
|
||||
expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('PRAGMA foreign_key_check'))).toBe(true);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('SQLite: rolls back transaction when foreign_key_check finds violations', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
||||
});
|
||||
// Use mockImplementation to match by SQL content, not call position
|
||||
runner.query.mockImplementation((sql: string) => {
|
||||
if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_list')) {
|
||||
return Promise.resolve([]); // FKs absent → trigger rebuild
|
||||
}
|
||||
if (typeof sql === 'string' && sql.includes('PRAGMA foreign_key_check')) {
|
||||
return Promise.resolve([
|
||||
{ table: 'attendance_sessions', rowid: 42, parent: 'class_schedule', fkid: 0 },
|
||||
]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
await bootstrap(runner);
|
||||
|
||||
await expect(service.protectAttendanceHistory()).rejects.toThrow(
|
||||
/外键一致性检查失败/,
|
||||
);
|
||||
|
||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
||||
|
||||
// The transaction should have been rolled back (ROLLBACK called)
|
||||
expect(queries.some((q: string) => q.includes('ROLLBACK'))).toBe(true);
|
||||
// COMMIT should NOT have been called
|
||||
expect(queries.some((q: string) => q.trim() === 'COMMIT')).toBe(false);
|
||||
// PRAGMA foreign_keys should still be restored
|
||||
expect(queries.some((q: string) => q.includes('PRAGMA foreign_keys = ON'))).toBe(true);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
it('MySQL: drops old FKs and recreates both schedule_id and class_id as RESTRICT', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
||||
});
|
||||
// Mock: override SELECT CONSTRAINT_NAME and REFERENTIAL_CONSTRAINTS queries
|
||||
runner.query.mockImplementation((sql: string, params?: string[]) => {
|
||||
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
|
||||
if (params?.[0] === 'schedule_id') {
|
||||
return Promise.resolve([{ CONSTRAINT_NAME: 'fk_schedule_cascade' }]);
|
||||
}
|
||||
if (params?.[0] === 'class_id') {
|
||||
return Promise.resolve([{ CONSTRAINT_NAME: 'fk_class_cascade' }]);
|
||||
}
|
||||
}
|
||||
// REFERENTIAL_CONSTRAINTS check — constraint does not yet exist
|
||||
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
await bootstrap(runner, 'mysql');
|
||||
await service.protectAttendanceHistory();
|
||||
|
||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
||||
|
||||
// Drops old FKs
|
||||
expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_schedule_cascade`'))).toBe(true);
|
||||
expect(queries.some((q: string) => q.includes('DROP FOREIGN KEY `fk_class_cascade`'))).toBe(true);
|
||||
// Checks REFERENTIAL_CONSTRAINTS before ADD
|
||||
expect(queries.some((q: string) =>
|
||||
q.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')
|
||||
)).toBe(true);
|
||||
// Creates new RESTRICT FKs
|
||||
expect(queries.some((q: string) =>
|
||||
q.includes('ADD CONSTRAINT fk_as_schedule_protect') && q.includes('ON DELETE RESTRICT')
|
||||
)).toBe(true);
|
||||
expect(queries.some((q: string) =>
|
||||
q.includes('ADD CONSTRAINT fk_as_class_protect') && q.includes('ON DELETE RESTRICT')
|
||||
)).toBe(true);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MySQL: throws when ADD CONSTRAINT RESTRICT fails', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
||||
});
|
||||
const addError = new Error('Cannot add foreign key constraint');
|
||||
runner.query.mockImplementation((sql: string, params?: string[]) => {
|
||||
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
if (typeof sql === 'string' && sql.includes('ADD CONSTRAINT')) {
|
||||
return Promise.reject(addError);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
await bootstrap(runner, 'mysql');
|
||||
await expect(service.protectAttendanceHistory()).rejects.toThrow('Cannot add foreign key constraint');
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('MySQL: skips ADD when RESTRICT constraint already confirmed via information_schema', async () => {
|
||||
const runner = mockRunner({
|
||||
getTables: [{ name: 'attendance_sessions', columns: [{ name: 'id' }] }],
|
||||
});
|
||||
runner.query.mockImplementation((sql: string, params?: string[]) => {
|
||||
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.KEY_COLUMN_USAGE')) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
// REFERENTIAL_CONSTRAINTS confirms RESTRICT already present
|
||||
if (typeof sql === 'string' && sql.includes('INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS')) {
|
||||
return Promise.resolve([{ DELETE_RULE: 'RESTRICT' }]);
|
||||
}
|
||||
return Promise.resolve([]);
|
||||
});
|
||||
await bootstrap(runner, 'mysql');
|
||||
await service.protectAttendanceHistory();
|
||||
|
||||
const queries: string[] = (runner.query as jest.Mock).mock.calls
|
||||
.map((c: unknown[]) => (typeof c[0] === 'string' ? c[0] : ''));
|
||||
|
||||
// No ADD CONSTRAINT calls
|
||||
expect(queries.filter((q: string) => q.includes('ADD CONSTRAINT')).length).toBe(0);
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
async function bootstrapCourseAttendance(runner: ReturnType<typeof mockRunner>) {
|
||||
const dataSource = createDataSource(runner);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DatabaseMigrationsService,
|
||||
{ provide: getDataSourceToken(), useValue: dataSource },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & MigrationsPrivate;
|
||||
}
|
||||
|
||||
@@ -47,12 +47,6 @@ export class DepositsController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get('pending-refunds')
|
||||
@RequirePermission('deposit:edit')
|
||||
findPendingRefunds() {
|
||||
return this.service.findPendingRefunds();
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
@RequirePermission('deposit:view')
|
||||
getStats() {
|
||||
@@ -165,7 +159,7 @@ export class DepositsController {
|
||||
}
|
||||
|
||||
@Put(':id/refund')
|
||||
@RequirePermission('deposit:edit')
|
||||
@RequirePermission('deposit:refund')
|
||||
async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.refund(+id, dto, req.user?.id);
|
||||
@@ -195,67 +189,6 @@ export class DepositsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post(':id/request-refund')
|
||||
@RequirePermission('deposit:edit')
|
||||
async requestRefund(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.requestRefund(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '申请退款',
|
||||
targetId: +id,
|
||||
targetType: 'deposit',
|
||||
detail: '提交退款申请',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/approve-refund')
|
||||
@RequirePermission('deposit:approve')
|
||||
async approveRefund(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.approveRefund(+id, req.user?.id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '审批退款',
|
||||
targetId: +id,
|
||||
targetType: 'deposit',
|
||||
detail: `审批通过 → ${result.refundStatus}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/reject-refund')
|
||||
@RequirePermission('deposit:approve')
|
||||
async rejectRefund(
|
||||
@Param('id') id: string,
|
||||
@Body() body: { reason: string },
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.rejectRefund(+id, body.reason, req.user?.id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '押金管理',
|
||||
action: '驳回退款',
|
||||
targetId: +id,
|
||||
targetType: 'deposit',
|
||||
detail: `驳回原因:${body.reason}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('deposit:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
|
||||
@@ -114,79 +114,13 @@ export class DepositsService {
|
||||
deposit.status =
|
||||
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
|
||||
if (dto.notes) deposit.notes = dto.notes;
|
||||
|
||||
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
|
||||
// ---- Refund approval flow ----
|
||||
|
||||
async requestRefund(id: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
if (deposit.status !== 'paid') throw new BadRequestException('该押金已处理');
|
||||
if (deposit.refundStatus) throw new BadRequestException('已提交退款申请,请等待审批');
|
||||
|
||||
deposit.refundStatus = 'pending';
|
||||
deposit.refundRequestedAt = new Date();
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
|
||||
async approveRefund(id: number, userId: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
|
||||
if (!deposit.refundStatus || deposit.refundStatus === 'refunded') {
|
||||
throw new BadRequestException('未找到待审批的退款申请');
|
||||
}
|
||||
|
||||
const transitions: Record<string, string> = {
|
||||
pending: 'head_teacher_approved',
|
||||
head_teacher_approved: 'finance_approved',
|
||||
finance_approved: 'refunded',
|
||||
};
|
||||
|
||||
const nextStatus = transitions[deposit.refundStatus];
|
||||
if (!nextStatus) throw new BadRequestException(`无效的退款状态: ${deposit.refundStatus}`);
|
||||
|
||||
deposit.refundStatus = nextStatus;
|
||||
deposit.refundApprovedBy = userId;
|
||||
deposit.refundStatus = 'refunded';
|
||||
deposit.refundApprovedBy = userId ?? null as unknown as number;
|
||||
deposit.refundApprovedAt = new Date();
|
||||
|
||||
if (nextStatus === 'refunded') {
|
||||
deposit.status = 'refunded';
|
||||
deposit.refundDate = new Date().toISOString().slice(0, 10);
|
||||
deposit.refundAmount = Number(deposit.amount) - Number(deposit.deductionAmount || 0);
|
||||
}
|
||||
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
|
||||
async rejectRefund(id: number, reason: string, userId: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
|
||||
if (!deposit.refundStatus || deposit.refundStatus === 'refunded') {
|
||||
throw new BadRequestException('未找到待审批的退款申请');
|
||||
}
|
||||
|
||||
deposit.refundStatus = null as unknown as string;
|
||||
deposit.refundApprovedBy = userId;
|
||||
deposit.refundApprovedAt = new Date();
|
||||
deposit.refundRejectedReason = reason;
|
||||
return this.repo.save(deposit);
|
||||
}
|
||||
async findPendingRefunds() {
|
||||
return this.repo.find({
|
||||
where: [
|
||||
{ refundStatus: 'pending' },
|
||||
{ refundStatus: 'head_teacher_approved' },
|
||||
],
|
||||
relations: ['student', 'installments'],
|
||||
order: { refundRequestedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id } });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { Class } from './class.entity';
|
||||
import { ClassSchedule } from './class-schedule.entity';
|
||||
import { AttendanceSession } from './attendance-session.entity';
|
||||
|
||||
@Entity('attendance_records')
|
||||
@Index(['classId', 'attendanceDate'])
|
||||
@Index(['studentId', 'attendanceDate'])
|
||||
@Index(['attendanceSessionId', 'studentId'], { unique: true })
|
||||
export class AttendanceRecord {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
@@ -32,6 +35,23 @@ export class AttendanceRecord {
|
||||
@JoinColumn({ name: 'class_id' })
|
||||
class: Class;
|
||||
|
||||
@Column({ name: 'schedule_id', type: 'integer', nullable: true })
|
||||
scheduleId: number | null;
|
||||
|
||||
@ManyToOne(() => ClassSchedule, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'schedule_id' })
|
||||
schedule: ClassSchedule | null;
|
||||
|
||||
@Column({ name: 'attendance_session_id', type: 'integer', nullable: true })
|
||||
attendanceSessionId: number | null;
|
||||
|
||||
@ManyToOne(() => AttendanceSession, (session) => session.records, {
|
||||
onDelete: 'SET NULL',
|
||||
nullable: true,
|
||||
})
|
||||
@JoinColumn({ name: 'attendance_session_id' })
|
||||
attendanceSession: AttendanceSession | null;
|
||||
|
||||
@Column({ name: 'attendance_date', type: 'date' })
|
||||
attendanceDate: string;
|
||||
|
||||
@@ -41,8 +61,8 @@ export class AttendanceRecord {
|
||||
@Column({ length: 20 })
|
||||
status: string;
|
||||
|
||||
@Column({ length: 200, nullable: true })
|
||||
remark: string;
|
||||
@Column({ type: 'varchar', length: 200, nullable: true })
|
||||
remark: string | null;
|
||||
|
||||
@Column({ name: 'source', length: 20, default: 'manual' })
|
||||
source: string;
|
||||
|
||||
62
apps/server/src/entities/attendance-session.entity.ts
Normal file
62
apps/server/src/entities/attendance-session.entity.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { ClassSchedule } from './class-schedule.entity';
|
||||
import { Class } from './class.entity';
|
||||
import { AttendanceRecord } from './attendance-record.entity';
|
||||
|
||||
@Entity('attendance_sessions')
|
||||
@Index(['scheduleId', 'lessonDate'], { unique: true })
|
||||
export class AttendanceSession {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'schedule_id', type: 'integer' })
|
||||
scheduleId: number;
|
||||
|
||||
@ManyToOne(() => ClassSchedule, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'schedule_id' })
|
||||
schedule: ClassSchedule;
|
||||
|
||||
@Column({ name: 'class_id', type: 'integer' })
|
||||
classId: number;
|
||||
|
||||
@ManyToOne(() => Class, { onDelete: 'RESTRICT' })
|
||||
@JoinColumn({ name: 'class_id' })
|
||||
class: Class;
|
||||
|
||||
@Column({ name: 'lesson_date', type: 'date' })
|
||||
lessonDate: string;
|
||||
|
||||
@Column({ length: 20, default: 'in_progress' })
|
||||
status: string;
|
||||
|
||||
@Column({ name: 'started_by', type: 'integer', nullable: true })
|
||||
startedBy: number | null;
|
||||
|
||||
@Column({ name: 'started_at', type: 'datetime', nullable: true })
|
||||
startedAt: Date | null;
|
||||
|
||||
@Column({ name: 'completed_by', type: 'integer', nullable: true })
|
||||
completedBy: number | null;
|
||||
|
||||
@Column({ name: 'completed_at', type: 'datetime', nullable: true })
|
||||
completedAt: Date | null;
|
||||
|
||||
@OneToMany(() => AttendanceRecord, (record) => record.attendanceSession)
|
||||
records: AttendanceRecord[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export { ClassStudent } from './class-student.entity';
|
||||
export { ClassTeacher, TeacherRoleType } from './class-teacher.entity';
|
||||
export { ClassSchedule, ScheduleType } from './class-schedule.entity';
|
||||
export { AttendanceRecord } from './attendance-record.entity';
|
||||
export { AttendanceSession } from './attendance-session.entity';
|
||||
export { DingAttendanceRaw } from './ding-attendance-raw.entity';
|
||||
export { SyncLog } from './sync-log.entity';
|
||||
export { SyncState } from './sync-state.entity';
|
||||
|
||||
53
apps/server/src/integration/dingtalk.group-delete.spec.ts
Normal file
53
apps/server/src/integration/dingtalk.group-delete.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { DingTalkService } from './dingtalk.service';
|
||||
|
||||
describe('DingTalkService attendance group deletion', () => {
|
||||
const originalAppKey = process.env.DINGTALK_APP_KEY;
|
||||
const originalAppSecret = process.env.DINGTALK_APP_SECRET;
|
||||
let service: DingTalkService;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.DINGTALK_APP_KEY = 'test-app-key';
|
||||
process.env.DINGTALK_APP_SECRET = 'test-app-secret';
|
||||
service = new DingTalkService({} as never, {} as never);
|
||||
Object.assign(service, {
|
||||
accessToken: 'test-token',
|
||||
tokenExpiresAt: Date.now() + 3_600_000,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
global.fetch = undefined as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalAppKey === undefined) delete process.env.DINGTALK_APP_KEY;
|
||||
else process.env.DINGTALK_APP_KEY = originalAppKey;
|
||||
if (originalAppSecret === undefined) delete process.env.DINGTALK_APP_SECRET;
|
||||
else process.env.DINGTALK_APP_SECRET = originalAppSecret;
|
||||
});
|
||||
|
||||
it('converts groupId to groupKey before deleting the group', async () => {
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
json: jest.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok', result: 'group-key-1' }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
json: jest.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok', success: true }),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await service.deleteAttendanceGroup(123, 'manager');
|
||||
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.stringContaining('/topapi/attendance/groups/idtokey'),
|
||||
expect.objectContaining({ body: JSON.stringify({ op_user_id: 'manager', group_id: 123 }) }),
|
||||
);
|
||||
expect(global.fetch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.stringContaining('/topapi/attendance/group/delete'),
|
||||
expect.objectContaining({ body: JSON.stringify({ op_userid: 'manager', group_key: 'group-key-1' }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -480,7 +480,7 @@ export class DingTalkService {
|
||||
return records.map((r) => ({
|
||||
userId: r.userId,
|
||||
userName: '',
|
||||
workDate: new Date(r.workDate).toISOString().slice(0, 10),
|
||||
workDate: new Date(r.workDate + 8 * 60 * 60 * 1000).toISOString().slice(0, 10),
|
||||
timeResult: r.timeResult ?? r.sourceType ?? '',
|
||||
locationResult: r.locationResult ?? r.locationMethod ?? r.userAddress ?? '',
|
||||
planCheckTime: '',
|
||||
@@ -728,6 +728,47 @@ export class DingTalkService {
|
||||
return all;
|
||||
}
|
||||
|
||||
async deleteAttendanceGroup(groupId: number, opUserId = 'manager'): Promise<void> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
await this.rateLimit();
|
||||
const keyResponse = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/attendance/groups/idtokey?access_token=${token}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ op_user_id: opUserId, group_id: groupId }),
|
||||
},
|
||||
);
|
||||
const keyData = await keyResponse.json() as {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result?: string;
|
||||
};
|
||||
if (keyData.errcode !== 0 || !keyData.result) {
|
||||
throw new Error(`钉钉考勤组ID转换失败: ${keyData.errmsg} (code=${keyData.errcode})`);
|
||||
}
|
||||
|
||||
await this.rateLimit();
|
||||
const deleteResponse = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/attendance/group/delete?access_token=${token}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ op_userid: opUserId, group_key: keyData.result }),
|
||||
},
|
||||
);
|
||||
const deleteData = await deleteResponse.json() as {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
success?: boolean;
|
||||
};
|
||||
if (deleteData.errcode !== 0 || deleteData.success !== true) {
|
||||
throw new Error(`钉钉删除考勤组失败: ${deleteData.errmsg} (code=${deleteData.errcode})`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// 考勤排班 — 排班分配
|
||||
|
||||
@@ -126,7 +126,7 @@ export class RbacController {
|
||||
// ==================== 用户管理 ====================
|
||||
|
||||
@Get('users')
|
||||
@RequirePermission('user:view')
|
||||
@RequirePermission('user:view', 'teacher:view')
|
||||
getUsers(@Query('isArchived') isArchived?: string) {
|
||||
const archived = isArchived === 'true';
|
||||
return this.rbacService.findAllUsers(archived);
|
||||
@@ -301,7 +301,7 @@ export class RbacController {
|
||||
// ---- 教师工作台 ----
|
||||
|
||||
@Get('teacher-workspace')
|
||||
@RequirePermission('class:view')
|
||||
@RequirePermission('teacher-workspace:view')
|
||||
async getTeacherWorkspace(@Request() req: any) {
|
||||
return this.rbacService.getTeacherWorkspace(req.user?.id);
|
||||
}
|
||||
@@ -309,7 +309,7 @@ export class RbacController {
|
||||
// ---- 教师管理 ----
|
||||
|
||||
@Get('teachers')
|
||||
@RequirePermission('user:view')
|
||||
@RequirePermission('teacher:view')
|
||||
async getTeachers(
|
||||
@Query('search') search?: string,
|
||||
@Query('page') page?: string,
|
||||
@@ -323,7 +323,7 @@ export class RbacController {
|
||||
}
|
||||
|
||||
@Put('teachers/:id/profile')
|
||||
@RequirePermission('user:edit')
|
||||
@RequirePermission('teacher:edit')
|
||||
async updateTeacherProfile(
|
||||
@Param('id') id: string,
|
||||
@Body() profile: UpdateProfileDto,
|
||||
|
||||
@@ -7,32 +7,59 @@ function permissionsFor(roleCode: string): { groups: string[]; extras: string[]
|
||||
}
|
||||
|
||||
describe('preset role permissions', () => {
|
||||
it('gives teachers explicit workspace permissions without class/schedule delete privileges', () => {
|
||||
it('keeps teachers read-only in scheduling while preserving class attendance access', () => {
|
||||
const teacher = permissionsFor('teacher');
|
||||
|
||||
expect(teacher.groups).toEqual(['notification', 'profile']);
|
||||
expect(teacher.extras).toEqual(
|
||||
expect.arrayContaining([
|
||||
'student:view',
|
||||
'class:view',
|
||||
'teacher-workspace:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'attendance:create',
|
||||
'attendance:export',
|
||||
'attendance:self-edit',
|
||||
]),
|
||||
);
|
||||
expect(teacher.extras).not.toEqual(
|
||||
expect.arrayContaining(['class:delete', 'schedule:delete']),
|
||||
expect(teacher.extras).not.toContain('schedule:create');
|
||||
expect(teacher.extras).not.toContain('schedule:edit');
|
||||
expect(teacher.extras).not.toContain('schedule:delete');
|
||||
expect(teacher.extras).not.toContain('student:view');
|
||||
expect(teacher.extras).not.toContain('class:view');
|
||||
expect(teacher.extras).not.toContain('attendance:export');
|
||||
});
|
||||
|
||||
|
||||
it('gives academic administrators the complete teaching administration workflow', () => {
|
||||
const academic = permissionsFor('academic');
|
||||
expect(academic.groups).toEqual(
|
||||
expect.arrayContaining(['student', 'class', 'schedule', 'attendance', 'classroom']),
|
||||
);
|
||||
expect(academic.extras).toEqual(expect.arrayContaining(['sync:read', 'sync:trigger']));
|
||||
});
|
||||
|
||||
it('combines accommodation, expenses, bills and deposits in one operations role', () => {
|
||||
const accommodation = permissionsFor('accommodation_operations');
|
||||
expect(accommodation.groups).toEqual(
|
||||
expect.arrayContaining(['room', 'occupancy', 'expense', 'bill', 'deposit']),
|
||||
);
|
||||
expect(accommodation.extras).toContain('student:basic-view');
|
||||
});
|
||||
|
||||
it('keeps classroom rental operations separate from accommodation operations', () => {
|
||||
const classroomOperations = permissionsFor('classroom_operations');
|
||||
expect(classroomOperations.groups).toEqual(
|
||||
expect.arrayContaining(['classroom', 'rental', 'organization']),
|
||||
);
|
||||
expect(classroomOperations.groups).not.toEqual(expect.arrayContaining(['room', 'deposit']));
|
||||
});
|
||||
|
||||
it('limits system administrators to accounts, permissions, logs and integrations', () => {
|
||||
const systemAdmin = permissionsFor('system_admin');
|
||||
expect(systemAdmin.groups).toEqual(
|
||||
expect.arrayContaining(['user', 'role', 'log', 'integration', 'sync', 'ai']),
|
||||
);
|
||||
expect(systemAdmin.groups).not.toEqual(
|
||||
expect.arrayContaining(['student', 'schedule', 'attendance', 'expense']),
|
||||
);
|
||||
});
|
||||
|
||||
it('gives institution heads every read permission required by the classroom rental pages', () => {
|
||||
const role = permissionsFor('institution_head');
|
||||
expect(role.groups).toEqual(expect.arrayContaining(['classroom', 'rental', 'organization']));
|
||||
});
|
||||
|
||||
it('keeps roles without dashboard access off the dashboard', () => {
|
||||
expect(permissionsFor('teacher').groups).not.toContain('dashboard');
|
||||
expect(permissionsFor('institution_head').groups).not.toContain('dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { RbacService } from './rbac.service';
|
||||
|
||||
describe('RbacService seedData', () => {
|
||||
it('adds preset permissions to system roles without removing manually granted permissions', async () => {
|
||||
it('migrates the legacy teacher role and replaces broad permissions with the teaching matrix', async () => {
|
||||
const permissions = [
|
||||
{ id: 1, code: 'profile:view', name: '查看个人资料', group: 'profile' },
|
||||
{ id: 2, code: 'notification:view', name: '查看通知', group: 'notification' },
|
||||
@@ -10,27 +10,32 @@ describe('RbacService seedData', () => {
|
||||
{ id: 5, code: 'schedule:view', name: '查看排课', group: 'schedule' },
|
||||
{ id: 6, code: 'attendance:view', name: '查看考勤', group: 'attendance' },
|
||||
{ id: 7, code: 'attendance:create', name: '新增考勤', group: 'attendance' },
|
||||
{ id: 8, code: 'attendance:export', name: '导出考勤', group: 'attendance' },
|
||||
{ id: 8, code: 'teacher-workspace:view', name: '教师工作台', group: 'teacher-workspace' },
|
||||
{ id: 9, code: 'room:view', name: '查看宿舍', group: 'room' },
|
||||
{ id: 10, code: 'schedule:create', name: '新增排课', group: 'schedule' },
|
||||
];
|
||||
const teacherRole = {
|
||||
id: 1,
|
||||
name: '老师',
|
||||
description: '查看和管理本班学生',
|
||||
code: 'teacher',
|
||||
description: '旧角色',
|
||||
isSystem: true,
|
||||
permissions: [permissions[8]],
|
||||
status: 1,
|
||||
permissions: [permissions[2], permissions[3], permissions[8]],
|
||||
};
|
||||
|
||||
const permRepo = {
|
||||
findOne: jest.fn(
|
||||
async ({ where }: any) => permissions.find((p) => p.code === where.code) ?? null,
|
||||
async ({ where }: any) => permissions.find((permission) => permission.code === where.code) ?? null,
|
||||
),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn(async () => permissions),
|
||||
};
|
||||
const roleRepo = {
|
||||
findOne: jest.fn(async ({ where }: any) => (where.name === '老师' ? teacherRole : null)),
|
||||
findOne: jest.fn(async ({ where }: any) =>
|
||||
where.code === 'teacher' || where.name === '老师' ? teacherRole : null,
|
||||
),
|
||||
create: jest.fn((value) => ({ ...value, permissions: [] })),
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn(async () => [teacherRole]),
|
||||
@@ -50,8 +55,86 @@ describe('RbacService seedData', () => {
|
||||
|
||||
await service.seedData();
|
||||
|
||||
expect(teacherRole.name).toBe('任课老师');
|
||||
expect(teacherRole.permissions.map((permission) => permission.code)).toEqual(
|
||||
expect.arrayContaining(['room:view', 'profile:view', 'student:view', 'attendance:create']),
|
||||
expect.arrayContaining([
|
||||
'profile:view',
|
||||
'teacher-workspace:view',
|
||||
'schedule:view',
|
||||
'attendance:create',
|
||||
]),
|
||||
);
|
||||
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain(
|
||||
'schedule:create',
|
||||
);
|
||||
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('student:view');
|
||||
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('class:view');
|
||||
expect(teacherRole.permissions.map((permission) => permission.code)).not.toContain('room:view');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RbacService legacy role consolidation', () => {
|
||||
it('moves users from duplicate accommodation roles before deleting the duplicates', async () => {
|
||||
const permissions = [
|
||||
{ id: 1, code: 'profile:view', name: '查看个人资料', group: 'profile' },
|
||||
{ id: 2, code: 'room:view', name: '查看宿舍', group: 'room' },
|
||||
{ id: 3, code: 'expense:view', name: '查看费用', group: 'expense' },
|
||||
{ id: 4, code: 'student:basic-view', name: '学生基础信息', group: 'student-scope' },
|
||||
];
|
||||
const targetRole: any = {
|
||||
id: 10,
|
||||
name: '住宿运营管理员',
|
||||
code: 'accommodation_operations',
|
||||
description: '',
|
||||
isSystem: true,
|
||||
status: 1,
|
||||
permissions: [],
|
||||
users: [],
|
||||
};
|
||||
const legacyRole: any = {
|
||||
id: 11,
|
||||
name: '财务',
|
||||
code: 'finance',
|
||||
description: '',
|
||||
isSystem: true,
|
||||
status: 1,
|
||||
permissions: [],
|
||||
users: [{ id: 21 }],
|
||||
};
|
||||
const user: any = { id: 21, roles: [legacyRole] };
|
||||
const permRepo = {
|
||||
findOne: jest.fn(async ({ where }: any) => permissions.find((item) => item.code === where.code) ?? null),
|
||||
create: jest.fn((value) => value),
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn(async () => permissions),
|
||||
};
|
||||
const roleRepo = {
|
||||
findOne: jest.fn(async () => targetRole),
|
||||
create: jest.fn((value) => ({ ...value, permissions: [] })),
|
||||
save: jest.fn(async (value) => value),
|
||||
find: jest.fn(async () => [targetRole, legacyRole]),
|
||||
remove: jest.fn(async (value) => value),
|
||||
};
|
||||
const userRepo = {
|
||||
count: jest.fn(async () => 1),
|
||||
findOne: jest.fn(async () => user),
|
||||
save: jest.fn(async (value) => value),
|
||||
};
|
||||
const service = new RbacService(
|
||||
permRepo as never,
|
||||
roleRepo as never,
|
||||
userRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.seedData();
|
||||
|
||||
expect(user.roles).toEqual([targetRole]);
|
||||
expect(userRepo.save).toHaveBeenCalledWith(user);
|
||||
expect(roleRepo.remove).toHaveBeenCalledWith(legacyRole);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,11 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'dashboard:view', name: '查看数据面板', group: 'dashboard' },
|
||||
{ code: 'profile:view', name: '查看个人资料', group: 'profile' },
|
||||
{ code: 'notification:view', name: '查看通知', group: 'notification' },
|
||||
{ code: 'student:view', name: '查看学生', group: 'student' },
|
||||
{ code: 'student:view', name: '查看学生管理', group: 'student' },
|
||||
{ code: 'student:basic-view', name: '查看学生基础信息', group: 'student-scope' },
|
||||
{ code: 'teacher-workspace:view', name: '查看教师工作台', group: 'teacher-workspace' },
|
||||
{ code: 'teacher:view', name: '查看教师', group: 'teacher' },
|
||||
{ code: 'teacher:edit', name: '编辑教师', group: 'teacher' },
|
||||
{ code: 'student:create', name: '新增学生', group: 'student' },
|
||||
{ code: 'student:edit', name: '编辑学生', group: 'student' },
|
||||
{ code: 'student:delete', name: '删除学生', group: 'student' },
|
||||
@@ -46,7 +50,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'deposit:create', name: '新增押金', group: 'deposit' },
|
||||
{ code: 'deposit:edit', name: '编辑押金', group: 'deposit' },
|
||||
{ code: 'deposit:delete', name: '删除押金', group: 'deposit' },
|
||||
{ code: 'deposit:approve', name: '审批退款', group: 'deposit' },
|
||||
{ code: 'deposit:refund', name: '直接退还押金', group: 'deposit' },
|
||||
{ code: 'classroom:view', name: '查看教室', group: 'classroom' },
|
||||
{ code: 'classroom:create', name: '新增教室', group: 'classroom' },
|
||||
{ code: 'classroom:edit', name: '编辑教室', group: 'classroom' },
|
||||
@@ -80,7 +84,8 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'schedule:delete', name: '删除排课', group: 'schedule' },
|
||||
{ code: 'attendance:view', name: '查看考勤', group: 'attendance' },
|
||||
{ code: 'attendance:create', name: '新增考勤', group: 'attendance' },
|
||||
{ code: 'attendance:edit', name: '编辑考勤', group: 'attendance' },
|
||||
{ code: 'attendance:edit', name: '编辑全部考勤', group: 'attendance' },
|
||||
{ code: 'attendance:self-edit', name: '编辑任教班级考勤', group: 'attendance-scope' },
|
||||
{ code: 'attendance:export', name: '导出考勤', group: 'attendance' },
|
||||
{ code: 'attendance:generate', name: '按课表生成考勤', group: 'attendance' },
|
||||
{ code: 'learning:create', name: '创建学习任务', group: 'learning' },
|
||||
@@ -108,85 +113,39 @@ export const PRESET_ROLES: Array<{
|
||||
isSystem: boolean;
|
||||
permissionGroups: string[];
|
||||
extraPermissions?: string[];
|
||||
legacyNames?: string[];
|
||||
legacyCodes?: string[];
|
||||
}> = [
|
||||
{
|
||||
name: '超管',
|
||||
name: '超级管理员',
|
||||
code: 'super_admin',
|
||||
description: '系统超级管理员,拥有全部权限',
|
||||
description: '系统初始化、应急维护和全局权限处理',
|
||||
isSystem: true,
|
||||
permissionGroups: [],
|
||||
legacyNames: ['超管', 'super_admin'],
|
||||
},
|
||||
{
|
||||
name: '宿管老师',
|
||||
code: 'dormitory_supervisor',
|
||||
description: '管理宿舍相关业务',
|
||||
isSystem: true,
|
||||
permissionGroups: [
|
||||
'student',
|
||||
'room',
|
||||
'occupancy',
|
||||
'expense',
|
||||
'bill',
|
||||
'deposit',
|
||||
'log',
|
||||
'dashboard',
|
||||
'class',
|
||||
'schedule',
|
||||
'attendance',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '老师',
|
||||
name: '任课老师',
|
||||
code: 'teacher',
|
||||
description: '查看和管理本班学生',
|
||||
description: '查看自己的排课、今日课程和任教班级考勤',
|
||||
isSystem: true,
|
||||
permissionGroups: ['notification', 'profile'],
|
||||
extraPermissions: [
|
||||
'student:view',
|
||||
'class:view',
|
||||
'teacher-workspace:view',
|
||||
'schedule:view',
|
||||
'attendance:view',
|
||||
'attendance:create',
|
||||
'attendance:export',
|
||||
'attendance:self-edit',
|
||||
],
|
||||
legacyNames: ['老师'],
|
||||
},
|
||||
{
|
||||
name: '机构负责人',
|
||||
code: 'institution_head',
|
||||
description: '管理机构教室和课程',
|
||||
isSystem: true,
|
||||
permissionGroups: ['classroom', 'rental', 'organization', 'notification', 'profile'],
|
||||
},
|
||||
{
|
||||
name: '财务',
|
||||
code: 'finance',
|
||||
description: '管理费用、账单与押金',
|
||||
isSystem: true,
|
||||
permissionGroups: ['expense', 'bill', 'deposit', 'dashboard', 'notification', 'profile'],
|
||||
},
|
||||
{
|
||||
name: '宿管',
|
||||
code: 'dorm_manager',
|
||||
description: '管理宿舍入住与宿舍信息',
|
||||
name: '教务管理员',
|
||||
code: 'academic',
|
||||
description: '管理学生、班级、教师、全局排课和历史考勤',
|
||||
isSystem: true,
|
||||
permissionGroups: [
|
||||
'student',
|
||||
'room',
|
||||
'occupancy',
|
||||
'deposit',
|
||||
'dashboard',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: '教务',
|
||||
code: 'academic',
|
||||
description: '管理班级、排课、考勤、学习与考试',
|
||||
isSystem: true,
|
||||
permissionGroups: [
|
||||
'class',
|
||||
'schedule',
|
||||
'attendance',
|
||||
@@ -197,6 +156,59 @@ export const PRESET_ROLES: Array<{
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
extraPermissions: [
|
||||
'teacher-workspace:view',
|
||||
'teacher:view',
|
||||
'teacher:edit',
|
||||
'sync:read',
|
||||
'sync:trigger',
|
||||
],
|
||||
legacyNames: ['教务'],
|
||||
},
|
||||
{
|
||||
name: '住宿运营管理员',
|
||||
code: 'accommodation_operations',
|
||||
description: '管理宿舍、入住、住宿费用、账单、押金和退宿结算',
|
||||
isSystem: true,
|
||||
permissionGroups: [
|
||||
'room',
|
||||
'occupancy',
|
||||
'expense',
|
||||
'bill',
|
||||
'deposit',
|
||||
'dashboard',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
extraPermissions: ['student:basic-view'],
|
||||
legacyNames: ['宿管老师', '宿管', '财务'],
|
||||
legacyCodes: ['dormitory_supervisor', 'dorm_manager', 'finance'],
|
||||
},
|
||||
{
|
||||
name: '教室运营管理员',
|
||||
code: 'classroom_operations',
|
||||
description: '管理教室、教室排期、外部机构和租赁订单',
|
||||
isSystem: true,
|
||||
permissionGroups: ['classroom', 'rental', 'organization', 'notification', 'profile'],
|
||||
legacyNames: ['机构负责人'],
|
||||
legacyCodes: ['institution_head'],
|
||||
},
|
||||
{
|
||||
name: '系统管理员',
|
||||
code: 'system_admin',
|
||||
description: '管理账号、角色、日志、同步和系统配置',
|
||||
isSystem: true,
|
||||
permissionGroups: [
|
||||
'user',
|
||||
'role',
|
||||
'log',
|
||||
'integration',
|
||||
'sync',
|
||||
'ai',
|
||||
'department',
|
||||
'notification',
|
||||
'profile',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -215,6 +227,18 @@ export class RbacService {
|
||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||
) {}
|
||||
|
||||
private async findLegacyPresetRole(preset: (typeof PRESET_ROLES)[number]): Promise<Role | null> {
|
||||
for (const code of preset.legacyCodes ?? []) {
|
||||
const role = await this.roleRepo.findOne({ where: { code } });
|
||||
if (role) return role;
|
||||
}
|
||||
for (const name of preset.legacyNames ?? []) {
|
||||
const role = await this.roleRepo.findOne({ where: { name } });
|
||||
if (role) return role;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async seedData(): Promise<void> {
|
||||
// Step 1: 幂等插入所有权限点(先查后插,兼容 SQLite/MySQL)
|
||||
for (const p of PRESET_PERMISSIONS) {
|
||||
@@ -227,7 +251,10 @@ export class RbacService {
|
||||
|
||||
// Step 2: 幂等插入预置角色
|
||||
for (const r of PRESET_ROLES) {
|
||||
const exists = await this.roleRepo.findOne({ where: { name: r.name } });
|
||||
const exists =
|
||||
(await this.roleRepo.findOne({ where: { code: r.code } })) ||
|
||||
(await this.roleRepo.findOne({ where: { name: r.name } })) ||
|
||||
(await this.findLegacyPresetRole(r));
|
||||
if (!exists) {
|
||||
await this.roleRepo.save(
|
||||
this.roleRepo.create({
|
||||
@@ -239,14 +266,44 @@ export class RbacService {
|
||||
);
|
||||
}
|
||||
}
|
||||
const allRoles = await this.roleRepo.find({ relations: ['permissions'] });
|
||||
const allRoles = await this.roleRepo.find({ relations: ['permissions', 'users'] });
|
||||
|
||||
// Step 3: 构建角色-权限关联
|
||||
// Step 3: 合并旧角色并构建新的职责权限矩阵
|
||||
for (const preset of PRESET_ROLES) {
|
||||
const role = allRoles.find((r) => r.name === preset.name || r.code === preset.code);
|
||||
const matchesPreset = (role: Role) =>
|
||||
role.name === preset.name ||
|
||||
role.code === preset.code ||
|
||||
preset.legacyNames?.includes(role.name) ||
|
||||
preset.legacyCodes?.includes(role.code);
|
||||
const candidates = allRoles.filter(matchesPreset);
|
||||
const role = candidates.find((candidate) => candidate.code === preset.code) ?? candidates[0];
|
||||
if (!role) continue;
|
||||
if (role.code !== preset.code) {
|
||||
|
||||
const duplicateRoles = candidates.filter((candidate) => candidate.id !== role.id);
|
||||
if (duplicateRoles.length > 0) {
|
||||
for (const duplicate of duplicateRoles) {
|
||||
for (const relatedUser of duplicate.users ?? []) {
|
||||
const user = await this.userRepo.findOne({
|
||||
where: { id: relatedUser.id },
|
||||
relations: ['roles'],
|
||||
});
|
||||
if (!user) continue;
|
||||
const remainingRoles = (user.roles ?? []).filter(
|
||||
(assignedRole) => assignedRole.id !== duplicate.id && assignedRole.id !== role.id,
|
||||
);
|
||||
user.roles = [...remainingRoles, role];
|
||||
await this.userRepo.save(user);
|
||||
}
|
||||
await this.roleRepo.remove(duplicate);
|
||||
}
|
||||
}
|
||||
|
||||
if (role.code !== preset.code || role.name !== preset.name || role.description !== preset.description) {
|
||||
role.code = preset.code;
|
||||
role.name = preset.name;
|
||||
role.description = preset.description;
|
||||
role.isSystem = preset.isSystem;
|
||||
role.status = 1;
|
||||
await this.roleRepo.save(role);
|
||||
}
|
||||
|
||||
@@ -265,12 +322,11 @@ export class RbacService {
|
||||
);
|
||||
}
|
||||
|
||||
// 系统角色只补齐预置权限,不移除管理员手动授予的额外权限。
|
||||
// 这样新增权限(例如 profile:view)会自动补上,同时避免重启后覆盖人工配置。
|
||||
const currentIds = new Set(role.permissions.map((permission) => permission.id));
|
||||
const missingPerms = perms.filter((permission) => !currentIds.has(permission.id));
|
||||
if (missingPerms.length > 0) {
|
||||
role.permissions = [...role.permissions, ...missingPerms];
|
||||
// 系统预置角色必须严格遵循职责矩阵;额外授权请创建自定义角色叠加。
|
||||
const currentIds = role.permissions.map((permission) => permission.id).sort((a, b) => a - b);
|
||||
const targetIds = perms.map((permission) => permission.id).sort((a, b) => a - b);
|
||||
if (currentIds.join(',') !== targetIds.join(',')) {
|
||||
role.permissions = perms;
|
||||
await this.roleRepo.save(role);
|
||||
}
|
||||
}
|
||||
@@ -285,7 +341,7 @@ export class RbacService {
|
||||
passwordHash: hash,
|
||||
name: '管理员',
|
||||
});
|
||||
const superAdminRole = allRoles.find((r) => r.name === '超管');
|
||||
const superAdminRole = allRoles.find((r) => r.code === 'super_admin');
|
||||
if (superAdminRole) {
|
||||
adminUser.roles = [superAdminRole];
|
||||
}
|
||||
@@ -555,7 +611,6 @@ export class RbacService {
|
||||
.andWhere('cs.startDate <= :today', { today: todayStr })
|
||||
.andWhere('cs.endDate >= :today', { today: todayStr })
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.andWhere('cs.teacherId = :userId', { userId })
|
||||
.orderBy('cs.startTime', 'ASC')
|
||||
.getMany();
|
||||
|
||||
@@ -594,8 +649,8 @@ export class RbacService {
|
||||
async getTeachers(query?: { search?: string; page?: number; pageSize?: number }) {
|
||||
const page = query?.page || 1;
|
||||
const pageSize = query?.pageSize || 20;
|
||||
const teacherRoleCodes = ['teacher', 'class_teacher', 'dormitory_supervisor', 'super_admin'];
|
||||
const teacherRoleNames = ['老师', '班主任', '宿管老师', '超管'];
|
||||
const teacherRoleCodes = ['teacher', 'super_admin'];
|
||||
const teacherRoleNames = ['任课老师', '老师', '超级管理员', '超管'];
|
||||
|
||||
const qb = this.userRepo
|
||||
.createQueryBuilder('u')
|
||||
|
||||
54
apps/server/src/rbac/rbac.teacher-workspace.spec.ts
Normal file
54
apps/server/src/rbac/rbac.teacher-workspace.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { RbacService } from './rbac.service';
|
||||
|
||||
describe('RbacService getTeacherWorkspace', () => {
|
||||
it('loads today schedules for every assigned class without requiring schedule.teacherId', async () => {
|
||||
const queryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 12,
|
||||
classId: 8,
|
||||
classroomId: 3,
|
||||
teacherId: null,
|
||||
weekDay: new Date().getDay() || 7,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
subject: '数学',
|
||||
scheduleType: 'INTERNAL',
|
||||
},
|
||||
]),
|
||||
};
|
||||
const classTeacherRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
classId: 8,
|
||||
userId: 21,
|
||||
roleType: 'subject_teacher',
|
||||
subject: '数学',
|
||||
class: { id: 8, name: '一班', code: 'C001' },
|
||||
},
|
||||
]),
|
||||
};
|
||||
const classStudentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const classScheduleRepo = { createQueryBuilder: jest.fn().mockReturnValue(queryBuilder) };
|
||||
const service = new RbacService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
classStudentRepo as never,
|
||||
classTeacherRepo as never,
|
||||
classScheduleRepo as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const result = await service.getTeacherWorkspace(21);
|
||||
|
||||
expect(result.todaySchedules).toHaveLength(1);
|
||||
expect(queryBuilder.andWhere).not.toHaveBeenCalledWith('cs.teacherId = :userId', {
|
||||
userId: 21,
|
||||
});
|
||||
});
|
||||
});
|
||||
126
apps/server/src/schedules/schedules.controller.spec.ts
Normal file
126
apps/server/src/schedules/schedules.controller.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
const teacherRequest = {
|
||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
||||
headers: {},
|
||||
};
|
||||
|
||||
const scheduleDto = {
|
||||
classId: 8,
|
||||
classroomId: 3,
|
||||
weekDay: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '数学',
|
||||
};
|
||||
|
||||
describe('SchedulesController — class data scope', () => {
|
||||
const service = {
|
||||
getAccessibleClassIds: jest.fn(),
|
||||
assertClassAccess: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
getClassroomOccupancy: jest.fn(),
|
||||
maskScheduleOccupancy: jest.fn((schedule) => ({
|
||||
...schedule,
|
||||
id: null,
|
||||
classId: null,
|
||||
subject: '已占用',
|
||||
teacherId: null,
|
||||
notes: null,
|
||||
canViewDetails: false,
|
||||
})),
|
||||
checkConflict: jest.fn(),
|
||||
};
|
||||
const logService = { log: jest.fn().mockResolvedValue(undefined) };
|
||||
const notificationsService = { create: jest.fn() };
|
||||
const ability = { can: jest.fn().mockReturnValue(false) };
|
||||
const authzService = { abilityForRequest: jest.fn().mockReturnValue(ability) };
|
||||
let controller: SchedulesController;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
ability.can.mockReturnValue(false);
|
||||
service.getAccessibleClassIds.mockResolvedValue([8]);
|
||||
controller = new SchedulesController(
|
||||
service as unknown as SchedulesService,
|
||||
logService as unknown as OperationLogsService,
|
||||
notificationsService as unknown as NotificationsService,
|
||||
authzService as never,
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the requested class before creating a schedule', async () => {
|
||||
service.create.mockResolvedValue({ id: 1, ...scheduleDto });
|
||||
|
||||
await controller.create(scheduleDto, teacherRequest as never);
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
expect(service.create).toHaveBeenCalledWith(scheduleDto);
|
||||
});
|
||||
|
||||
it('checks both the current and destination class before moving a schedule', async () => {
|
||||
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto });
|
||||
service.update.mockResolvedValue({ id: 4, ...scheduleDto, classId: 9 });
|
||||
|
||||
await controller.update('4', { classId: 9 }, teacherRequest as never);
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenNthCalledWith(1, 21, 8, false);
|
||||
expect(service.assertClassAccess).toHaveBeenNthCalledWith(2, 21, 9, false);
|
||||
});
|
||||
|
||||
it('checks the owning class before returning full schedule details', async () => {
|
||||
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto });
|
||||
|
||||
await controller.findOne('4', teacherRequest as never);
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
});
|
||||
|
||||
it('checks the owning class before deleting a schedule', async () => {
|
||||
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto });
|
||||
service.remove.mockResolvedValue({ success: true });
|
||||
|
||||
await controller.remove('4', teacherRequest as never);
|
||||
|
||||
expect(service.assertClassAccess).toHaveBeenCalledWith(21, 8, false);
|
||||
expect(service.remove).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it('returns only masked occupancy blocks from the classroom occupancy endpoint', async () => {
|
||||
service.getClassroomOccupancy.mockResolvedValue([
|
||||
{ id: 2, classId: 99, subject: '英语', teacherId: 7, notes: '隐私', classroomId: 3 },
|
||||
]);
|
||||
|
||||
const result = await controller.getClassroomOccupancy('3', undefined, teacherRequest as never);
|
||||
|
||||
expect(result).toEqual([
|
||||
expect.objectContaining({
|
||||
id: null,
|
||||
classId: null,
|
||||
subject: '已占用',
|
||||
teacherId: null,
|
||||
notes: null,
|
||||
canViewDetails: false,
|
||||
}),
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain('英语');
|
||||
expect(JSON.stringify(result)).not.toContain('隐私');
|
||||
});
|
||||
|
||||
it('rejects records without a class instead of exposing full details to a scoped teacher', async () => {
|
||||
service.findOne.mockResolvedValue({ id: 4, ...scheduleDto, classId: null });
|
||||
|
||||
await expect(controller.findOne('4', teacherRequest as never)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -9,12 +9,10 @@ import {
|
||||
Query,
|
||||
UseGuards,
|
||||
Request,
|
||||
ForbiddenException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
AuthorizationService,
|
||||
CaslAction,
|
||||
SubjectName,
|
||||
} from '../authorization';
|
||||
import { AuthorizationService, CaslAction, SubjectName } from '../authorization';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import {
|
||||
CreateScheduleDto,
|
||||
@@ -25,7 +23,6 @@ import {
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationType } from '../entities/notification.entity';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
@@ -56,6 +53,22 @@ export class SchedulesController {
|
||||
);
|
||||
}
|
||||
|
||||
private assertClassAccess(req: { user: RequestUser }, classId: number) {
|
||||
return this.service.assertClassAccess(req.user.id, classId, this.canManageAllSchedules(req));
|
||||
}
|
||||
|
||||
private async getAuthorizedSchedule(id: number, req: { user: RequestUser }) {
|
||||
const schedule = await this.service.findOne(id);
|
||||
if (schedule.classId == null) {
|
||||
if (!this.canManageAllSchedules(req)) {
|
||||
throw new ForbiddenException('无权访问该排课详情');
|
||||
}
|
||||
return schedule;
|
||||
}
|
||||
await this.assertClassAccess(req, schedule.classId);
|
||||
return schedule;
|
||||
}
|
||||
|
||||
@Get('lookups')
|
||||
@RequirePermission('schedule:view')
|
||||
async getLookups(@Request() req: { user: RequestUser }) {
|
||||
@@ -99,14 +112,29 @@ export class SchedulesController {
|
||||
|
||||
@Get('classroom/:id/occupancy')
|
||||
@RequirePermission('schedule:view')
|
||||
getClassroomOccupancy(@Param('id') id: string, @Query('date') date?: string) {
|
||||
return this.service.getClassroomOccupancy(+id, date);
|
||||
async getClassroomOccupancy(
|
||||
@Param('id') id: string,
|
||||
@Query('date') date: string | undefined,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const schedules = await this.service.getClassroomOccupancy(+id, date);
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
this.canManageAllSchedules(req),
|
||||
);
|
||||
if (!classIds) return schedules.map((schedule) => ({ ...schedule, canViewDetails: true }));
|
||||
const allowed = new Set(classIds);
|
||||
return schedules.map((schedule) =>
|
||||
schedule.classId !== null && allowed.has(schedule.classId)
|
||||
? { ...schedule, canViewDetails: true }
|
||||
: this.service.maskScheduleOccupancy(schedule),
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('schedule:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(+id);
|
||||
async findOne(@Param('id') id: string, @Request() req: { user: RequestUser }) {
|
||||
return this.getAuthorizedSchedule(+id, req);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@@ -116,6 +144,7 @@ export class SchedulesController {
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.assertClassAccess(req as { user: RequestUser }, dto.classId);
|
||||
try {
|
||||
const result = await this.service.create(dto);
|
||||
await this.logService.log({
|
||||
@@ -152,7 +181,9 @@ export class SchedulesController {
|
||||
content: `教室${dto.classroomId} 周${dto.weekDay} ${dto.startTime}-${dto.endTime} 与已有排课冲突`,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Best-effort conflict notification must not hide the original conflict.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -166,7 +197,10 @@ export class SchedulesController {
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const existing = await this.service.findOne(+id);
|
||||
const existing = await this.getAuthorizedSchedule(+id, req as { user: RequestUser });
|
||||
if (dto.classId !== undefined && dto.classId !== existing.classId) {
|
||||
await this.assertClassAccess(req as { user: RequestUser }, dto.classId);
|
||||
}
|
||||
try {
|
||||
const result = await this.service.update(+id, dto);
|
||||
await this.logService.log({
|
||||
@@ -203,7 +237,9 @@ export class SchedulesController {
|
||||
content: `教室${existing.classroomId} 周${existing.weekDay} ${existing.startTime}-${existing.endTime} (更新) 与已有排课冲突`,
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
} catch {
|
||||
// Best-effort conflict notification must not hide the original conflict.
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -216,6 +252,7 @@ export class SchedulesController {
|
||||
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.getAuthorizedSchedule(+id, req as { user: RequestUser });
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities';
|
||||
import {
|
||||
ClassSchedule,
|
||||
Class,
|
||||
Classroom,
|
||||
ClassroomRental,
|
||||
ClassTeacher,
|
||||
AttendanceSession,
|
||||
} from '../entities';
|
||||
import { SchedulesService } from './schedules.service';
|
||||
import { SchedulesController } from './schedules.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
@@ -8,7 +15,14 @@ import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ClassSchedule, Class, ClassroomRental, ClassTeacher]),
|
||||
TypeOrmModule.forFeature([
|
||||
ClassSchedule,
|
||||
Class,
|
||||
Classroom,
|
||||
ClassroomRental,
|
||||
ClassTeacher,
|
||||
AttendanceSession,
|
||||
]),
|
||||
OperationLogsModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
|
||||
@@ -39,3 +39,67 @@ describe('SchedulesService — teacher class scope', () => {
|
||||
expect(qb.getMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesService — shared classroom occupancy visibility', () => {
|
||||
it('shows other classes as masked busy blocks while preserving assigned-class details', async () => {
|
||||
const qb = createQb();
|
||||
qb.getMany.mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
classId: 3,
|
||||
classroomId: 10,
|
||||
weekDay: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '数学',
|
||||
teacherId: 8,
|
||||
scheduleType: 'INTERNAL',
|
||||
status: 'active',
|
||||
notes: '本班备注',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
classId: 99,
|
||||
classroomId: 10,
|
||||
weekDay: 1,
|
||||
startTime: '10:00',
|
||||
endTime: '11:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
subject: '其他班隐私科目',
|
||||
teacherId: 9,
|
||||
scheduleType: 'INTERNAL',
|
||||
status: 'active',
|
||||
notes: '其他班备注',
|
||||
},
|
||||
]);
|
||||
const service = new SchedulesService(
|
||||
{ createQueryBuilder: jest.fn().mockReturnValue(qb) } as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const result = await service.getWeeklyView({}, [3]);
|
||||
const blocks = result[10][1];
|
||||
|
||||
expect(blocks[0]).toEqual(expect.objectContaining({ subject: '数学', canViewDetails: true }));
|
||||
expect(blocks[1]).toEqual(
|
||||
expect.objectContaining({
|
||||
subject: '已占用',
|
||||
classId: null,
|
||||
teacherId: null,
|
||||
notes: null,
|
||||
canViewDetails: false,
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(blocks[1])).not.toContain('其他班隐私科目');
|
||||
expect(JSON.stringify(blocks[1])).not.toContain('其他班备注');
|
||||
expect(qb.andWhere).not.toHaveBeenCalledWith(
|
||||
'cs.classId IN (:...accessibleClassIds)',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Class } from '../entities/class.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { AttendanceSession } from '../entities/attendance-session.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
|
||||
/** Build a mock query-builder where each chain method returns `this`. */
|
||||
function mockQueryBuilder<T>(results: T[] = []) {
|
||||
@@ -20,6 +22,45 @@ function mockQueryBuilder<T>(results: T[] = []) {
|
||||
return qb;
|
||||
}
|
||||
|
||||
describe('SchedulesService — getLookups', () => {
|
||||
it('includes active classrooms that have never been scheduled', async () => {
|
||||
const classroom = { id: 7, name: '新教室', building: 'A座' } as Classroom;
|
||||
const classroomRepo = { find: jest.fn().mockResolvedValue([classroom]) };
|
||||
const scheduleQb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
distinct: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
SchedulesService,
|
||||
{
|
||||
provide: getRepositoryToken(ClassSchedule),
|
||||
useValue: { createQueryBuilder: jest.fn().mockReturnValue(scheduleQb) },
|
||||
},
|
||||
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: classroomRepo },
|
||||
{ provide: getRepositoryToken(ClassroomRental), useValue: {} },
|
||||
{ provide: getRepositoryToken(ClassTeacher), useValue: {} },
|
||||
{ provide: getRepositoryToken(AttendanceSession), useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
const service = module.get(SchedulesService);
|
||||
|
||||
await expect(service.getLookups([1])).resolves.toMatchObject({ classrooms: [classroom] });
|
||||
expect(classroomRepo.find).toHaveBeenCalledWith({
|
||||
where: expect.any(Object),
|
||||
select: ['id', 'name', 'building', 'floor', 'roomType'],
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesService — checkConflict', () => {
|
||||
let service: SchedulesService;
|
||||
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'createQueryBuilder'>>;
|
||||
@@ -34,6 +75,7 @@ describe('SchedulesService — checkConflict', () => {
|
||||
providers: [
|
||||
SchedulesService,
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: mockRepo },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{
|
||||
provide: getRepositoryToken(ClassroomRental),
|
||||
@@ -43,6 +85,10 @@ describe('SchedulesService — checkConflict', () => {
|
||||
provide: getRepositoryToken(ClassTeacher),
|
||||
useValue: { find: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AttendanceSession),
|
||||
useValue: { count: jest.fn().mockResolvedValue(0) },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -141,13 +187,13 @@ describe('SchedulesService — checkConflict', () => {
|
||||
describe('SchedulesService — getClassroomOccupancy', () => {
|
||||
let service: SchedulesService;
|
||||
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'createQueryBuilder'>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SchedulesService,
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
|
||||
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{
|
||||
provide: getRepositoryToken(ClassroomRental),
|
||||
useValue: { createQueryBuilder: jest.fn() },
|
||||
@@ -156,6 +202,10 @@ describe('SchedulesService — getClassroomOccupancy', () => {
|
||||
provide: getRepositoryToken(ClassTeacher),
|
||||
useValue: { find: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AttendanceSession),
|
||||
useValue: { count: jest.fn().mockResolvedValue(0) },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -191,3 +241,69 @@ describe('SchedulesService — getClassroomOccupancy', () => {
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('cs.endDate >= :date', { date: '2026-03-15' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('SchedulesService — remove', () => {
|
||||
let service: SchedulesService;
|
||||
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'findOne' | 'remove'>>;
|
||||
let attendanceSessionRepo: jest.Mocked<Pick<Repository<AttendanceSession>, 'count'>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SchedulesService,
|
||||
{
|
||||
provide: getRepositoryToken(ClassSchedule),
|
||||
useValue: { findOne: jest.fn(), remove: jest.fn() },
|
||||
},
|
||||
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{
|
||||
provide: getRepositoryToken(ClassroomRental),
|
||||
useValue: { createQueryBuilder: jest.fn() },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(ClassTeacher),
|
||||
useValue: { find: jest.fn().mockResolvedValue([]) },
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(AttendanceSession),
|
||||
useValue: { count: jest.fn() },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SchedulesService>(SchedulesService);
|
||||
scheduleRepo = module.get(getRepositoryToken(ClassSchedule));
|
||||
attendanceSessionRepo = module.get(getRepositoryToken(AttendanceSession));
|
||||
});
|
||||
|
||||
it('deletes a schedule with no attendance sessions', async () => {
|
||||
const schedule = { id: 1, subject: '数学' } as ClassSchedule;
|
||||
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule);
|
||||
(scheduleRepo.remove as jest.Mock).mockResolvedValue(schedule);
|
||||
(attendanceSessionRepo.count as jest.Mock).mockResolvedValue(0);
|
||||
|
||||
const result = await service.remove(1);
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
expect(scheduleRepo.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
|
||||
expect(scheduleRepo.remove).toHaveBeenCalledWith(schedule);
|
||||
});
|
||||
|
||||
it('rejects deletion when attendance sessions exist', async () => {
|
||||
const schedule = { id: 2, subject: '英语' } as ClassSchedule;
|
||||
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(schedule);
|
||||
(scheduleRepo.remove as jest.Mock).mockResolvedValue(schedule);
|
||||
(attendanceSessionRepo.count as jest.Mock).mockResolvedValue(3);
|
||||
|
||||
await expect(service.remove(2)).rejects.toThrow(ConflictException);
|
||||
expect(scheduleRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws NotFoundException for non-existent schedule', async () => {
|
||||
(scheduleRepo.findOne as jest.Mock).mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(999)).rejects.toThrow('排课记录不存在');
|
||||
expect(scheduleRepo.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,12 +2,19 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
ForbiddenException,
|
||||
BadRequestException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { ClassSchedule, Class, ClassroomRental, ClassTeacher } from '../entities';
|
||||
|
||||
import { In, Not, Repository } from 'typeorm';
|
||||
import {
|
||||
ClassSchedule,
|
||||
Class,
|
||||
Classroom,
|
||||
ClassroomRental,
|
||||
ClassTeacher,
|
||||
AttendanceSession,
|
||||
} from '../entities';
|
||||
import {
|
||||
CreateScheduleDto,
|
||||
UpdateScheduleDto,
|
||||
@@ -21,10 +28,13 @@ export class SchedulesService {
|
||||
@InjectRepository(ClassSchedule)
|
||||
private readonly scheduleRepo: Repository<ClassSchedule>,
|
||||
@InjectRepository(Class) private readonly classRepo: Repository<Class>,
|
||||
@InjectRepository(Classroom) private readonly classroomRepo: Repository<Classroom>,
|
||||
@InjectRepository(ClassroomRental)
|
||||
private readonly rentalRepo: Repository<ClassroomRental>,
|
||||
@InjectRepository(ClassTeacher)
|
||||
private readonly classTeacherRepo: Repository<ClassTeacher>,
|
||||
@InjectRepository(AttendanceSession)
|
||||
private readonly attendanceSessionRepo: Repository<AttendanceSession>,
|
||||
) {}
|
||||
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
@@ -33,6 +43,31 @@ export class SchedulesService {
|
||||
return [...new Set(assignments.map((assignment) => assignment.classId))];
|
||||
}
|
||||
|
||||
async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise<void> {
|
||||
if (canManageAll) return;
|
||||
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
|
||||
if (!assignment) throw new ForbiddenException('只能管理自己被分配班级的排课');
|
||||
}
|
||||
|
||||
maskScheduleOccupancy(schedule: ClassSchedule) {
|
||||
return {
|
||||
id: null,
|
||||
classId: null,
|
||||
classroomId: schedule.classroomId,
|
||||
weekDay: schedule.weekDay,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
startDate: schedule.startDate,
|
||||
endDate: schedule.endDate,
|
||||
subject: '已占用',
|
||||
teacherId: null,
|
||||
scheduleType: schedule.scheduleType,
|
||||
status: schedule.status,
|
||||
notes: null,
|
||||
canViewDetails: false,
|
||||
};
|
||||
}
|
||||
|
||||
async getLookups(accessibleClassIds?: number[]) {
|
||||
const classes = accessibleClassIds
|
||||
? accessibleClassIds.length > 0
|
||||
@@ -47,24 +82,15 @@ export class SchedulesService {
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
|
||||
const classroomRows = await this.scheduleRepo
|
||||
.createQueryBuilder('schedule')
|
||||
.select('classroom.id', 'classroomId')
|
||||
.addSelect('classroom.name', 'classroomName')
|
||||
.addSelect('classroom.building', 'classroomBuilding')
|
||||
.innerJoin('schedule.classroom', 'classroom')
|
||||
.distinct(true)
|
||||
.orderBy('classroom.building', 'ASC')
|
||||
.addOrderBy('classroom.name', 'ASC')
|
||||
.getRawMany();
|
||||
const classrooms = await this.classroomRepo.find({
|
||||
where: { status: Not('archived') },
|
||||
select: ['id', 'name', 'building', 'floor', 'roomType'],
|
||||
order: { building: 'ASC', name: 'ASC' },
|
||||
});
|
||||
|
||||
return {
|
||||
classes,
|
||||
classrooms: classroomRows.map((row) => ({
|
||||
id: Number(row.classroomId),
|
||||
name: String(row.classroomName ?? ''),
|
||||
building: String(row.classroomBuilding ?? ''),
|
||||
})),
|
||||
classrooms,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -181,6 +207,16 @@ export class SchedulesService {
|
||||
async remove(id: number) {
|
||||
const schedule = await this.scheduleRepo.findOne({ where: { id } });
|
||||
if (!schedule) throw new NotFoundException('排课记录不存在');
|
||||
|
||||
const sessionCount = await this.attendanceSessionRepo.count({
|
||||
where: { scheduleId: id },
|
||||
});
|
||||
if (sessionCount > 0) {
|
||||
throw new ConflictException(
|
||||
`无法删除已产生 ${sessionCount} 个考勤场次的排课。请先取消或停用排课以保护历史考勤数据。`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.scheduleRepo.remove(schedule);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -236,10 +272,6 @@ export class SchedulesService {
|
||||
if (query.classroomId) {
|
||||
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
|
||||
}
|
||||
if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return {};
|
||||
qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.startDate) {
|
||||
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
|
||||
}
|
||||
@@ -253,12 +285,25 @@ export class SchedulesService {
|
||||
.addOrderBy('cs.startTime', 'ASC')
|
||||
.getMany();
|
||||
|
||||
const allowedClassIds = accessibleClassIds ? new Set(accessibleClassIds) : null;
|
||||
const visibleSchedules = schedules.map((schedule) => {
|
||||
const canViewDetails =
|
||||
allowedClassIds === null ||
|
||||
(schedule.classId !== null && allowedClassIds.has(schedule.classId));
|
||||
if (canViewDetails) return { ...schedule, canViewDetails: true };
|
||||
|
||||
// Other classes remain visible only as a room/time occupancy block.
|
||||
// Do not expose class, subject, teacher, notes, or internal record IDs.
|
||||
return this.maskScheduleOccupancy(schedule);
|
||||
});
|
||||
|
||||
// Group by classroomId → weekDay
|
||||
const matrix: Record<number, Record<number, typeof schedules>> = {};
|
||||
for (const s of schedules) {
|
||||
if (!matrix[s.classroomId]) matrix[s.classroomId] = {};
|
||||
if (!matrix[s.classroomId][s.weekDay]) matrix[s.classroomId][s.weekDay] = [];
|
||||
matrix[s.classroomId][s.weekDay].push(s);
|
||||
const matrix: Record<number, Record<number, typeof visibleSchedules>> = {};
|
||||
for (const schedule of visibleSchedules) {
|
||||
if (!matrix[schedule.classroomId]) matrix[schedule.classroomId] = {};
|
||||
if (!matrix[schedule.classroomId][schedule.weekDay])
|
||||
matrix[schedule.classroomId][schedule.weekDay] = [];
|
||||
matrix[schedule.classroomId][schedule.weekDay].push(schedule);
|
||||
}
|
||||
|
||||
return matrix;
|
||||
|
||||
@@ -52,6 +52,12 @@ export class StudentsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('basic-lookups')
|
||||
@RequirePermission('student:basic-view', 'student:view')
|
||||
getBasicLookups() {
|
||||
return this.service.getBasicLookups();
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('student:view')
|
||||
async findAll(
|
||||
|
||||
@@ -27,6 +27,14 @@ export class StudentsService {
|
||||
return [...new Set(assignments.map((assignment) => assignment.classId))];
|
||||
}
|
||||
|
||||
async getBasicLookups() {
|
||||
return this.repo.find({
|
||||
select: ['id', 'name', 'studentNo', 'gender', 'phone', 'status'],
|
||||
where: { status: 'active' },
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(
|
||||
query?: {
|
||||
name?: string;
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('ScheduleSyncService — absence threshold', () => {
|
||||
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
|
||||
};
|
||||
const dingTalkService = {
|
||||
queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '排课_16:00-17:00' }]),
|
||||
queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '冲刺班_16:00-17:00' }]),
|
||||
upsertShift: jest.fn().mockResolvedValue(456),
|
||||
queryAttendanceGroups: jest.fn().mockResolvedValue([
|
||||
{ group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
|
||||
@@ -50,7 +50,7 @@ describe('ScheduleSyncService — absence threshold', () => {
|
||||
|
||||
expect(dingTalkService.upsertShift).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 456,
|
||||
name: '排课_16:00-17:00',
|
||||
name: '冲刺班_16:00-17:00',
|
||||
setting: expect.objectContaining({ absenteeism_late_minutes: 60 }),
|
||||
}));
|
||||
});
|
||||
@@ -118,3 +118,347 @@ describe('ScheduleSyncService — attendance machine only', () => {
|
||||
expect(dingTalkService.createAttendanceGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('ScheduleSyncService — partial batch failure', () => {
|
||||
it('reports failedBatchCount > 0 when a scheduleUsers batch fails, not full success', async () => {
|
||||
const scheduleRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
classId: 10,
|
||||
classroomId: 1,
|
||||
weekDay: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '11:00',
|
||||
startDate: '2026-07-06',
|
||||
endDate: '2026-07-06',
|
||||
status: 'active',
|
||||
} as ClassSchedule,
|
||||
{
|
||||
id: 2,
|
||||
classId: 20,
|
||||
classroomId: 2,
|
||||
weekDay: 2,
|
||||
startTime: '14:00',
|
||||
endTime: '16:00',
|
||||
startDate: '2026-07-07',
|
||||
endDate: '2026-07-07',
|
||||
status: 'active',
|
||||
} as ClassSchedule,
|
||||
]),
|
||||
};
|
||||
const classStudentRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ classId: 10, studentId: 20, status: 'active' },
|
||||
{ classId: 20, studentId: 30, status: 'active' },
|
||||
]),
|
||||
};
|
||||
const mappingRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ studentId: 20, dingUserId: 'student-1' },
|
||||
{ studentId: 30, dingUserId: 'student-2' },
|
||||
]),
|
||||
};
|
||||
const classRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ id: 10, name: '冲刺班' },
|
||||
{ id: 20, name: '强化班' },
|
||||
]),
|
||||
};
|
||||
|
||||
const scheduleUsers = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('钉钉排班失败: rate limited (code=33018)'));
|
||||
|
||||
const dingTalkService = {
|
||||
queryShifts: jest.fn().mockResolvedValue([
|
||||
{ id: 900, name: '排课_09:00-11:00' },
|
||||
{ id: 901, name: '排课_14:00-16:00' },
|
||||
]),
|
||||
upsertShift: jest.fn().mockResolvedValue(900).mockResolvedValueOnce(900).mockResolvedValueOnce(901),
|
||||
queryAttendanceGroups: jest.fn().mockResolvedValue([
|
||||
{ group_id: 777, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
|
||||
]),
|
||||
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
|
||||
createAttendanceGroup: jest.fn().mockResolvedValue(888),
|
||||
scheduleUsers,
|
||||
};
|
||||
|
||||
const service = new ScheduleSyncService(
|
||||
scheduleRepo as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classRepo as never,
|
||||
dingTalkService as never,
|
||||
);
|
||||
|
||||
const result = await service.syncAll('2026-07-06', 2);
|
||||
|
||||
expect(scheduleUsers).toHaveBeenCalledTimes(2);
|
||||
expect(result.failedBatchCount).toBeGreaterThan(0);
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors!.length).toBeGreaterThan(0);
|
||||
// syncedItems should only count the successful batch
|
||||
expect(result.syncedItems).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScheduleSyncService — attendance group failure', () => {
|
||||
it('counts createAttendanceGroup failure as real failure, not skippedNoMapping', async () => {
|
||||
const scheduleRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
classId: 10,
|
||||
classroomId: 1,
|
||||
weekDay: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '11:00',
|
||||
startDate: '2026-07-06',
|
||||
endDate: '2026-07-06',
|
||||
status: 'active',
|
||||
} as ClassSchedule,
|
||||
{
|
||||
id: 2,
|
||||
classId: 20,
|
||||
classroomId: 2,
|
||||
weekDay: 2,
|
||||
startTime: '14:00',
|
||||
endTime: '16:00',
|
||||
startDate: '2026-07-07',
|
||||
endDate: '2026-07-07',
|
||||
status: 'active',
|
||||
} as ClassSchedule,
|
||||
]),
|
||||
};
|
||||
const classStudentRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ classId: 10, studentId: 20, status: 'active' },
|
||||
{ classId: 20, studentId: 30, status: 'active' },
|
||||
]),
|
||||
};
|
||||
const mappingRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ studentId: 20, dingUserId: 'student-1' },
|
||||
{ studentId: 30, dingUserId: 'student-2' },
|
||||
]),
|
||||
};
|
||||
const classRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ id: 10, name: '冲刺班' },
|
||||
{ id: 20, name: '强化班' },
|
||||
]),
|
||||
};
|
||||
|
||||
const createAttendanceGroup = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('钉钉考勤组创建失败: insuffient permission (code=403)'));
|
||||
|
||||
const dingTalkService = {
|
||||
queryShifts: jest.fn().mockResolvedValue([
|
||||
{ id: 900, name: '排课_09:00-11:00' },
|
||||
{ id: 901, name: '排课_14:00-16:00' },
|
||||
]),
|
||||
upsertShift: jest.fn().mockResolvedValue(900),
|
||||
queryAttendanceGroups: jest.fn().mockResolvedValue([
|
||||
{ group_id: 888, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
|
||||
]),
|
||||
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
|
||||
createAttendanceGroup,
|
||||
scheduleUsers: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const service = new ScheduleSyncService(
|
||||
scheduleRepo as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classRepo as never,
|
||||
dingTalkService as never,
|
||||
);
|
||||
|
||||
const result = await service.syncAll('2026-07-06', 2);
|
||||
|
||||
// group failure must NOT be counted as skippedNoMapping
|
||||
expect(result.skippedNoMapping).toBe(0);
|
||||
// group failure must increment failure counters
|
||||
expect(result.failedBatchCount).toBeGreaterThan(0);
|
||||
expect(result.failedItems).toBeGreaterThan(0);
|
||||
// error message must contain the group failure detail
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors!.some((e) => e.includes('考勤组'))).toBe(true);
|
||||
expect(result.errors!.some((e) => e.includes('强化班'))).toBe(true);
|
||||
// the successful class should still sync
|
||||
expect(result.syncedItems).toBeGreaterThan(0);
|
||||
expect(result.groupCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScheduleSyncService — dedup', () => {
|
||||
it('does not write duplicate schedule items for same user/date/shift', async () => {
|
||||
const scheduleRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
classId: 10,
|
||||
classroomId: 1,
|
||||
weekDay: 3,
|
||||
startTime: '10:00',
|
||||
endTime: '12:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
status: 'active',
|
||||
} as ClassSchedule,
|
||||
{
|
||||
id: 2,
|
||||
classId: 10,
|
||||
classroomId: 1,
|
||||
weekDay: 3,
|
||||
startTime: '10:00',
|
||||
endTime: '12:00',
|
||||
startDate: '2026-07-08',
|
||||
endDate: '2026-07-08',
|
||||
status: 'active',
|
||||
} as ClassSchedule,
|
||||
]),
|
||||
};
|
||||
const classStudentRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
|
||||
};
|
||||
const mappingRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
|
||||
};
|
||||
const classRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
|
||||
};
|
||||
|
||||
const scheduleUsers = jest.fn().mockResolvedValue(undefined);
|
||||
const dingTalkService = {
|
||||
queryShifts: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ id: 456, name: '排课_10:00-12:00' }]),
|
||||
upsertShift: jest.fn().mockResolvedValue(456),
|
||||
queryAttendanceGroups: jest.fn().mockResolvedValue([
|
||||
{ group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
|
||||
]),
|
||||
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
|
||||
createAttendanceGroup: jest.fn(),
|
||||
scheduleUsers,
|
||||
};
|
||||
|
||||
const service = new ScheduleSyncService(
|
||||
scheduleRepo as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classRepo as never,
|
||||
dingTalkService as never,
|
||||
);
|
||||
|
||||
await service.syncAll('2026-07-01', 31);
|
||||
|
||||
const batchItems = scheduleUsers.mock.calls[0][1] as Array<{ userid: string; work_date: number; shift_id: number }>;
|
||||
|
||||
// 2026-07-08 is a Wednesday (weekDay 3), so both schedules hit that date.
|
||||
// The dedup should collapse the two identical {userid, work_date, shift_id} items into one.
|
||||
const key = (item: { userid: string; work_date: number; shift_id: number }) =>
|
||||
`${item.userid}-${item.work_date}-${item.shift_id}`;
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const item of batchItems) {
|
||||
const k = key(item);
|
||||
expect(seen.has(k)).toBe(false);
|
||||
seen.add(k);
|
||||
}
|
||||
|
||||
// At least one item exists for 07-08 (proving overlap was handled)
|
||||
// work_date is epoch ms at 00:00:00+08:00; convert back to date string
|
||||
const fmtDate = (epochMs: number) => {
|
||||
const d = new Date(epochMs);
|
||||
return new Date(d.getTime() - d.getTimezoneOffset() * 60000)
|
||||
.toISOString().slice(0, 10);
|
||||
};
|
||||
const july8Items = batchItems.filter((i) => fmtDate(i.work_date) === '2026-07-08');
|
||||
expect(july8Items.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScheduleSyncService — all shifts fail', () => {
|
||||
it('counts failedBatchCount and failedItems when every shift creation fails, never skippedNoMapping', async () => {
|
||||
const scheduleRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
classId: 10,
|
||||
classroomId: 1,
|
||||
weekDay: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '11:00',
|
||||
startDate: '2026-07-06',
|
||||
endDate: '2026-07-06',
|
||||
status: 'active',
|
||||
} as ClassSchedule,
|
||||
{
|
||||
id: 2,
|
||||
classId: 10,
|
||||
classroomId: 1,
|
||||
weekDay: 2,
|
||||
startTime: '14:00',
|
||||
endTime: '16:00',
|
||||
startDate: '2026-07-07',
|
||||
endDate: '2026-07-07',
|
||||
status: 'active',
|
||||
} as ClassSchedule,
|
||||
]),
|
||||
};
|
||||
const classStudentRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ classId: 10, studentId: 20, status: 'active' },
|
||||
]),
|
||||
};
|
||||
const mappingRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
|
||||
};
|
||||
const classRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺班' }]),
|
||||
};
|
||||
|
||||
const dingTalkService = {
|
||||
queryShifts: jest.fn().mockResolvedValue([]),
|
||||
upsertShift: jest.fn().mockRejectedValue(new Error('钉钉班次创建失败: permission denied')),
|
||||
queryAttendanceGroups: jest.fn().mockResolvedValue([]),
|
||||
updateAttendanceGroup: jest.fn(),
|
||||
createAttendanceGroup: jest.fn(),
|
||||
scheduleUsers: jest.fn(),
|
||||
};
|
||||
|
||||
const service = new ScheduleSyncService(
|
||||
scheduleRepo as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classRepo as never,
|
||||
dingTalkService as never,
|
||||
);
|
||||
|
||||
const result = await service.syncAll('2026-07-06', 2);
|
||||
|
||||
// All shifts failed → no shifts created
|
||||
expect(result.shiftCount).toBe(0);
|
||||
// No attendance groups created (no usable shifts)
|
||||
expect(result.groupCount).toBe(0);
|
||||
// Nothing synced
|
||||
expect(result.syncedItems).toBe(0);
|
||||
// Must NOT count as skippedNoMapping
|
||||
expect(result.skippedNoMapping).toBe(0);
|
||||
// Failure counters must reflect the failed shifts
|
||||
expect(result.failedBatchCount).toBeGreaterThan(0);
|
||||
expect(result.failedItems).toBeGreaterThan(0);
|
||||
// Errors must contain shift failure messages
|
||||
expect(result.errors).toBeDefined();
|
||||
expect(result.errors!.length).toBeGreaterThan(0);
|
||||
expect(result.errors!.some((e) => e.includes('班次'))).toBe(true);
|
||||
// No scheduleUsers calls (no group created)
|
||||
expect(dingTalkService.scheduleUsers).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,12 @@ export interface ScheduleSyncResult {
|
||||
syncedItems: number;
|
||||
/** 因无学生或无钉钉映射而跳过的排课数 */
|
||||
skippedNoMapping: number;
|
||||
/** 写入失败的排班批次数 */
|
||||
failedBatchCount: number;
|
||||
/** 写入失败的排班条数 */
|
||||
failedItems: number;
|
||||
/** 失败批次错误详情 */
|
||||
errors: string[];
|
||||
/** 按班级分组的详情 */
|
||||
groups: Array<{
|
||||
className: string;
|
||||
@@ -39,6 +45,13 @@ export interface ScheduleSyncResult {
|
||||
* 4. 每个班级创建/匹配一个排班制考勤组(考勤组列表只拉一次)
|
||||
* 5. 将排课展开为每个学生的每日排班,批量写入钉钉
|
||||
*
|
||||
* ## 残余风险:同步窗口内已不存在的旧排班无法清理
|
||||
* 钉钉开放平台未暴露排班删除接口(仅提供 `schedule/listbyusers` 查询和
|
||||
* `group/schedule/async` 写入)。`queryScheduleByUsers` 受限于 7 天窗口
|
||||
* 和每次 50 个用户,且无配套删除能力,无法在同步前清理旧排班。
|
||||
* 当前产品流程为"排课后手动同步钉钉",依赖运营人员知晓同步时机;
|
||||
* 若后续需要自动清理,需等钉钉开放排班删除 API 或改用考勤组覆盖策略。
|
||||
*
|
||||
* ## API 调用优化
|
||||
* - 班次列表、考勤组列表各只查询一次,在内存中按名称匹配,避免每次 findOrCreate 都发一次查询。
|
||||
* - 排班写入按考勤组分批(钉钉单次最多 200 条)。
|
||||
@@ -77,7 +90,9 @@ export class ScheduleSyncService {
|
||||
|
||||
const empty: ScheduleSyncResult = {
|
||||
scheduleCount: 0, shiftCount: 0, groupCount: 0,
|
||||
syncedItems: 0, skippedNoMapping: 0, groups: [],
|
||||
syncedItems: 0, skippedNoMapping: 0,
|
||||
failedBatchCount: 0, failedItems: 0, errors: [],
|
||||
groups: [],
|
||||
};
|
||||
|
||||
// ── Step 1: 查询活跃排课(必须关联到班级才能取学生) ──
|
||||
@@ -93,23 +108,37 @@ export class ScheduleSyncService {
|
||||
// ── Step 2: 班级 → 学生钉钉ID 映射 ──
|
||||
const classIds = [...new Set(schedules.map((s) => s.classId as number))];
|
||||
const classDingUsers = await this.buildClassDingUserMap(classIds);
|
||||
const classNameMap = await this.loadClassNames(classIds);
|
||||
|
||||
// ── Step 3: 班次(按时间段去重,班次列表只查一次) ──
|
||||
const shiftKey = (start: string, end: string) => `${start}-${end}`;
|
||||
const uniqueShifts = new Map<string, { startTime: string; endTime: string }>();
|
||||
for (const s of schedules) {
|
||||
const key = shiftKey(s.startTime, s.endTime);
|
||||
const shiftKey = (classId: number, start: string, end: string) =>
|
||||
`${classId}|${start}-${end}`;
|
||||
const uniqueShifts = new Map<
|
||||
string,
|
||||
{ className: string; startTime: string; endTime: string }
|
||||
>();
|
||||
const shiftScheduleCount = new Map<string, number>();
|
||||
for (const schedule of schedules) {
|
||||
const classId = schedule.classId as number;
|
||||
const key = shiftKey(classId, schedule.startTime, schedule.endTime);
|
||||
if (!uniqueShifts.has(key)) {
|
||||
uniqueShifts.set(key, { startTime: s.startTime, endTime: s.endTime });
|
||||
uniqueShifts.set(key, {
|
||||
className: classNameMap.get(classId) || `班级${classId}`,
|
||||
startTime: schedule.startTime,
|
||||
endTime: schedule.endTime,
|
||||
});
|
||||
}
|
||||
shiftScheduleCount.set(key, (shiftScheduleCount.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
const existingShifts = await this.dingTalkService.queryShifts(opUserId);
|
||||
const shiftByName = new Map(existingShifts.map((s) => [s.name, s.id]));
|
||||
const timeToShiftId = new Map<string, number>();
|
||||
const errors: string[] = [];
|
||||
let failedBatchCount = 0;
|
||||
let failedItems = 0;
|
||||
let shiftCount = 0;
|
||||
for (const [key, { startTime, endTime }] of uniqueShifts) {
|
||||
const shiftName = `排课_${startTime}-${endTime}`;
|
||||
for (const [key, { className, startTime, endTime }] of uniqueShifts) {
|
||||
const shiftName = `${className}_${startTime}-${endTime}`;
|
||||
try {
|
||||
let shiftId = shiftByName.get(shiftName);
|
||||
const shiftParams = {
|
||||
@@ -133,7 +162,11 @@ export class ScheduleSyncService {
|
||||
timeToShiftId.set(key, shiftId);
|
||||
shiftCount++;
|
||||
} catch (e) {
|
||||
this.logger.error(`创建班次 ${shiftName} 失败: ${(e as Error).message}`);
|
||||
const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`;
|
||||
this.logger.error(msg);
|
||||
errors.push(msg);
|
||||
failedBatchCount++;
|
||||
failedItems += shiftScheduleCount.get(key) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +175,6 @@ export class ScheduleSyncService {
|
||||
const groupByName = new Map(existingGroups.map((g) => [g.group_name, g.group_id]));
|
||||
|
||||
// ── Step 5: 按班级同步 ──
|
||||
const classNameMap = await this.loadClassNames(classIds);
|
||||
const schedulesByClass = new Map<number, ClassSchedule[]>();
|
||||
for (const s of schedules) {
|
||||
const cid = s.classId as number;
|
||||
@@ -154,7 +186,6 @@ export class ScheduleSyncService {
|
||||
let skippedNoMapping = 0;
|
||||
let groupCount = 0;
|
||||
const groupDetails: ScheduleSyncResult['groups'] = [];
|
||||
|
||||
for (const [classId, classSchedules] of schedulesByClass) {
|
||||
const className = classNameMap.get(classId) || `班级${classId}`;
|
||||
const dingUserIds = classDingUsers.get(classId) ?? [];
|
||||
@@ -168,11 +199,21 @@ export class ScheduleSyncService {
|
||||
// 该班级用到的班次
|
||||
const classShiftIds = new Set<number>();
|
||||
for (const s of classSchedules) {
|
||||
const sid = timeToShiftId.get(shiftKey(s.startTime, s.endTime));
|
||||
const sid = timeToShiftId.get(shiftKey(classId, s.startTime, s.endTime));
|
||||
if (sid) classShiftIds.add(sid);
|
||||
}
|
||||
if (classShiftIds.size === 0) {
|
||||
this.logger.warn(`班级 ${className} 无可用班次,跳过`);
|
||||
this.logger.warn(`班级 ${className} 无可用班次,跳过(班次创建已计入 failure)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 先展开排班以计算受影响条数
|
||||
const items = this.expandSchedules(
|
||||
classSchedules, dingUserIds, timeToShiftId, startDate, endDate,
|
||||
);
|
||||
|
||||
if (items.length === 0) {
|
||||
this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`);
|
||||
skippedNoMapping += classSchedules.length;
|
||||
continue;
|
||||
}
|
||||
@@ -205,16 +246,14 @@ export class ScheduleSyncService {
|
||||
}
|
||||
groupCount++;
|
||||
} catch (e) {
|
||||
this.logger.error(`创建考勤组 ${groupName} 失败: ${(e as Error).message}`);
|
||||
skippedNoMapping += classSchedules.length;
|
||||
const msg = `考勤组 ${groupName} 创建/更新失败: ${(e as Error).message}`;
|
||||
this.logger.error(msg);
|
||||
errors.push(msg);
|
||||
failedBatchCount++;
|
||||
failedItems += items.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 展开为每个学生的每日排班
|
||||
const items = this.expandSchedules(
|
||||
classSchedules, dingUserIds, timeToShiftId, startDate, endDate,
|
||||
);
|
||||
|
||||
// 批量写入(单次≤200)
|
||||
let classItems = 0;
|
||||
for (let i = 0; i < items.length; i += 200) {
|
||||
@@ -224,7 +263,11 @@ export class ScheduleSyncService {
|
||||
syncedItems += batch.length;
|
||||
classItems += batch.length;
|
||||
} catch (e) {
|
||||
this.logger.error(`排班写入失败 (groupId=${attendanceGroupId}, offset=${i}): ${(e as Error).message}`);
|
||||
const msg = `排班写入失败 (groupId=${attendanceGroupId}, batch=${Math.floor(i / 200) + 1}): ${(e as Error).message}`;
|
||||
this.logger.error(msg);
|
||||
failedBatchCount++;
|
||||
failedItems += batch.length;
|
||||
errors.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +276,8 @@ export class ScheduleSyncService {
|
||||
|
||||
this.logger.log(
|
||||
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
|
||||
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射`,
|
||||
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` +
|
||||
(failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''),
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -242,6 +286,9 @@ export class ScheduleSyncService {
|
||||
groupCount,
|
||||
syncedItems,
|
||||
skippedNoMapping,
|
||||
failedBatchCount,
|
||||
failedItems,
|
||||
errors,
|
||||
groups: groupDetails,
|
||||
};
|
||||
}
|
||||
@@ -297,6 +344,7 @@ export class ScheduleSyncService {
|
||||
syncFrom: string,
|
||||
syncTo: string,
|
||||
): DingTalkScheduleItem[] {
|
||||
const seen = new Set<string>();
|
||||
const items: DingTalkScheduleItem[] = [];
|
||||
const fromDate = new Date(syncFrom);
|
||||
const toDate = new Date(syncTo);
|
||||
@@ -309,7 +357,7 @@ export class ScheduleSyncService {
|
||||
}
|
||||
|
||||
for (const s of schedules) {
|
||||
const shiftId = timeToShiftId.get(`${s.startTime}-${s.endTime}`);
|
||||
const shiftId = timeToShiftId.get(`${s.classId}|${s.startTime}-${s.endTime}`);
|
||||
if (!shiftId) continue;
|
||||
|
||||
const scheduleStart = s.startDate > syncFrom ? s.startDate : syncFrom;
|
||||
@@ -321,6 +369,9 @@ export class ScheduleSyncService {
|
||||
|
||||
const workDate = new Date(dateStr + 'T00:00:00+08:00').getTime();
|
||||
for (const userid of dingUserIds) {
|
||||
const dedupKey = `${userid}|${workDate}|${shiftId}`;
|
||||
if (seen.has(dedupKey)) continue;
|
||||
seen.add(dedupKey);
|
||||
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false });
|
||||
}
|
||||
}
|
||||
@@ -329,6 +380,7 @@ export class ScheduleSyncService {
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
private minutesBetween(startTime: string, endTime: string): number {
|
||||
const [startHour, startMinute] = startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = endTime.split(':').map(Number);
|
||||
|
||||
@@ -50,6 +50,21 @@ export class SyncController {
|
||||
return { success: true, data: tree };
|
||||
}
|
||||
|
||||
@Get('dingtalk/attendance-groups')
|
||||
@RequirePermission('sync:read')
|
||||
async getDingTalkAttendanceGroups() {
|
||||
return { success: true, data: await this.syncService.getDingTalkAttendanceGroups() };
|
||||
}
|
||||
|
||||
@Post('dingtalk/attendance-groups/delete-all')
|
||||
@RequirePermission('sync:trigger')
|
||||
async deleteAllDingTalkAttendanceGroups() {
|
||||
return {
|
||||
success: true,
|
||||
data: await this.syncService.deleteAllDingTalkAttendanceGroups(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('logs')
|
||||
@RequirePermission('sync:read')
|
||||
async getLogs(
|
||||
|
||||
@@ -98,6 +98,29 @@ export class SyncService {
|
||||
return this.dingTalkService.fetchOrgTreeWithUsers(rootDeptId);
|
||||
}
|
||||
|
||||
async getDingTalkAttendanceGroups() {
|
||||
return this.dingTalkService.queryAttendanceGroups();
|
||||
}
|
||||
|
||||
async deleteAllDingTalkAttendanceGroups() {
|
||||
const groups = await this.dingTalkService.queryAttendanceGroups();
|
||||
const deleted: Array<{ groupId: number; groupName: string }> = [];
|
||||
const failed: Array<{ groupId: number; groupName: string; error: string }> = [];
|
||||
for (const group of groups) {
|
||||
try {
|
||||
await this.dingTalkService.deleteAttendanceGroup(group.group_id);
|
||||
deleted.push({ groupId: group.group_id, groupName: group.group_name });
|
||||
} catch (error: unknown) {
|
||||
failed.push({
|
||||
groupId: group.group_id,
|
||||
groupName: group.group_name,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return { total: groups.length, deleted, failed };
|
||||
}
|
||||
|
||||
// ── 排班同步 ──
|
||||
|
||||
/** 将本地排课同步到钉钉考勤排班 */
|
||||
|
||||
Reference in New Issue
Block a user