feat(sync): sync class schedules to DingTalk students
Group active schedules by class, resolve enrolled students through DingTalk mappings, reuse shifts and attendance groups, and expose class-based sync status in the admin UI.
This commit is contained in:
@@ -6,9 +6,10 @@ import {
|
|||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||||
SyncOutlined, PlusOutlined, BankOutlined, UserOutlined,
|
SyncOutlined, BankOutlined, UserOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import type { DataNode } from 'antd/es/tree';
|
import type { DataNode } from 'antd/es/tree';
|
||||||
|
import type { TreeSelectProps } from 'antd/es/tree-select';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
|
|
||||||
interface DingTalkConfig {
|
interface DingTalkConfig {
|
||||||
@@ -42,6 +43,8 @@ interface OrgTreeWithUsersResponse {
|
|||||||
data: DingOrgTreeNodeExt[];
|
data: DingOrgTreeNodeExt[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeptPickerTreeNode = NonNullable<TreeSelectProps<number>['treeData']>[number];
|
||||||
|
|
||||||
interface ClassItem {
|
interface ClassItem {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -72,7 +75,7 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
const [fetchingTree, setFetchingTree] = useState(false);
|
const [fetchingTree, setFetchingTree] = useState(false);
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [deptPickerTree, setDeptPickerTree] = useState<Array<{ title: string; value: number; children?: Array<{ title: string; value: number; children?: unknown[] }> }>>([]);
|
const [deptPickerTree, setDeptPickerTree] = useState<DeptPickerTreeNode[]>([]);
|
||||||
const [checkedKeys, setCheckedKeys] = useState<React.Key[]>([]);
|
const [checkedKeys, setCheckedKeys] = useState<React.Key[]>([]);
|
||||||
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
|
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
|
||||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||||
@@ -140,7 +143,7 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const res = await api.get<OrgTreeResponse>('/sync/dingtalk/org-tree');
|
const res = await api.get<OrgTreeResponse>('/sync/dingtalk/org-tree');
|
||||||
if (res.success && res.data) {
|
if (res.success && res.data) {
|
||||||
const toTreeNode = (nodes: OrgTreeNodeRaw[]): Array<{ title: string; value: number; children?: Array<{ title: string; value: number; children?: unknown[] }> }> =>
|
const toTreeNode = (nodes: OrgTreeNodeRaw[]): DeptPickerTreeNode[] =>
|
||||||
nodes.map((n) => ({
|
nodes.map((n) => ({
|
||||||
title: n.name,
|
title: n.name,
|
||||||
value: n.id,
|
value: n.id,
|
||||||
@@ -188,23 +191,36 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
|
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
|
||||||
return nodes.map((node) => ({
|
return nodes.map((node) => {
|
||||||
title: (
|
const users = node.users ?? [];
|
||||||
<Space size="small">
|
const children: DataNode[] = [
|
||||||
<BankOutlined />
|
...buildTreeData(node.children ?? []),
|
||||||
<span>{node.name}</span>
|
...users.map((u) => ({
|
||||||
<Tag>{node.users.length}人</Tag>
|
title: (
|
||||||
</Space>
|
<Space>
|
||||||
),
|
<UserOutlined />
|
||||||
key: `dept-${node.id}`,
|
<span>{u.name}</span>
|
||||||
children: [
|
{u.mobile ? <Tag>{u.mobile}</Tag> : null}
|
||||||
...buildTreeData(node.children),
|
</Space>
|
||||||
...node.users.map((u) => ({
|
),
|
||||||
title: <Space><UserOutlined /><span>{u.name}</span><Tag>{u.mobile}</Tag></Space>,
|
|
||||||
key: `user-${u.userid}`,
|
key: `user-${u.userid}`,
|
||||||
|
isLeaf: true,
|
||||||
})),
|
})),
|
||||||
],
|
];
|
||||||
}));
|
return {
|
||||||
|
title: (
|
||||||
|
<Space size="small">
|
||||||
|
<BankOutlined />
|
||||||
|
<span>{node.name}</span>
|
||||||
|
<Tag>{users.length}人</Tag>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
key: `dept-${node.id}`,
|
||||||
|
// Only attach children when there are any, so empty/leaf departments
|
||||||
|
// don't render a phantom expand arrow that opens to nothing.
|
||||||
|
...(children.length > 0 ? { children } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]);
|
const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]);
|
||||||
@@ -213,12 +229,12 @@ const IntegrationConfigPage: React.FC = () => {
|
|||||||
const result: Array<{ dingUserId: string; name: string; mobile?: string }> = [];
|
const result: Array<{ dingUserId: string; name: string; mobile?: string }> = [];
|
||||||
const walk = (nodes: DingOrgTreeNodeExt[]) => {
|
const walk = (nodes: DingOrgTreeNodeExt[]) => {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
for (const u of node.users) {
|
for (const u of node.users ?? []) {
|
||||||
if (checkedKeys.includes(`user-${u.userid}`)) {
|
if (checkedKeys.includes(`user-${u.userid}`)) {
|
||||||
result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined });
|
result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
walk(node.children);
|
walk(node.children ?? []);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
walk(orgTree);
|
walk(orgTree);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ interface ScheduleSyncResult {
|
|||||||
groupCount: number;
|
groupCount: number;
|
||||||
syncedItems: number;
|
syncedItems: number;
|
||||||
skippedNoMapping: number;
|
skippedNoMapping: number;
|
||||||
groups: Array<{ deptName: string; groupId: number; itemCount: number }>;
|
groups: Array<{ className: string; groupId: number; itemCount: number }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||||
@@ -112,12 +112,12 @@ const SchedulesPage: React.FC = () => {
|
|||||||
const [syncModalOpen, setSyncModalOpen] = useState(false);
|
const [syncModalOpen, setSyncModalOpen] = useState(false);
|
||||||
const [syncing, setSyncing] = useState(false);
|
const [syncing, setSyncing] = useState(false);
|
||||||
const [syncStatus, setSyncStatus] = useState<{
|
const [syncStatus, setSyncStatus] = useState<{
|
||||||
activeSchedules: number; mappedTeachers: number; totalTeachers: number;
|
activeSchedules: number; mappedClasses: number; totalClasses: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [syncResult, setSyncResult] = useState<{
|
const [syncResult, setSyncResult] = useState<{
|
||||||
scheduleCount: number; shiftCount: number; groupCount: number;
|
scheduleCount: number; shiftCount: number; groupCount: number;
|
||||||
syncedItems: number; skippedNoMapping: number;
|
syncedItems: number; skippedNoMapping: number;
|
||||||
groups: Array<{ deptName: string; groupId: number; itemCount: number }>;
|
groups: Array<{ className: string; groupId: number; itemCount: number }>;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
|
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
|
||||||
const [syncDays, setSyncDays] = useState(30);
|
const [syncDays, setSyncDays] = useState(30);
|
||||||
@@ -128,7 +128,7 @@ const SchedulesPage: React.FC = () => {
|
|||||||
setSyncResult(null);
|
setSyncResult(null);
|
||||||
try {
|
try {
|
||||||
const res = await api.get<{
|
const res = await api.get<{
|
||||||
success: boolean; data: { activeSchedules: number; mappedTeachers: number; totalTeachers: number };
|
success: boolean; data: { activeSchedules: number; mappedClasses: number; totalClasses: number };
|
||||||
}>('/sync/schedule/status');
|
}>('/sync/schedule/status');
|
||||||
setSyncStatus(res.data);
|
setSyncStatus(res.data);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -986,17 +986,17 @@ const SchedulesPage: React.FC = () => {
|
|||||||
{syncResult.skippedNoMapping > 0 && (
|
{syncResult.skippedNoMapping > 0 && (
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
message={`${syncResult.skippedNoMapping} 条排课因教师未绑定钉钉而跳过`}
|
message={`${syncResult.skippedNoMapping} 条排课因班级无钉钉绑定学生而跳过`}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
showIcon
|
showIcon
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{syncResult.groups.length > 0 && (
|
{syncResult.groups.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>按部门分组:</div>
|
<div style={{ fontWeight: 500, marginBottom: 8 }}>按班级分组:</div>
|
||||||
{syncResult.groups.map((g) => (
|
{syncResult.groups.map((g) => (
|
||||||
<Tag key={g.groupId} color="blue" style={{ marginBottom: 4 }}>
|
<Tag key={g.groupId} color="blue" style={{ marginBottom: 4 }}>
|
||||||
{g.deptName}:{g.itemCount} 条排班
|
{g.className}:{g.itemCount} 条排班
|
||||||
</Tag>
|
</Tag>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1011,20 +1011,20 @@ const SchedulesPage: React.FC = () => {
|
|||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title="已绑定教师"
|
title="已就绪班级"
|
||||||
value={syncStatus.mappedTeachers}
|
value={syncStatus.mappedClasses}
|
||||||
suffix={`/ ${syncStatus.totalTeachers}`}
|
suffix={`/ ${syncStatus.totalClasses}`}
|
||||||
valueStyle={{ color: syncStatus.mappedTeachers < syncStatus.totalTeachers ? '#faad14' : '#3f8600' }}
|
valueStyle={{ color: syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600' }}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
<Statistic title="未绑定" value={syncStatus.totalTeachers - syncStatus.mappedTeachers} suffix="人" />
|
<Statistic title="无绑定学生班级" value={syncStatus.totalClasses - syncStatus.mappedClasses} suffix="个" />
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
{syncStatus.mappedTeachers < syncStatus.totalTeachers && (
|
{syncStatus.mappedClasses < syncStatus.totalClasses && (
|
||||||
<Alert
|
<Alert
|
||||||
type="warning"
|
type="warning"
|
||||||
message={`${syncStatus.totalTeachers - syncStatus.mappedTeachers} 位教师未绑定钉钉,其排课将被跳过。请先执行组织架构同步。`}
|
message={`${syncStatus.totalClasses - syncStatus.mappedClasses} 个班级没有已绑定钉钉的学生,其排课将被跳过。请先在钉钉集成页导入并绑定学生。`}
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
showIcon
|
showIcon
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -562,24 +562,6 @@ export class DingTalkService {
|
|||||||
return (data.result ?? []).map((s) => ({ id: s.id, name: s.name }));
|
return (data.result ?? []).map((s) => ({ id: s.id, name: s.name }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按名称查找班次,不存在则创建 */
|
|
||||||
async findOrCreateShift(name: string, startTime: string, endTime: string, opUserId = 'manager'): Promise<number> {
|
|
||||||
const existing = await this.queryShifts(opUserId);
|
|
||||||
const found = existing.find((s) => s.name === name);
|
|
||||||
if (found) return found.id;
|
|
||||||
|
|
||||||
return this.upsertShift({
|
|
||||||
name,
|
|
||||||
owner: opUserId,
|
|
||||||
sections: [{
|
|
||||||
times: [
|
|
||||||
{ check_type: 'OnDuty', across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false },
|
|
||||||
{ check_type: 'OffDuty', across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false },
|
|
||||||
],
|
|
||||||
}],
|
|
||||||
setting: { is_flexible: false, serious_late_minutes: -1, absenteeism_late_minutes: -1 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// 考勤排班 — 考勤组管理
|
// 考勤排班 — 考勤组管理
|
||||||
@@ -672,29 +654,6 @@ export class DingTalkService {
|
|||||||
return all;
|
return all;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按名称查找考勤组,不存在则创建 */
|
|
||||||
async findOrCreateAttendanceGroup(
|
|
||||||
name: string, ownerUserId: string, memberUserIds: string[], shiftIds: number[],
|
|
||||||
): Promise<number> {
|
|
||||||
const existing = await this.queryAttendanceGroups(ownerUserId);
|
|
||||||
const found = existing.find((g) => g.group_name === name);
|
|
||||||
if (found) return found.group_id;
|
|
||||||
|
|
||||||
return this.createAttendanceGroup({
|
|
||||||
name,
|
|
||||||
type: 'TURN',
|
|
||||||
owner: ownerUserId,
|
|
||||||
members: memberUserIds.map((uid) => ({
|
|
||||||
role: 'Attendance',
|
|
||||||
type: 'StaffMember',
|
|
||||||
user_id: uid,
|
|
||||||
})),
|
|
||||||
shift_ids: shiftIds,
|
|
||||||
enable_emp_select_class: true,
|
|
||||||
disable_check_without_schedule: false,
|
|
||||||
disable_check_when_rest: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════
|
// ═══════════════════════════════════════════
|
||||||
// 考勤排班 — 排班分配
|
// 考勤排班 — 排班分配
|
||||||
|
|||||||
@@ -1,27 +1,29 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, Not, IsNull } from 'typeorm';
|
import { Repository, In } from 'typeorm';
|
||||||
import {
|
import {
|
||||||
ClassSchedule,
|
ClassSchedule,
|
||||||
ClassTeacher,
|
ClassStudent,
|
||||||
|
StudentDingMapping,
|
||||||
|
Class,
|
||||||
} from '../entities';
|
} from '../entities';
|
||||||
import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service';
|
import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service';
|
||||||
|
|
||||||
/** 单次排班同步的结果 */
|
/** 单次排班同步的结果 */
|
||||||
export interface ScheduleSyncResult {
|
export interface ScheduleSyncResult {
|
||||||
/** 同步的排课记录数 */
|
/** 参与同步的排课记录数 */
|
||||||
scheduleCount: number;
|
scheduleCount: number;
|
||||||
/** 创建的班次数 */
|
/** 创建/复用的班次数 */
|
||||||
shiftCount: number;
|
shiftCount: number;
|
||||||
/** 创建/使用的考勤组数 */
|
/** 创建/复用的考勤组数 */
|
||||||
groupCount: number;
|
groupCount: number;
|
||||||
/** 实际发送的排班条数 */
|
/** 实际写入钉钉的排班条数 */
|
||||||
syncedItems: number;
|
syncedItems: number;
|
||||||
/** 跳过的记录数(无钉钉映射的用户) */
|
/** 因无学生或无钉钉映射而跳过的排课数 */
|
||||||
skippedNoMapping: number;
|
skippedNoMapping: number;
|
||||||
/** 按部门分组的详情 */
|
/** 按班级分组的详情 */
|
||||||
groups: Array<{
|
groups: Array<{
|
||||||
deptName: string;
|
className: string;
|
||||||
groupId: number;
|
groupId: number;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
}>;
|
}>;
|
||||||
@@ -30,11 +32,16 @@ export interface ScheduleSyncResult {
|
|||||||
/**
|
/**
|
||||||
* 排班同步服务 — 将本地 ClassSchedule 同步到钉钉考勤排班。
|
* 排班同步服务 — 将本地 ClassSchedule 同步到钉钉考勤排班。
|
||||||
*
|
*
|
||||||
* ## 同步流程
|
* ## 同步流程(按班级学生)
|
||||||
* 1. 查询活跃排课 + 关联教师
|
* 1. 查询活跃排课,按 classId 分组
|
||||||
* 2. 按 (startTime, endTime) 创建/匹配钉钉班次
|
* 2. 通过 ClassStudent + StudentDingMapping 拿到每个班级学生的钉钉 userId
|
||||||
* 3. 按部门创建/匹配钉钉排班制考勤组
|
* 3. 按 (startTime, endTime) 创建/匹配钉钉班次(班次列表只拉一次)
|
||||||
* 4. 将排课展开为每日排班,批量写入钉钉
|
* 4. 每个班级创建/匹配一个排班制考勤组(考勤组列表只拉一次)
|
||||||
|
* 5. 将排课展开为每个学生的每日排班,批量写入钉钉
|
||||||
|
*
|
||||||
|
* ## API 调用优化
|
||||||
|
* - 班次列表、考勤组列表各只查询一次,在内存中按名称匹配,避免每次 findOrCreate 都发一次查询。
|
||||||
|
* - 排班写入按考勤组分批(钉钉单次最多 200 条)。
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ScheduleSyncService {
|
export class ScheduleSyncService {
|
||||||
@@ -43,13 +50,17 @@ export class ScheduleSyncService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(ClassSchedule)
|
@InjectRepository(ClassSchedule)
|
||||||
private readonly scheduleRepo: Repository<ClassSchedule>,
|
private readonly scheduleRepo: Repository<ClassSchedule>,
|
||||||
@InjectRepository(ClassTeacher)
|
@InjectRepository(ClassStudent)
|
||||||
private readonly classTeacherRepo: Repository<ClassTeacher>,
|
private readonly classStudentRepo: Repository<ClassStudent>,
|
||||||
|
@InjectRepository(StudentDingMapping)
|
||||||
|
private readonly mappingRepo: Repository<StudentDingMapping>,
|
||||||
|
@InjectRepository(Class)
|
||||||
|
private readonly classRepo: Repository<Class>,
|
||||||
private readonly dingTalkService: DingTalkService,
|
private readonly dingTalkService: DingTalkService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全量同步:将所有活跃排课同步到钉钉排班
|
* 全量同步:将所有活跃排课同步到钉钉排班。
|
||||||
* @param dateFrom 起始日期(YYYY-MM-DD),默认今天
|
* @param dateFrom 起始日期(YYYY-MM-DD),默认今天
|
||||||
* @param days 同步天数,默认 30
|
* @param days 同步天数,默认 30
|
||||||
* @param opUserId 钉钉操作人 userId
|
* @param opUserId 钉钉操作人 userId
|
||||||
@@ -62,24 +73,26 @@ export class ScheduleSyncService {
|
|||||||
const startDate = dateFrom || new Date().toISOString().slice(0, 10);
|
const startDate = dateFrom || new Date().toISOString().slice(0, 10);
|
||||||
const endDate = this.addDays(startDate, days);
|
const endDate = this.addDays(startDate, days);
|
||||||
|
|
||||||
// ── Step 1: 查询活跃排课 + 关联教师 ──
|
const empty: ScheduleSyncResult = {
|
||||||
const schedules = await this.scheduleRepo.find({
|
scheduleCount: 0, shiftCount: 0, groupCount: 0,
|
||||||
where: {
|
syncedItems: 0, skippedNoMapping: 0, groups: [],
|
||||||
status: 'active',
|
};
|
||||||
teacherId: Not(IsNull()),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// ── Step 1: 查询活跃排课(必须关联到班级才能取学生) ──
|
||||||
|
const allSchedules = await this.scheduleRepo.find({
|
||||||
|
where: { status: 'active' },
|
||||||
|
});
|
||||||
|
const schedules = allSchedules.filter((s) => s.classId != null);
|
||||||
if (schedules.length === 0) {
|
if (schedules.length === 0) {
|
||||||
this.logger.log('没有需要同步的活跃排课');
|
this.logger.log('没有需要同步的活跃排课(无关联班级)');
|
||||||
return { scheduleCount: 0, shiftCount: 0, groupCount: 0, syncedItems: 0, skippedNoMapping: 0, groups: [] };
|
return empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 2: 获取教师→钉钉用户ID映射 ──
|
// ── Step 2: 班级 → 学生钉钉ID 映射 ──
|
||||||
// ponytail: teacher scheduling deprecated; StudentDingMapping.studentId is Student FK, not User
|
const classIds = [...new Set(schedules.map((s) => s.classId as number))];
|
||||||
const userIdToDingId = new Map<number, string>();
|
const classDingUsers = await this.buildClassDingUserMap(classIds);
|
||||||
|
|
||||||
// ── Step 3: 按 (startTime, endTime) 创建/匹配班次 ──
|
// ── Step 3: 班次(按时间段去重,班次列表只查一次) ──
|
||||||
const shiftKey = (start: string, end: string) => `${start}-${end}`;
|
const shiftKey = (start: string, end: string) => `${start}-${end}`;
|
||||||
const uniqueShifts = new Map<string, { startTime: string; endTime: string }>();
|
const uniqueShifts = new Map<string, { startTime: string; endTime: string }>();
|
||||||
for (const s of schedules) {
|
for (const s of schedules) {
|
||||||
@@ -89,12 +102,28 @@ export class ScheduleSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 timeToShiftId = new Map<string, number>();
|
||||||
let shiftCount = 0;
|
let shiftCount = 0;
|
||||||
for (const [key, { startTime, endTime }] of uniqueShifts) {
|
for (const [key, { startTime, endTime }] of uniqueShifts) {
|
||||||
const shiftName = `排课_${startTime}-${endTime}`;
|
const shiftName = `排课_${startTime}-${endTime}`;
|
||||||
try {
|
try {
|
||||||
const shiftId = await this.dingTalkService.findOrCreateShift(shiftName, startTime, endTime, opUserId);
|
let shiftId = shiftByName.get(shiftName);
|
||||||
|
if (shiftId === undefined) {
|
||||||
|
shiftId = await this.dingTalkService.upsertShift({
|
||||||
|
name: shiftName,
|
||||||
|
owner: opUserId,
|
||||||
|
sections: [{
|
||||||
|
times: [
|
||||||
|
{ check_type: 'OnDuty', across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false },
|
||||||
|
{ check_type: 'OffDuty', across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
setting: { is_flexible: false, serious_late_minutes: -1, absenteeism_late_minutes: -1 },
|
||||||
|
});
|
||||||
|
shiftByName.set(shiftName, shiftId);
|
||||||
|
}
|
||||||
timeToShiftId.set(key, shiftId);
|
timeToShiftId.set(key, shiftId);
|
||||||
shiftCount++;
|
shiftCount++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -102,84 +131,94 @@ export class ScheduleSyncService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 4: Collect teacher IDs (no department entity) ──
|
// ── Step 4: 考勤组列表只查一次,供每个班级匹配 ──
|
||||||
const teacherIds = new Set<number>();
|
const existingGroups = await this.dingTalkService.queryAttendanceGroups(opUserId);
|
||||||
|
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) {
|
for (const s of schedules) {
|
||||||
if (s.teacherId) teacherIds.add(s.teacherId);
|
const cid = s.classId as number;
|
||||||
|
if (!schedulesByClass.has(cid)) schedulesByClass.set(cid, []);
|
||||||
|
schedulesByClass.get(cid)!.push(s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Step 5: Single group → attendance group → scheduling ──
|
|
||||||
let syncedItems = 0;
|
let syncedItems = 0;
|
||||||
let skippedNoMapping = 0;
|
let skippedNoMapping = 0;
|
||||||
let groupCount = 0;
|
let groupCount = 0;
|
||||||
const groupDetails: ScheduleSyncResult['groups'] = [];
|
const groupDetails: ScheduleSyncResult['groups'] = [];
|
||||||
|
|
||||||
// Collect teacher→ding mapping
|
for (const [classId, classSchedules] of schedulesByClass) {
|
||||||
const dingUserIds: string[] = [];
|
const className = classNameMap.get(classId) || `班级${classId}`;
|
||||||
const teacherDingMap = new Map<number, string>();
|
const dingUserIds = classDingUsers.get(classId) ?? [];
|
||||||
for (const tid of teacherIds) {
|
|
||||||
const dingId = userIdToDingId.get(tid);
|
if (dingUserIds.length === 0) {
|
||||||
if (dingId) {
|
this.logger.warn(`班级 ${className} 无钉钉映射学生,跳过 ${classSchedules.length} 条排课`);
|
||||||
dingUserIds.push(dingId);
|
skippedNoMapping += classSchedules.length;
|
||||||
teacherDingMap.set(tid, dingId);
|
continue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (dingUserIds.length === 0) {
|
// 该班级用到的班次
|
||||||
this.logger.warn('无钉钉用户映射,跳过全部排班');
|
const classShiftIds = new Set<number>();
|
||||||
return { scheduleCount: schedules.length, shiftCount, groupCount: 0, syncedItems: 0, skippedNoMapping: schedules.length, groups: [] };
|
for (const s of classSchedules) {
|
||||||
}
|
const sid = timeToShiftId.get(shiftKey(s.startTime, s.endTime));
|
||||||
|
if (sid) classShiftIds.add(sid);
|
||||||
|
}
|
||||||
|
if (classShiftIds.size === 0) {
|
||||||
|
this.logger.warn(`班级 ${className} 无可用班次,跳过`);
|
||||||
|
skippedNoMapping += classSchedules.length;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// All used shift IDs
|
// 创建/匹配该班级的考勤组
|
||||||
const allShiftIds = new Set<number>();
|
const groupName = `排课_${className}`;
|
||||||
for (const s of schedules) {
|
let attendanceGroupId: number;
|
||||||
const key = shiftKey(s.startTime, s.endTime);
|
|
||||||
const sid = timeToShiftId.get(key);
|
|
||||||
if (sid) allShiftIds.add(sid);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create/find attendance group
|
|
||||||
const groupName = '排课_全部';
|
|
||||||
let attendanceGroupId: number;
|
|
||||||
try {
|
|
||||||
attendanceGroupId = await this.dingTalkService.findOrCreateAttendanceGroup(
|
|
||||||
groupName,
|
|
||||||
opUserId,
|
|
||||||
dingUserIds,
|
|
||||||
[...allShiftIds],
|
|
||||||
);
|
|
||||||
groupCount++;
|
|
||||||
} catch (e) {
|
|
||||||
this.logger.error(`创建考勤组 ${groupName} 失败: ${(e as Error).message}`);
|
|
||||||
return { scheduleCount: schedules.length, shiftCount, groupCount: 0, syncedItems: 0, skippedNoMapping: schedules.length, groups: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expand schedules to daily items
|
|
||||||
const items = this.expandSchedules(
|
|
||||||
schedules,
|
|
||||||
teacherDingMap,
|
|
||||||
timeToShiftId,
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
);
|
|
||||||
skippedNoMapping = schedules.length - new Set(items.map((i) => i.userid)).size;
|
|
||||||
|
|
||||||
// Batch write (max 200 per batch)
|
|
||||||
for (let i = 0; i < items.length; i += 200) {
|
|
||||||
const batch = items.slice(i, i + 200);
|
|
||||||
try {
|
try {
|
||||||
await this.dingTalkService.scheduleUsers(attendanceGroupId, batch, opUserId);
|
const cached = groupByName.get(groupName);
|
||||||
syncedItems += batch.length;
|
if (cached !== undefined) {
|
||||||
|
attendanceGroupId = cached;
|
||||||
|
} else {
|
||||||
|
attendanceGroupId = await this.dingTalkService.createAttendanceGroup({
|
||||||
|
name: groupName,
|
||||||
|
type: 'TURN',
|
||||||
|
owner: opUserId,
|
||||||
|
members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember', user_id: uid })),
|
||||||
|
shift_ids: [...classShiftIds],
|
||||||
|
enable_emp_select_class: true,
|
||||||
|
disable_check_without_schedule: false,
|
||||||
|
disable_check_when_rest: true,
|
||||||
|
});
|
||||||
|
groupByName.set(groupName, attendanceGroupId);
|
||||||
|
}
|
||||||
|
groupCount++;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.logger.error(`排班写入失败 (groupId=${attendanceGroupId}, offset=${i}): ${(e as Error).message}`);
|
this.logger.error(`创建考勤组 ${groupName} 失败: ${(e as Error).message}`);
|
||||||
|
skippedNoMapping += classSchedules.length;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 展开为每个学生的每日排班
|
||||||
|
const items = this.expandSchedules(
|
||||||
|
classSchedules, dingUserIds, timeToShiftId, startDate, endDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 批量写入(单次≤200)
|
||||||
|
let classItems = 0;
|
||||||
|
for (let i = 0; i < items.length; i += 200) {
|
||||||
|
const batch = items.slice(i, i + 200);
|
||||||
|
try {
|
||||||
|
await this.dingTalkService.scheduleUsers(attendanceGroupId, batch, opUserId);
|
||||||
|
syncedItems += batch.length;
|
||||||
|
classItems += batch.length;
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`排班写入失败 (groupId=${attendanceGroupId}, offset=${i}): ${(e as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
groupDetails.push({ className, groupId: attendanceGroupId, itemCount: classItems });
|
||||||
}
|
}
|
||||||
|
|
||||||
groupDetails.push({
|
|
||||||
deptName: groupName,
|
|
||||||
groupId: attendanceGroupId,
|
|
||||||
itemCount: items.length,
|
|
||||||
});
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
|
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
|
||||||
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射`,
|
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射`,
|
||||||
@@ -196,12 +235,52 @@ export class ScheduleSyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将排课记录展开为每日排班数组。
|
* 构建 classId → 学生钉钉 userId 列表。
|
||||||
* 每条 ClassSchedule(weekDay, startDate-endDate) → 该日期范围内所有 weekDay 对应日期的排班
|
* 一次性查询所有班级的活跃学生与钉钉映射,避免 N+1。
|
||||||
|
*/
|
||||||
|
private async buildClassDingUserMap(classIds: number[]): Promise<Map<number, string[]>> {
|
||||||
|
const result = new Map<number, string[]>();
|
||||||
|
if (classIds.length === 0) return result;
|
||||||
|
|
||||||
|
// 班级 → 活跃学生
|
||||||
|
const links = await this.classStudentRepo.find({
|
||||||
|
where: { classId: In(classIds), status: 'active' },
|
||||||
|
});
|
||||||
|
if (links.length === 0) return result;
|
||||||
|
|
||||||
|
// 学生 → 钉钉 userId
|
||||||
|
const studentIds = [...new Set(links.map((l) => l.studentId))];
|
||||||
|
const mappings = await this.mappingRepo.find({
|
||||||
|
where: { studentId: In(studentIds) },
|
||||||
|
});
|
||||||
|
const studentToDing = new Map(mappings.map((m) => [m.studentId, m.dingUserId]));
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
const dingId = studentToDing.get(link.studentId);
|
||||||
|
if (!dingId) continue;
|
||||||
|
if (!result.has(link.classId)) result.set(link.classId, []);
|
||||||
|
const arr = result.get(link.classId)!;
|
||||||
|
if (!arr.includes(dingId)) arr.push(dingId);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadClassNames(classIds: number[]): Promise<Map<number, string>> {
|
||||||
|
const map = new Map<number, string>();
|
||||||
|
if (classIds.length === 0) return map;
|
||||||
|
const classes = await this.classRepo.find({ where: { id: In(classIds) } });
|
||||||
|
for (const c of classes) map.set(c.id, c.name);
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将排课记录展开为每个学生的每日排班数组。
|
||||||
|
* 每条 ClassSchedule(weekDay, startDate-endDate) × 班级每个学生 →
|
||||||
|
* 该日期范围内所有 weekDay 对应日期的排班。
|
||||||
*/
|
*/
|
||||||
private expandSchedules(
|
private expandSchedules(
|
||||||
schedules: ClassSchedule[],
|
schedules: ClassSchedule[],
|
||||||
teacherDingMap: Map<number, string>,
|
dingUserIds: string[],
|
||||||
timeToShiftId: Map<string, number>,
|
timeToShiftId: Map<string, number>,
|
||||||
syncFrom: string,
|
syncFrom: string,
|
||||||
syncTo: string,
|
syncTo: string,
|
||||||
@@ -210,17 +289,14 @@ export class ScheduleSyncService {
|
|||||||
const fromDate = new Date(syncFrom);
|
const fromDate = new Date(syncFrom);
|
||||||
const toDate = new Date(syncTo);
|
const toDate = new Date(syncTo);
|
||||||
|
|
||||||
// 预计算日期范围内每一天是星期几
|
// 预计算日期范围内每一天是星期几(周日=7)
|
||||||
const dateWeekDays = new Map<string, number>();
|
const dateWeekDays = new Map<string, number>();
|
||||||
for (let d = new Date(fromDate); d <= toDate; d.setDate(d.getDate() + 1)) {
|
for (let d = new Date(fromDate); d <= toDate; d.setDate(d.getDate() + 1)) {
|
||||||
const dateStr = d.toISOString().slice(0, 10);
|
const dateStr = d.toISOString().slice(0, 10);
|
||||||
dateWeekDays.set(dateStr, d.getDay() === 0 ? 7 : d.getDay()); // 周日=7
|
dateWeekDays.set(dateStr, d.getDay() === 0 ? 7 : d.getDay());
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const s of schedules) {
|
for (const s of schedules) {
|
||||||
const dingUserId = s.teacherId ? teacherDingMap.get(s.teacherId) : undefined;
|
|
||||||
if (!dingUserId) continue;
|
|
||||||
|
|
||||||
const shiftId = timeToShiftId.get(`${s.startTime}-${s.endTime}`);
|
const shiftId = timeToShiftId.get(`${s.startTime}-${s.endTime}`);
|
||||||
if (!shiftId) continue;
|
if (!shiftId) continue;
|
||||||
|
|
||||||
@@ -232,12 +308,9 @@ export class ScheduleSyncService {
|
|||||||
if (weekDay !== s.weekDay) continue;
|
if (weekDay !== s.weekDay) continue;
|
||||||
|
|
||||||
const workDate = new Date(dateStr + 'T00:00:00+08:00').getTime();
|
const workDate = new Date(dateStr + 'T00:00:00+08:00').getTime();
|
||||||
items.push({
|
for (const userid of dingUserIds) {
|
||||||
userid: dingUserId,
|
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false });
|
||||||
work_date: workDate,
|
}
|
||||||
shift_id: shiftId,
|
|
||||||
is_rest: false,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,16 +323,22 @@ export class ScheduleSyncService {
|
|||||||
return d.toISOString().slice(0, 10);
|
return d.toISOString().slice(0, 10);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 获取排班同步状态:活跃排课数量 */
|
/** 获取排班同步状态:活跃排课数、有钉钉映射学生的班级数 */
|
||||||
async getStatus(_targetDate: string): Promise<{ activeSchedules: number; mappedTeachers: number; totalTeachers: number }> {
|
async getStatus(_targetDate: string): Promise<{
|
||||||
const schedules = await this.scheduleRepo.find({
|
activeSchedules: number;
|
||||||
where: { status: 'active', teacherId: Not(IsNull()) },
|
mappedClasses: number;
|
||||||
});
|
totalClasses: number;
|
||||||
// ponytail: teacher scheduling is deprecated; mappedTeachers always 0
|
}> {
|
||||||
|
const allSchedules = await this.scheduleRepo.find({ where: { status: 'active' } });
|
||||||
|
const schedules = allSchedules.filter((s) => s.classId != null);
|
||||||
|
const classIds = [...new Set(schedules.map((s) => s.classId as number))];
|
||||||
|
const classDingUsers = await this.buildClassDingUserMap(classIds);
|
||||||
|
const mappedClasses = [...classDingUsers.values()].filter((u) => u.length > 0).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeSchedules: schedules.length,
|
activeSchedules: schedules.length,
|
||||||
mappedTeachers: 0,
|
mappedClasses,
|
||||||
totalTeachers: 0,
|
totalClasses: classIds.length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
SyncState,
|
SyncState,
|
||||||
StudentDingMapping,
|
StudentDingMapping,
|
||||||
ClassSchedule,
|
ClassSchedule,
|
||||||
ClassTeacher,
|
ClassStudent,
|
||||||
User,
|
User,
|
||||||
Student,
|
Student,
|
||||||
Role,
|
Role,
|
||||||
@@ -24,7 +24,7 @@ import { ScheduleSyncService } from './schedule-sync.service';
|
|||||||
SyncState,
|
SyncState,
|
||||||
StudentDingMapping,
|
StudentDingMapping,
|
||||||
ClassSchedule,
|
ClassSchedule,
|
||||||
ClassTeacher,
|
ClassStudent,
|
||||||
User,
|
User,
|
||||||
Student,
|
Student,
|
||||||
Role,
|
Role,
|
||||||
|
|||||||
Reference in New Issue
Block a user