feat: 钉钉学生同步支持仅绑定手机号及学生列表修复
This commit is contained in:
@@ -28,6 +28,7 @@ import {
|
||||
InboxOutlined,
|
||||
PlusOutlined,
|
||||
SwapOutlined,
|
||||
SyncOutlined,
|
||||
UndoOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
@@ -39,6 +40,7 @@ import JinshujuMatchModal from '../../components/JinshujuMatchModal';
|
||||
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useUserStore } from '../../store/user/userStore';
|
||||
import { selectArchiveRecords } from '../archive-view';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
@@ -79,6 +81,17 @@ interface StudentUpdateImportResult {
|
||||
skipped?: number;
|
||||
}
|
||||
|
||||
interface DingTalkSyncLog {
|
||||
status: string;
|
||||
recordsCount: number;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
interface DingTalkSyncResult {
|
||||
synced: number;
|
||||
logs: DingTalkSyncLog[];
|
||||
}
|
||||
|
||||
interface StudentFilterLookups {
|
||||
classes: Array<{ id: number; name: string; code?: string }>;
|
||||
teachers: Array<{ id: number; name: string; username: string }>;
|
||||
@@ -98,6 +111,7 @@ const StudentsPage: React.FC = () => {
|
||||
const canEditStudent = hasPermission('student:edit');
|
||||
const canDeleteStudent = hasPermission('student:delete');
|
||||
const canSyncJinshuju = hasAllPermissions('sync:read', 'sync:trigger');
|
||||
const canSyncDingTalk = hasAllPermissions('sync:read', 'sync:trigger');
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -114,7 +128,9 @@ const StudentsPage: React.FC = () => {
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [dingSyncLoading, setDingSyncLoading] = useState(false);
|
||||
const [enrollmentData, setEnrollmentData] = useState<Record<number, EnrollmentInfo[]>>({});
|
||||
const [pageInfo, setPageInfo] = useState({ current: 1, pageSize: 15 });
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerStudentId, setDrawerStudentId] = useState<number | undefined>(undefined);
|
||||
const [form] = Form.useForm();
|
||||
@@ -336,7 +352,7 @@ const StudentsPage: React.FC = () => {
|
||||
const baseURL = import.meta.env.PROD
|
||||
? '/api'
|
||||
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
fetch(`${baseURL}/students/template`, { headers: { Authorization: `Bearer ${token}` } })
|
||||
.then((res) => res.blob())
|
||||
.then((blob) => {
|
||||
@@ -441,11 +457,33 @@ const StudentsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDingTalkSync = async () => {
|
||||
setDingSyncLoading(true);
|
||||
try {
|
||||
const res = await api.post<DingTalkSyncResult>('/sync/trigger', null, {
|
||||
params: { platform: 'dingtalk_students', createMissing: false, updateProfile: false },
|
||||
timeout: 120000,
|
||||
});
|
||||
const log = res.logs?.[0];
|
||||
if (log?.status === 'partial') {
|
||||
message.warning(log.errorMessage || '钉钉同步完成,但有数据需要人工处理');
|
||||
} else {
|
||||
message.success(log?.errorMessage || `钉钉同步完成,共处理 ${res.synced} 条`);
|
||||
}
|
||||
await fetchData();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '钉钉同步失败');
|
||||
} finally {
|
||||
setDingSyncLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const baseURL = import.meta.env.PROD
|
||||
? '/api'
|
||||
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
|
||||
const token = localStorage.getItem('token');
|
||||
const token = useUserStore.getState().token;
|
||||
const params = new URLSearchParams();
|
||||
if (searchName) params.set('name', searchName);
|
||||
if (filterStatus) params.set('status', filterStatus);
|
||||
@@ -469,7 +507,13 @@ const StudentsPage: React.FC = () => {
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{
|
||||
title: '序号',
|
||||
key: 'index',
|
||||
width: 70,
|
||||
render: (_: unknown, __: unknown, index: number) =>
|
||||
(pageInfo.current - 1) * pageInfo.pageSize + index + 1,
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
@@ -741,6 +785,7 @@ const StudentsPage: React.FC = () => {
|
||||
saveCell,
|
||||
hasPermission,
|
||||
canChooseOrganization,
|
||||
pageInfo,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -904,6 +949,11 @@ const StudentsPage: React.FC = () => {
|
||||
同步金数据
|
||||
</Button>
|
||||
) : null}
|
||||
{!showArchived && canSyncDingTalk ? (
|
||||
<Button icon={<SyncOutlined />} loading={dingSyncLoading} onClick={handleDingTalkSync}>
|
||||
同步钉钉
|
||||
</Button>
|
||||
) : null}
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
icon={<DownloadOutlined />}
|
||||
@@ -920,6 +970,23 @@ const StudentsPage: React.FC = () => {
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
</div>
|
||||
{selectedRowKeys.length > 0 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
title={
|
||||
<span>
|
||||
已选 <strong style={{ color: '#1677ff' }}>{selectedRowKeys.length}</strong> 人(支持跨页勾选)
|
||||
</span>
|
||||
}
|
||||
action={
|
||||
<Button size="small" type="link" onClick={() => setSelectedRowKeys([])}>
|
||||
清空选择
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Alert
|
||||
showIcon
|
||||
type="warning"
|
||||
@@ -940,9 +1007,12 @@ const StudentsPage: React.FC = () => {
|
||||
scroll={{ x: 1410 }}
|
||||
pagination={{
|
||||
defaultPageSize: 15,
|
||||
current: pageInfo.current,
|
||||
pageSize: pageInfo.pageSize,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [15, 30, 50, 100],
|
||||
showTotal: (total) => `共 ${total} 人`,
|
||||
onChange: (current, pageSize) => setPageInfo({ current, pageSize }),
|
||||
}}
|
||||
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||
rowSelection={{
|
||||
|
||||
@@ -58,7 +58,7 @@ describe('syncDingTalkStudents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a phone conflict without creating a duplicate student', async () => {
|
||||
it('binds an existing student by unique phone and creates a mapping', async () => {
|
||||
const occupied = { id: 5, phone: '13800000000' } as Student;
|
||||
const { manager, saves } = managerFixture({ occupiedPhones: [occupied] });
|
||||
|
||||
@@ -66,13 +66,79 @@ describe('syncDingTalkStudents', () => {
|
||||
{ dingUserId: 'u2', name: '李四', mobile: occupied.phone },
|
||||
]);
|
||||
|
||||
expect(result.created).toBe(0);
|
||||
expect(result).toMatchObject({ created: 0, updated: 1, matched: 1, conflicts: [] });
|
||||
expect(saves).toContainEqual({
|
||||
entity: Student,
|
||||
values: [expect.objectContaining({ id: 5, name: '李四' })],
|
||||
});
|
||||
expect(saves).toContainEqual({
|
||||
entity: StudentDingMapping,
|
||||
values: [expect.objectContaining({ dingUserId: 'u2', studentId: 5 })],
|
||||
});
|
||||
});
|
||||
|
||||
it('reports a conflict when a phone matches multiple students', async () => {
|
||||
const occupied = [
|
||||
{ id: 5, phone: '13800000000' },
|
||||
{ id: 6, phone: '13800000000' },
|
||||
] as Student[];
|
||||
const { manager, saves } = managerFixture({ occupiedPhones: occupied });
|
||||
|
||||
const result = await syncDingTalkStudents(manager, [
|
||||
{ dingUserId: 'u2', name: '李四', mobile: '13800000000' },
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject({ created: 0, updated: 0, matched: 0 });
|
||||
expect(result.conflicts).toEqual([
|
||||
expect.objectContaining({ dingUserId: 'u2', reason: expect.stringContaining('人工绑定') }),
|
||||
expect.objectContaining({ reason: expect.stringContaining('多名学生') }),
|
||||
]);
|
||||
expect(saves).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips unmatched users when createMissing is false', async () => {
|
||||
const { manager, saves } = managerFixture();
|
||||
|
||||
const result = await syncDingTalkStudents(
|
||||
manager,
|
||||
[{ dingUserId: 'u4', name: '赵六', mobile: '13700000000' }],
|
||||
{ createMissing: false, updateProfile: false },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
created: 0,
|
||||
updated: 0,
|
||||
matched: 0,
|
||||
skipped: 1,
|
||||
conflicts: [],
|
||||
});
|
||||
expect(saves).toEqual([]);
|
||||
});
|
||||
|
||||
it('creates only the mapping without touching the student profile when updateProfile is false', async () => {
|
||||
const occupied = { id: 5, name: '原名', phone: '13800000000' } as Student;
|
||||
const { manager, saves } = managerFixture({ occupiedPhones: [occupied] });
|
||||
|
||||
const result = await syncDingTalkStudents(
|
||||
manager,
|
||||
[{ dingUserId: 'u2', name: '钉钉名', mobile: occupied.phone }],
|
||||
{ createMissing: false, updateProfile: false },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
created: 0,
|
||||
updated: 0,
|
||||
matched: 1,
|
||||
skipped: 0,
|
||||
conflicts: [],
|
||||
});
|
||||
expect(occupied).toMatchObject({ name: '原名', phone: '13800000000' });
|
||||
expect(saves.filter((save) => save.entity === Student)).toEqual([]);
|
||||
expect(saves).toContainEqual({
|
||||
entity: StudentDingMapping,
|
||||
values: [expect.objectContaining({ dingUserId: 'u2', studentId: 5 })],
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces mapping persistence failure so the surrounding transaction can roll back', async () => {
|
||||
const { manager } = managerFixture({ failMappingSave: true });
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface DingTalkStudentConflict {
|
||||
export interface DingTalkStudentSyncResult {
|
||||
created: number;
|
||||
updated: number;
|
||||
matched: number;
|
||||
skipped: number;
|
||||
studentIds: Map<string, number>;
|
||||
conflicts: DingTalkStudentConflict[];
|
||||
}
|
||||
@@ -23,7 +25,10 @@ export interface DingTalkStudentSyncResult {
|
||||
export async function syncDingTalkStudents(
|
||||
manager: EntityManager,
|
||||
inputs: DingTalkStudentInput[],
|
||||
options: { createMissing?: boolean; updateProfile?: boolean } = {},
|
||||
): Promise<DingTalkStudentSyncResult> {
|
||||
const createMissing = options.createMissing !== false;
|
||||
const updateProfile = options.updateProfile !== false;
|
||||
const users = new Map<string, DingTalkStudentInput>();
|
||||
const conflicts: DingTalkStudentConflict[] = [];
|
||||
|
||||
@@ -43,7 +48,7 @@ export async function syncDingTalkStudents(
|
||||
}
|
||||
|
||||
if (users.size === 0) {
|
||||
return { created: 0, updated: 0, studentIds: new Map(), conflicts };
|
||||
return { created: 0, updated: 0, matched: 0, skipped: 0, studentIds: new Map(), conflicts };
|
||||
}
|
||||
|
||||
const dingUserIds = [...users.keys()];
|
||||
@@ -58,6 +63,7 @@ export async function syncDingTalkStudents(
|
||||
const studentById = new Map(mappedStudents.map((student) => [student.id, student]));
|
||||
const studentIds = new Map<string, number>();
|
||||
const updates: Student[] = [];
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const mapping of mappings) {
|
||||
const input = users.get(mapping.dingUserId);
|
||||
@@ -71,9 +77,20 @@ export async function syncDingTalkStudents(
|
||||
continue;
|
||||
}
|
||||
studentIds.set(mapping.dingUserId, student.id);
|
||||
student.name = input.name;
|
||||
if (input.mobile) student.phone = input.mobile;
|
||||
updates.push(student);
|
||||
if (!updateProfile) continue;
|
||||
let changed = false;
|
||||
if (student.name !== input.name) {
|
||||
student.name = input.name;
|
||||
changed = true;
|
||||
}
|
||||
if (input.mobile && student.phone !== input.mobile) {
|
||||
student.phone = input.mobile;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
updates.push(student);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const newUsers = [...users.values()].filter((user) => !mappingByDingId.has(user.dingUserId));
|
||||
@@ -81,19 +98,91 @@ export async function syncDingTalkStudents(
|
||||
const occupiedPhones = mobiles.length
|
||||
? await manager.find(Student, { where: { phone: In(mobiles) } })
|
||||
: [];
|
||||
const studentByPhone = new Map(occupiedPhones.map((student) => [student.phone, student]));
|
||||
const creatable = newUsers.filter((user) => {
|
||||
if (!user.mobile || !studentByPhone.has(user.mobile)) return true;
|
||||
conflicts.push({ dingUserId: user.dingUserId, name: user.name, reason: '手机号已属于其他学生,请人工绑定' });
|
||||
return false;
|
||||
});
|
||||
const studentsByPhone = new Map<string, Student[]>();
|
||||
for (const student of occupiedPhones) {
|
||||
const list = studentsByPhone.get(student.phone) ?? [];
|
||||
list.push(student);
|
||||
studentsByPhone.set(student.phone, list);
|
||||
}
|
||||
|
||||
// 新钉钉用户按手机号匹配:唯一命中 → 自动绑定;多人同号 → 冲突;无命中 → 新建
|
||||
const boundStudentIds = new Set(mappings.map((mapping) => mapping.studentId));
|
||||
const bindable: DingTalkStudentInput[] = [];
|
||||
const creatable: DingTalkStudentInput[] = [];
|
||||
const skipped: DingTalkStudentInput[] = [];
|
||||
for (const user of newUsers) {
|
||||
if (!user.mobile) {
|
||||
(createMissing ? creatable : skipped).push(user);
|
||||
continue;
|
||||
}
|
||||
const matches = studentsByPhone.get(user.mobile) ?? [];
|
||||
if (matches.length === 0) {
|
||||
(createMissing ? creatable : skipped).push(user);
|
||||
continue;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
conflicts.push({
|
||||
dingUserId: user.dingUserId,
|
||||
name: user.name,
|
||||
reason: '手机号匹配到多名学生,请人工绑定',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const student = matches[0];
|
||||
if (boundStudentIds.has(student.id)) {
|
||||
conflicts.push({
|
||||
dingUserId: user.dingUserId,
|
||||
name: user.name,
|
||||
reason: '手机号对应的学生已绑定其他钉钉账号',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
boundStudentIds.add(student.id);
|
||||
bindable.push(user);
|
||||
}
|
||||
|
||||
const host = creatable.length
|
||||
? await manager.findOne(Organization, { where: { isHost: true, status: 'active' } })
|
||||
: null;
|
||||
if (creatable.length && !host) throw new Error('尚未配置本机构');
|
||||
|
||||
const bindableStudent = new Map<string, Student>();
|
||||
for (const user of bindable) {
|
||||
const student = studentsByPhone.get(user.mobile!)![0];
|
||||
bindableStudent.set(user.dingUserId, student);
|
||||
if (!updateProfile) continue;
|
||||
let changed = false;
|
||||
if (student.name !== user.name) {
|
||||
student.name = user.name;
|
||||
changed = true;
|
||||
}
|
||||
if (user.mobile && student.phone !== user.mobile) {
|
||||
student.phone = user.mobile;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
updates.push(student);
|
||||
updatedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length) await manager.save(Student, updates);
|
||||
|
||||
if (bindable.length) {
|
||||
await manager.save(
|
||||
StudentDingMapping,
|
||||
bindable.map((user) =>
|
||||
manager.create(StudentDingMapping, {
|
||||
dingUserId: user.dingUserId,
|
||||
studentId: bindableStudent.get(user.dingUserId)!.id,
|
||||
}),
|
||||
),
|
||||
);
|
||||
for (const user of bindable) {
|
||||
studentIds.set(user.dingUserId, bindableStudent.get(user.dingUserId)!.id);
|
||||
}
|
||||
}
|
||||
|
||||
const createdStudents = creatable.length
|
||||
? await manager.save(
|
||||
Student,
|
||||
@@ -120,5 +209,12 @@ export async function syncDingTalkStudents(
|
||||
createdStudents.forEach((student, index) => studentIds.set(creatable[index].dingUserId, student.id));
|
||||
}
|
||||
|
||||
return { created: createdStudents.length, updated: updates.length, studentIds, conflicts };
|
||||
return {
|
||||
created: createdStudents.length,
|
||||
updated: updatedCount,
|
||||
matched: bindable.length,
|
||||
skipped: skipped.length,
|
||||
studentIds,
|
||||
conflicts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -335,11 +335,16 @@ export class DingTalkService {
|
||||
// Sync all — 主入口
|
||||
// ═══════════════════════════════════════════
|
||||
|
||||
async syncAll(rootDeptId = 1): Promise<{
|
||||
async syncAll(
|
||||
rootDeptId = 1,
|
||||
options: { createMissing?: boolean; updateProfile?: boolean } = {},
|
||||
): Promise<{
|
||||
deptCount: number;
|
||||
userCount: number;
|
||||
created: number;
|
||||
updated: number;
|
||||
matched: number;
|
||||
skipped: number;
|
||||
conflicts: Array<{ dingUserId: string; name: string; reason: string }>;
|
||||
}> {
|
||||
if (!(await this.isConfigured())) {
|
||||
@@ -373,11 +378,12 @@ export class DingTalkService {
|
||||
}
|
||||
}
|
||||
const result = await this.dataSource.transaction((manager) =>
|
||||
syncDingTalkStudents(manager, [...users.values()]),
|
||||
syncDingTalkStudents(manager, [...users.values()], options),
|
||||
);
|
||||
this.logger.log(
|
||||
`钉钉同步完成: ${users.size} 个用户, ${allDeptIds.length} 个部门, ` +
|
||||
`${result.created} 个新增, ${result.updated} 个更新, ${result.conflicts.length} 个冲突, ` +
|
||||
`${result.created} 个新增, ${result.updated} 个更新, ${result.matched} 个手机号绑定, ` +
|
||||
`${result.skipped} 个跳过, ${result.conflicts.length} 个冲突, ` +
|
||||
`API 请求 ${this.apiRequestCount} 次, 耗时 ${Date.now() - t0}ms`,
|
||||
);
|
||||
return {
|
||||
@@ -385,6 +391,8 @@ export class DingTalkService {
|
||||
userCount: users.size,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
matched: result.matched,
|
||||
skipped: result.skipped,
|
||||
conflicts: result.conflicts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -638,6 +638,7 @@ export class StudentsService {
|
||||
'student.gender',
|
||||
'student.status',
|
||||
'student.organizationId',
|
||||
'student.createdAt',
|
||||
'organization.name',
|
||||
])
|
||||
.leftJoin('student.organization', 'organization');
|
||||
@@ -648,12 +649,12 @@ export class StudentsService {
|
||||
// ---- Filters ----
|
||||
if (query?.keyword) {
|
||||
qb.andWhere(
|
||||
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
|
||||
'(student.name LIKE :keyword OR student.student_no LIKE :keyword)',
|
||||
{ keyword: `%${query.keyword}%` },
|
||||
);
|
||||
}
|
||||
if (query?.organizationId) {
|
||||
qb.andWhere('student.organizationId = :orgId', { orgId: query.organizationId });
|
||||
qb.andWhere('student.organization_id = :orgId', { orgId: query.organizationId });
|
||||
}
|
||||
|
||||
qb.orderBy('student.createdAt', 'DESC').take(limit);
|
||||
@@ -668,12 +669,12 @@ export class StudentsService {
|
||||
const csQb = this.classStudentRepo
|
||||
.createQueryBuilder('cs')
|
||||
.select(['cs.studentId', 'cs.classId'])
|
||||
.where('cs.studentId IN (:...ids)', { ids: studentIds })
|
||||
.where('cs.student_id IN (:...ids)', { ids: studentIds })
|
||||
.andWhere('cs.status = :status', { status: 'active' });
|
||||
|
||||
if (scope.type === 'teacher') {
|
||||
csQb.andWhere(
|
||||
'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
|
||||
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
|
||||
{ scopeTeacherUserId: scope.userId },
|
||||
);
|
||||
}
|
||||
@@ -740,12 +741,12 @@ export class StudentsService {
|
||||
const csQb = this.classStudentRepo
|
||||
.createQueryBuilder('cs')
|
||||
.select(['cs.classId'])
|
||||
.where('cs.studentId = :studentId', { studentId })
|
||||
.where('cs.student_id = :studentId', { studentId })
|
||||
.andWhere('cs.status = :status', { status: 'active' });
|
||||
|
||||
if (scope.type === 'teacher') {
|
||||
csQb.andWhere(
|
||||
'cs.classId IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
|
||||
'cs.class_id IN (SELECT ct.class_id FROM class_teacher ct WHERE ct.user_id = :scopeTeacherUserId)',
|
||||
{ scopeTeacherUserId: scope.userId },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('SyncController — schedule sync options', () => {
|
||||
|
||||
await controller.triggerSync('dingtalk_students', '12');
|
||||
|
||||
expect(syncService.triggerSync).toHaveBeenCalledWith('dingtalk_students', 12);
|
||||
expect(syncService.triggerSync).toHaveBeenCalledWith('dingtalk_students', 12, true, true);
|
||||
});
|
||||
|
||||
it('returns Jinshuju form fields for the selector', async () => {
|
||||
|
||||
@@ -16,9 +16,16 @@ export class SyncController {
|
||||
async triggerSync(
|
||||
@Query('platform') platform?: SyncPlatform,
|
||||
@Query('rootDeptId') rootDeptId?: string,
|
||||
@Query('createMissing') createMissing?: string,
|
||||
@Query('updateProfile') updateProfile?: string,
|
||||
) {
|
||||
const rootId = rootDeptId ? this.parseRootDeptId(rootDeptId) : 1;
|
||||
const logs = await this.syncService.triggerSync(platform, rootId);
|
||||
const logs = await this.syncService.triggerSync(
|
||||
platform,
|
||||
rootId,
|
||||
createMissing !== 'false',
|
||||
updateProfile !== 'false',
|
||||
);
|
||||
return { synced: logs.length, logs };
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,13 @@ function createService(options?: {
|
||||
find: jest.fn(),
|
||||
};
|
||||
const dingTalkService = {
|
||||
syncAll: jest.fn().mockResolvedValue({ created: 1, updated: 2, conflicts: [] }),
|
||||
syncAll: jest.fn().mockResolvedValue({
|
||||
created: 1,
|
||||
updated: 2,
|
||||
matched: 0,
|
||||
skipped: 0,
|
||||
conflicts: [],
|
||||
}),
|
||||
};
|
||||
const attendanceImportService = {
|
||||
importFromDingTalk: jest.fn().mockResolvedValue(options?.attendanceResult ?? {
|
||||
|
||||
@@ -34,13 +34,23 @@ export class SyncService {
|
||||
private readonly dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async syncDingTalkStudents(rootDeptId = 1): Promise<SyncLog> {
|
||||
async syncDingTalkStudents(
|
||||
rootDeptId = 1,
|
||||
createMissing = true,
|
||||
updateProfile = true,
|
||||
): Promise<SyncLog> {
|
||||
return this.runSync('dingtalk_students', async () => {
|
||||
const result = await this.dingTalkService.syncAll(rootDeptId);
|
||||
const result = await this.dingTalkService.syncAll(rootDeptId, { createMissing, updateProfile });
|
||||
return {
|
||||
recordsCount: result.created + result.updated,
|
||||
recordsCount: result.created + result.updated + (result.matched ?? 0),
|
||||
status: result.conflicts.length ? 'partial' : 'success',
|
||||
message: result.conflicts.length ? JSON.stringify(result.conflicts.slice(0, 20)) : undefined,
|
||||
message: result.conflicts.length
|
||||
? JSON.stringify(result.conflicts.slice(0, 20))
|
||||
: !createMissing && !updateProfile
|
||||
? `手机号绑定 ${result.matched ?? 0} 人,跳过 ${result.skipped ?? 0} 人`
|
||||
: createMissing
|
||||
? `新增 ${result.created} 人,更新 ${result.updated} 人,手机号绑定 ${result.matched ?? 0} 人`
|
||||
: `手机号绑定 ${result.matched ?? 0} 人,更新 ${result.updated} 人,跳过 ${result.skipped ?? 0} 人`,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -220,12 +230,19 @@ export class SyncService {
|
||||
});
|
||||
}
|
||||
|
||||
async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise<SyncLog[]> {
|
||||
if (platform === 'dingtalk_students') return [await this.syncDingTalkStudents(rootDeptId)];
|
||||
async triggerSync(
|
||||
platform?: SyncPlatform,
|
||||
rootDeptId = 1,
|
||||
createMissing = true,
|
||||
updateProfile = true,
|
||||
): Promise<SyncLog[]> {
|
||||
if (platform === 'dingtalk_students') {
|
||||
return [await this.syncDingTalkStudents(rootDeptId, createMissing, updateProfile)];
|
||||
}
|
||||
if (platform === 'dingtalk_attendance') return [await this.syncDingTalkAttendance()];
|
||||
if (platform === 'wecom') return [await this.syncWeCom()];
|
||||
return [
|
||||
await this.syncDingTalkStudents(rootDeptId),
|
||||
await this.syncDingTalkStudents(rootDeptId, createMissing, updateProfile),
|
||||
await this.syncDingTalkAttendance(),
|
||||
await this.syncWeCom(),
|
||||
];
|
||||
@@ -274,6 +291,33 @@ export class SyncService {
|
||||
return this.scheduleSyncService.getStatus(date || new Date().toISOString().slice(0, 10));
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent tool: 汇总各平台最近一次同步状态和排课映射状态。
|
||||
*/
|
||||
async agentGetSyncStatus(): Promise<{
|
||||
dingTalkStudents: { lastSyncAt: Date | null; status: string } | null;
|
||||
dingTalkAttendance: { lastSyncAt: Date | null; status: string } | null;
|
||||
weCom: { lastSyncAt: Date | null; status: string } | null;
|
||||
schedule: { activeSchedules: number; mappedClasses: number; totalClasses: number };
|
||||
}> {
|
||||
const [students, attendance, weCom, schedule] = await Promise.all([
|
||||
this.getLastSync('dingtalk_students'),
|
||||
this.getLastSync('dingtalk_attendance'),
|
||||
this.getLastSync('wecom'),
|
||||
this.getScheduleSyncStatus(),
|
||||
]);
|
||||
return {
|
||||
dingTalkStudents: students
|
||||
? { lastSyncAt: students.finishedAt ?? null, status: students.status }
|
||||
: null,
|
||||
dingTalkAttendance: attendance
|
||||
? { lastSyncAt: attendance.finishedAt ?? null, status: attendance.status }
|
||||
: null,
|
||||
weCom: weCom ? { lastSyncAt: weCom.finishedAt ?? null, status: weCom.status } : null,
|
||||
schedule,
|
||||
};
|
||||
}
|
||||
|
||||
async getLogs(platform?: SyncPlatform, limit = 50): Promise<SyncLog[]> {
|
||||
const where: Record<string, SyncPlatform> = {};
|
||||
if (platform) where.platform = platform;
|
||||
|
||||
Reference in New Issue
Block a user