forked from wangziqi/gongxue-base
feat: syncOneUser skips Students with non-active status
This commit is contained in:
@@ -13,6 +13,7 @@ import * as bcrypt from 'bcryptjs';
|
|||||||
import { Department } from '../entities/department.entity';
|
import { Department } from '../entities/department.entity';
|
||||||
import { User } from '../entities/user.entity';
|
import { User } from '../entities/user.entity';
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
|
import { Class } from '../entities/class.entity';
|
||||||
import { UserDingMapping } from '../entities/user-ding-mapping.entity';
|
import { UserDingMapping } from '../entities/user-ding-mapping.entity';
|
||||||
|
|
||||||
// ── Types ──
|
// ── Types ──
|
||||||
@@ -71,6 +72,92 @@ export interface DingOrgTreeNode {
|
|||||||
children: 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()
|
@Injectable()
|
||||||
export class DingTalkService {
|
export class DingTalkService {
|
||||||
@@ -92,6 +179,8 @@ export class DingTalkService {
|
|||||||
private readonly studentRepo: Repository<Student>,
|
private readonly studentRepo: Repository<Student>,
|
||||||
@InjectRepository(UserDingMapping)
|
@InjectRepository(UserDingMapping)
|
||||||
private readonly mappingRepo: Repository<UserDingMapping>,
|
private readonly mappingRepo: Repository<UserDingMapping>,
|
||||||
|
@InjectRepository(Class)
|
||||||
|
private readonly classRepo: Repository<Class>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private get configured(): boolean {
|
private get configured(): boolean {
|
||||||
@@ -281,6 +370,27 @@ export class DingTalkService {
|
|||||||
}
|
}
|
||||||
await this.deptRepo.save(syncedDepts);
|
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 ──
|
// ── Step 3: Sync users per department ──
|
||||||
let userCount = 0;
|
let userCount = 0;
|
||||||
const seenUserIds = new Set<string>();
|
const seenUserIds = new Set<string>();
|
||||||
@@ -398,7 +508,10 @@ export class DingTalkService {
|
|||||||
|
|
||||||
// Ensure Student record exists (backfill for users synced before this logic)
|
// Ensure Student record exists (backfill for users synced before this logic)
|
||||||
const existingStudent = await this.studentRepo.findOne({ where: { userId: user.id } });
|
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({
|
const student = this.studentRepo.create({
|
||||||
name: du.name,
|
name: du.name,
|
||||||
phone: du.mobile || undefined,
|
phone: du.mobile || undefined,
|
||||||
@@ -486,6 +599,307 @@ export class DingTalkService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 考勤排班 — 班次管理
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
/** 创建或修改班次。id 不传=创建,传了=修改 */
|
||||||
|
async upsertShift(params: DingTalkShiftParams): Promise<number> {
|
||||||
|
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||||
|
const token = await this.getAccessToken();
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
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<string, unknown>).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<DingTalkShiftSummary[]> {
|
||||||
|
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<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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 考勤排班 — 考勤组管理
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
/** 创建排班制考勤组 */
|
||||||
|
async createAttendanceGroup(params: DingTalkGroupParams): Promise<number> {
|
||||||
|
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||||
|
const token = await this.getAccessToken();
|
||||||
|
|
||||||
|
const topGroup: Record<string, unknown> = {
|
||||||
|
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<DingTalkGroupSummary[]> {
|
||||||
|
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<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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
// 考勤排班 — 排班分配
|
||||||
|
// ═══════════════════════════════════════════
|
||||||
|
|
||||||
|
/** 批量排班(单次最多200条) */
|
||||||
|
async scheduleUsers(
|
||||||
|
groupId: number, schedules: DingTalkScheduleItem[], opUserId = 'manager',
|
||||||
|
): Promise<void> {
|
||||||
|
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<DingTalkScheduleResult[]> {
|
||||||
|
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<void> {
|
private delay(requestIndex: number): Promise<void> {
|
||||||
const ms = requestIndex % 10 === 0 ? 200 : DingTalkService.MIN_INTERVAL;
|
const ms = requestIndex % 10 === 0 ? 200 : DingTalkService.MIN_INTERVAL;
|
||||||
return this.sleep(ms);
|
return this.sleep(ms);
|
||||||
|
|||||||
Reference in New Issue
Block a user