fix permissions and teacher attendance workflows
This commit is contained in:
174
apps/server/src/integration/dingtalk.service.spec.ts
Normal file
174
apps/server/src/integration/dingtalk.service.spec.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { DingTalkService } from './dingtalk.service';
|
||||
|
||||
describe('DingTalkService — queryShifts', () => {
|
||||
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,
|
||||
});
|
||||
jest.spyOn(service as never, 'rateLimit').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
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('unwraps the paged result object returned by DingTalk', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
result: {
|
||||
cursor: 678215070,
|
||||
has_more: false,
|
||||
result: [
|
||||
{ id: 677995086, name: 'A' },
|
||||
{ id: 678215070, name: 'B' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await expect(service.queryShifts('manager')).resolves.toEqual([
|
||||
{ id: 677995086, name: 'A' },
|
||||
{ id: 678215070, name: 'B' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('requests subsequent pages using the cursor returned by DingTalk', async () => {
|
||||
global.fetch = jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
result: {
|
||||
cursor: 200,
|
||||
has_more: true,
|
||||
result: [{ id: 100, name: '早班' }],
|
||||
},
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
result: {
|
||||
cursor: 300,
|
||||
has_more: false,
|
||||
result: [{ id: 200, name: '晚班' }],
|
||||
},
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await expect(service.queryShifts('manager')).resolves.toEqual([
|
||||
{ id: 100, name: '早班' },
|
||||
{ id: 200, name: '晚班' },
|
||||
]);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||
expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toEqual({
|
||||
op_user_id: 'manager',
|
||||
cursor: 0,
|
||||
});
|
||||
expect(JSON.parse((global.fetch as jest.Mock).mock.calls[1][1].body)).toEqual({
|
||||
op_user_id: 'manager',
|
||||
cursor: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('DingTalkService — attendance machine only group', () => {
|
||||
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,
|
||||
});
|
||||
jest.spyOn(service as never, 'rateLimit').mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
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('accepts the success envelope returned when updating an attendance group', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
success: true,
|
||||
result: { id: 123, name: '排课_冲刺班' },
|
||||
request_id: 'request-1',
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await expect(
|
||||
service.updateAttendanceGroup({
|
||||
id: 123,
|
||||
name: '排课_冲刺班',
|
||||
type: 'TURN',
|
||||
owner: 'manager',
|
||||
members: [{ role: 'Attendance', type: 'StaffMember', user_id: 'student-1' }],
|
||||
shift_ids: [456],
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('disables mobile-oriented punching when creating a machine-only group', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
result: { id: 123 },
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await service.createAttendanceGroup({
|
||||
name: '排课_冲刺班',
|
||||
type: 'TURN',
|
||||
owner: 'manager',
|
||||
members: [{ role: 'Attendance', type: 'StaffMember', user_id: 'student-1' }],
|
||||
shift_ids: [456],
|
||||
attendance_machine_only: true,
|
||||
});
|
||||
|
||||
const body = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
|
||||
expect(body.top_group).toEqual(expect.objectContaining({
|
||||
enable_emp_select_class: false,
|
||||
disable_check_without_schedule: true,
|
||||
enable_outside_check: false,
|
||||
enable_position_ble: false,
|
||||
positions: [],
|
||||
wifis: [],
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,13 @@ export interface DingTalkGroupParams {
|
||||
enable_emp_select_class?: boolean;
|
||||
disable_check_without_schedule?: boolean;
|
||||
disable_check_when_rest?: boolean;
|
||||
/** 关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,仅保留考勤机打卡入口 */
|
||||
attendance_machine_only?: boolean;
|
||||
}
|
||||
|
||||
/** 修改考勤组参数 */
|
||||
export interface DingTalkGroupUpdateParams extends DingTalkGroupParams {
|
||||
id: number;
|
||||
}
|
||||
|
||||
/** 考勤组摘要(查询返回) */
|
||||
@@ -433,10 +440,10 @@ export class DingTalkService {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
userIds?: string[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}): Promise<DingTalkAttendanceResult[]> {
|
||||
if (!this.configured) throw new Error('DingTalk not configured');
|
||||
if (!params.userIds?.length) throw new Error('钉钉考勤 userIds 不能为空');
|
||||
if (params.userIds.length > 50) throw new Error('钉钉考勤单次最多查询50人');
|
||||
const token = await this.getAccessToken();
|
||||
|
||||
const dateFrom = params.startDate.includes(' ') ? params.startDate : `${params.startDate} 00:00:00`;
|
||||
@@ -446,9 +453,7 @@ export class DingTalkService {
|
||||
checkDateFrom: dateFrom,
|
||||
checkDateTo: dateTo,
|
||||
};
|
||||
if (params.userIds?.length) body.userIds = params.userIds;
|
||||
if (params.offset !== undefined) body.offset = params.offset;
|
||||
if (params.limit !== undefined) body.limit = params.limit;
|
||||
body.userIds = params.userIds;
|
||||
|
||||
const res = await fetch(
|
||||
`https://oapi.dingtalk.com/attendance/listRecord?access_token=${token}`,
|
||||
@@ -470,7 +475,9 @@ export class DingTalkService {
|
||||
};
|
||||
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
|
||||
|
||||
return (data.recordresult ?? []).map((r) => ({
|
||||
const records = data.recordresult ?? [];
|
||||
|
||||
return records.map((r) => ({
|
||||
userId: r.userId,
|
||||
userName: '',
|
||||
workDate: new Date(r.workDate).toISOString().slice(0, 10),
|
||||
@@ -538,28 +545,50 @@ export class DingTalkService {
|
||||
return data.result!.id;
|
||||
}
|
||||
|
||||
/** 查询所有班次摘要 */
|
||||
/** 查询所有班次摘要(每页最多200条) */
|
||||
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})`);
|
||||
const all: DingTalkShiftSummary[] = [];
|
||||
let cursor = 0;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
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, cursor }),
|
||||
},
|
||||
);
|
||||
const data = (await res.json()) as {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result?: {
|
||||
cursor?: number;
|
||||
has_more?: boolean;
|
||||
result?: Array<{ id: number; name: string }>;
|
||||
};
|
||||
};
|
||||
if (data.errcode !== 0) {
|
||||
throw new Error(`钉钉查询班次失败: ${data.errmsg} (code=${data.errcode})`);
|
||||
}
|
||||
|
||||
const page = data.result;
|
||||
all.push(...(page?.result ?? []).map((s) => ({ id: s.id, name: s.name })));
|
||||
hasMore = page?.has_more ?? false;
|
||||
if (hasMore) {
|
||||
if (page?.cursor === undefined || page.cursor === cursor) {
|
||||
throw new Error('钉钉查询班次失败: 分页游标无效');
|
||||
}
|
||||
cursor = page.cursor;
|
||||
}
|
||||
}
|
||||
return (data.result ?? []).map((s) => ({ id: s.id, name: s.name }));
|
||||
|
||||
return all;
|
||||
}
|
||||
|
||||
|
||||
@@ -572,22 +601,7 @@ export class DingTalkService {
|
||||
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 topGroup = this.buildAttendanceGroupBody(params);
|
||||
|
||||
const body = { op_user_id: params.owner, top_group: topGroup };
|
||||
|
||||
@@ -611,6 +625,66 @@ export class DingTalkService {
|
||||
return data.result!.id;
|
||||
}
|
||||
|
||||
/** 更新排班制考勤组,确保复用考勤组时同步最新打卡限制 */
|
||||
async updateAttendanceGroup(params: DingTalkGroupUpdateParams): Promise<void> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
const token = await this.getAccessToken();
|
||||
const topGroup = { ...this.buildAttendanceGroupBody(params), id: params.id };
|
||||
|
||||
await this.rateLimit();
|
||||
const res = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/attendance/group/modify?access_token=${token}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ op_user_id: params.owner, top_group: topGroup }),
|
||||
},
|
||||
);
|
||||
const data = (await res.json()) as {
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
};
|
||||
const succeeded = data.success === true || data.errcode === 0;
|
||||
if (!succeeded) {
|
||||
throw new Error(
|
||||
`钉钉更新考勤组失败: ${data.message || data.errmsg || '未知错误'} ` +
|
||||
`(code=${data.errcode ?? 'unknown'})`,
|
||||
);
|
||||
}
|
||||
this.logger.log(`钉钉考勤组更新成功: ${params.name} (id=${params.id})`);
|
||||
}
|
||||
|
||||
private buildAttendanceGroupBody(params: DingTalkGroupParams): Record<string, unknown> {
|
||||
const machineOnly = params.attendance_machine_only ?? false;
|
||||
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: machineOnly ? false : (params.enable_emp_select_class ?? true),
|
||||
disable_check_without_schedule: machineOnly ? true : (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 }));
|
||||
}
|
||||
if (machineOnly) {
|
||||
Object.assign(topGroup, {
|
||||
enable_outside_check: false,
|
||||
enable_position_ble: false,
|
||||
positions: [],
|
||||
wifis: [],
|
||||
});
|
||||
}
|
||||
return topGroup;
|
||||
}
|
||||
|
||||
/** 查询所有考勤组摘要(分页,每页10条) */
|
||||
async queryAttendanceGroups(opUserId = 'manager'): Promise<DingTalkGroupSummary[]> {
|
||||
if (!this.configured) throw new ServiceUnavailableException('钉钉未配置');
|
||||
|
||||
Reference in New Issue
Block a user