From 6e0ad8759fbe98a2daf7c131eb42e7fb037e2684 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 10:05:41 +0800 Subject: [PATCH] feat: syncOneUser skips Students with non-active status --- .../src/integration/dingtalk.service.ts | 416 +++++++++++++++++- 1 file changed, 415 insertions(+), 1 deletion(-) diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index 65829c6..2dd9810 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -13,6 +13,7 @@ import * as bcrypt from 'bcryptjs'; import { Department } from '../entities/department.entity'; import { User } from '../entities/user.entity'; import { Student } from '../entities/student.entity'; +import { Class } from '../entities/class.entity'; import { UserDingMapping } from '../entities/user-ding-mapping.entity'; // ── Types ── @@ -71,6 +72,92 @@ export interface DingOrgTreeNode { children: DingOrgTreeNode[]; } +// ── 考勤排班 API 类型 ── + +/** 班次卡段打卡时间 */ +export interface DingTalkShiftTime { + check_type: 'OnDuty' | 'OffDuty'; + across: number; + check_time: string; + begin_min?: number; + end_min?: number; + free_check?: boolean; +} + +/** 班次卡段 */ +export interface DingTalkShiftSection { + times: DingTalkShiftTime[]; +} + +/** 班次配置 */ +export interface DingTalkShiftSetting { + is_flexible?: boolean; + serious_late_minutes?: number; + absenteeism_late_minutes?: number; +} + +/** 创建/修改班次参数 */ +export interface DingTalkShiftParams { + id?: number; + name: string; + owner?: string; + sections: DingTalkShiftSection[]; + setting?: DingTalkShiftSetting; +} + +/** 班次摘要(查询返回) */ +export interface DingTalkShiftSummary { + id: number; + name: string; +} + +/** 考勤组成员 */ +export interface DingTalkGroupMember { + role: string; + type: 'StaffMember' | 'DeptMember'; + user_id: string; +} + +/** 创建考勤组参数 */ +export interface DingTalkGroupParams { + name: string; + type: 'TURN'; + owner: string; + members: DingTalkGroupMember[]; + shift_ids?: number[]; + enable_emp_select_class?: boolean; + disable_check_without_schedule?: boolean; + disable_check_when_rest?: boolean; +} + +/** 考勤组摘要(查询返回) */ +export interface DingTalkGroupSummary { + group_id: number; + group_name: string; + type: string; + member_count: number; +} + +/** 排班参数(单条) */ +export interface DingTalkScheduleItem { + userid: string; + work_date: number; + shift_id: number; + is_rest?: boolean; +} + +/** 排班查询结果 */ +export interface DingTalkScheduleResult { + userid: string; + work_date: string; + shift_id: number; + is_rest: string; + check_type: string; + plan_check_time: string; + group_id: number; + id: number; +} + @Injectable() export class DingTalkService { @@ -92,6 +179,8 @@ export class DingTalkService { private readonly studentRepo: Repository, @InjectRepository(UserDingMapping) private readonly mappingRepo: Repository, + @InjectRepository(Class) + private readonly classRepo: Repository, ) {} private get configured(): boolean { @@ -281,6 +370,27 @@ export class DingTalkService { } await this.deptRepo.save(syncedDepts); + // ── Step 2.5: Auto-create Class for leaf departments ── + const parentIds = new Set(syncedDepts.map((d) => d.parentSourceId)); + const leafDepts = syncedDepts.filter((d) => !parentIds.has(d.sourceId)); + let classCreated = 0; + for (const leaf of leafDepts) { + const code = `DT_${leaf.sourceId}`; + const exists = await this.classRepo.findOne({ where: { code } }); + if (!exists) { + const cls = this.classRepo.create({ + name: leaf.name, + code, + departmentId: leaf.id, + classType: 'culture', + status: 'enrolling', + }); + await this.classRepo.save(cls); + classCreated++; + } + } + if (classCreated > 0) this.logger.log(`从钉钉叶子部门自动创建 ${classCreated} 个班级`); + // ── Step 3: Sync users per department ── let userCount = 0; const seenUserIds = new Set(); @@ -398,7 +508,10 @@ export class DingTalkService { // Ensure Student record exists (backfill for users synced before this logic) const existingStudent = await this.studentRepo.findOne({ where: { userId: user.id } }); - if (!existingStudent) { + if (existingStudent && existingStudent.status !== 'active') { + // Student was manually marked as staff/graduated/withdrawn — do not overwrite + this.logger.debug(`User ${user.id} has non-active Student (${existingStudent.status}), skipping backfill`); + } else if (!existingStudent) { const student = this.studentRepo.create({ name: du.name, phone: du.mobile || undefined, @@ -486,6 +599,307 @@ export class DingTalkService { })); } + // ═══════════════════════════════════════════ + // 考勤排班 — 班次管理 + // ═══════════════════════════════════════════ + + /** 创建或修改班次。id 不传=创建,传了=修改 */ + async upsertShift(params: DingTalkShiftParams): Promise { + if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + const token = await this.getAccessToken(); + + const body: Record = { + op_user_id: params.owner || 'manager', + shift: { + name: params.name, + owner: params.owner, + sections: params.sections.map((s) => ({ + times: s.times.map((t) => ({ + check_type: t.check_type, + across: t.across, + check_time: t.check_time, + begin_min: t.begin_min ?? -1, + end_min: t.end_min ?? -1, + free_check: t.free_check ?? false, + })), + })), + setting: params.setting + ? { + is_flexible: params.setting.is_flexible ?? false, + serious_late_minutes: params.setting.serious_late_minutes ?? -1, + absenteeism_late_minutes: params.setting.absenteeism_late_minutes ?? -1, + } + : undefined, + }, + }; + if (params.id) (body.shift as Record).id = params.id; + + await this.rateLimit(); + const res = await fetch( + `https://oapi.dingtalk.com/topapi/attendance/shift/add?access_token=${token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); + const data = (await res.json()) as { + errcode: number; errmsg: string; + result?: { id: number; name: string }; + }; + if (data.errcode !== 0) { + throw new Error(`钉钉班次操作失败: ${data.errmsg} (code=${data.errcode})`); + } + this.logger.log(`钉钉班次 ${params.id ? '更新' : '创建'} 成功: ${data.result?.name} (id=${data.result?.id})`); + return data.result!.id; + } + + /** 查询所有班次摘要 */ + async queryShifts(opUserId = 'manager'): Promise { + if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + const token = await this.getAccessToken(); + + await this.rateLimit(); + const res = await fetch( + `https://oapi.dingtalk.com/topapi/attendance/shift/list?access_token=${token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ op_user_id: opUserId }), + }, + ); + const data = (await res.json()) as { + errcode: number; errmsg: string; + result?: Array<{ id: number; name: string }>; + }; + if (data.errcode !== 0) { + throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`); + } + return (data.result ?? []).map((s) => ({ id: s.id, name: s.name })); + } + + /** 按名称查找班次,不存在则创建 */ + async findOrCreateShift(name: string, startTime: string, endTime: string, opUserId = 'manager'): Promise { + 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 }, + }); + } + + // ═══════════════════════════════════════════ + // 考勤排班 — 考勤组管理 + // ═══════════════════════════════════════════ + + /** 创建排班制考勤组 */ + async createAttendanceGroup(params: DingTalkGroupParams): Promise { + if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + const token = await this.getAccessToken(); + + const topGroup: Record = { + name: params.name, + type: params.type, + owner: params.owner, + members: params.members.map((m) => ({ + role: m.role, + type: m.type, + user_id: m.user_id, + })), + enable_emp_select_class: params.enable_emp_select_class ?? true, + disable_check_without_schedule: params.disable_check_without_schedule ?? false, + disable_check_when_rest: params.disable_check_when_rest ?? true, + }; + if (params.shift_ids?.length) { + topGroup.shift_vo_list = params.shift_ids.map((id) => ({ id })); + } + + const body = { op_user_id: params.owner, top_group: topGroup }; + + await this.rateLimit(); + const res = await fetch( + `https://oapi.dingtalk.com/topapi/attendance/group/add?access_token=${token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); + const data = (await res.json()) as { + errcode: number; errmsg: string; + result?: { id: number }; + }; + if (data.errcode !== 0) { + throw new Error(`钉钉创建考勤组失败: ${data.errmsg} (code=${data.errcode})`); + } + this.logger.log(`钉钉考勤组创建成功: ${params.name} (id=${data.result?.id})`); + return data.result!.id; + } + + /** 查询所有考勤组摘要(分页,每页10条) */ + async queryAttendanceGroups(opUserId = 'manager'): Promise { + if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + const token = await this.getAccessToken(); + + const all: DingTalkGroupSummary[] = []; + let offset = 0; + let hasMore = true; + + while (hasMore) { + await this.rateLimit(); + const res = await fetch( + `https://oapi.dingtalk.com/topapi/attendance/getsimplegroups?access_token=${token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ offset, size: 10 }), + }, + ); + const data = (await res.json()) as { + errcode: number; errmsg: string; + result?: { + has_more: boolean; + groups: Array<{ group_id: number; group_name: string; type: string; member_count: number }>; + }; + }; + if (data.errcode !== 0) { + throw new Error(`钉钉查询考勤组失败: ${data.errmsg} (code=${data.errcode})`); + } + if (data.result?.groups) { + all.push(...data.result.groups.map((g) => ({ + group_id: g.group_id, + group_name: g.group_name, + type: g.type, + member_count: g.member_count, + }))); + } + hasMore = data.result?.has_more ?? false; + offset += 10; + } + return all; + } + + /** 按名称查找考勤组,不存在则创建 */ + async findOrCreateAttendanceGroup( + name: string, ownerUserId: string, memberUserIds: string[], shiftIds: number[], + ): Promise { + 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, + }); + } + + // ═══════════════════════════════════════════ + // 考勤排班 — 排班分配 + // ═══════════════════════════════════════════ + + /** 批量排班(单次最多200条) */ + async scheduleUsers( + groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager', + ): Promise { + if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + if (schedules.length === 0) return; + if (schedules.length > 200) { + throw new Error(`排班单次最多200条,当前 ${schedules.length} 条`); + } + + const token = await this.getAccessToken(); + const body = { + op_user_id: opUserId, + group_id: groupId, + schedules: schedules.map((s) => ({ + userid: s.userid, + work_date: s.work_date, + shift_id: s.shift_id, + is_rest: s.is_rest ?? false, + })), + }; + + await this.rateLimit(); + const res = await fetch( + `https://oapi.dingtalk.com/topapi/attendance/group/schedule/async?access_token=${token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); + const data = (await res.json()) as { + errcode: number; errmsg: string; + }; + if (data.errcode !== 0) { + throw new Error(`钉钉排班失败: ${data.errmsg} (code=${data.errcode})`); + } + this.logger.log(`钉钉排班成功: groupId=${groupId}, ${schedules.length} 条`); + } + + /** 查询指定用户的排班信息(7天内,最多50人) */ + async queryScheduleByUsers( + userIds: string[], fromDate: number, toDate: number, opUserId = 'manager', + ): Promise { + if (!this.configured) throw new ServiceUnavailableException('钉钉未配置'); + const token = await this.getAccessToken(); + + await this.rateLimit(); + const res = await fetch( + `https://oapi.dingtalk.com/topapi/attendance/schedule/listbyusers?access_token=${token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + op_user_id: opUserId, + userids: userIds.join(','), + from_date_time: fromDate, + to_date_time: toDate, + }), + }, + ); + const data = (await res.json()) as { + errcode: number; errmsg: string; + result?: Array<{ + userid: string; work_date: string; shift_id: number; + is_rest: string; check_type: string; plan_check_time: string; + group_id: number; id: number; + }>; + }; + if (data.errcode !== 0) { + throw new Error(`钉钉查询排班失败: ${data.errmsg} (code=${data.errcode})`); + } + return (data.result ?? []).map((r) => ({ + userid: r.userid, + work_date: r.work_date, + shift_id: r.shift_id, + is_rest: r.is_rest, + check_type: r.check_type, + plan_check_time: r.plan_check_time, + group_id: r.group_id, + id: r.id, + })); + } + private delay(requestIndex: number): Promise { const ms = requestIndex % 10 === 0 ? 200 : DingTalkService.MIN_INTERVAL; return this.sleep(ms);