fix permissions and teacher attendance workflows

This commit is contained in:
2026-07-10 20:40:52 +08:00
parent 247879f276
commit 8ed1682b90
95 changed files with 4745 additions and 1237 deletions

View 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: [],
}));
});
});

View File

@@ -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('钉钉未配置');