feat: show DingTalk punch device details

This commit is contained in:
2026-07-14 11:20:27 +08:00
parent d572e984d2
commit 811e7ce826
12 changed files with 377 additions and 13 deletions

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
canPullAttendance,
getAttendanceExperience,
getPunchDisplayInfo,
getSchedulePhase,
summarizeAttendance,
summarizeLessonCheckins,
@@ -65,3 +66,34 @@ describe('lesson check-in summary', () => {
).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 });
});
});
describe('lesson punch device display', () => {
it('labels attendance machine punches with the machine name and id', () => {
expect(
getPunchDisplayInfo({
status: 'present',
source: 'dingtalk',
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
punchTime: '2026-07-11T00:55:00.000Z',
}),
).toEqual({
label: '考勤机打卡',
machine: true,
detail: '东门考勤机ATM-01',
time: '2026-07-11T00:55:00.000Z',
});
});
it('distinguishes mobile punches and manual teacher markings', () => {
expect(
getPunchDisplayInfo({ status: 'present', source: 'dingtalk', punchSource: 'USER' }),
).toEqual({ label: '手机打卡', machine: false, detail: undefined, time: undefined });
expect(getPunchDisplayInfo({ status: 'present', source: 'manual' })).toEqual({
label: '老师手动标记',
machine: false,
});
});
});

View File

@@ -82,3 +82,56 @@ export function summarizeLessonCheckins(
notCheckedIn: records.length - checkedIn,
};
}
export interface PunchDisplayRecord {
status: string;
source?: string;
punchTime?: string | null;
punchSource?: string | null;
punchDeviceName?: string | null;
punchDeviceId?: string | null;
}
export interface PunchDisplayInfo {
label: string;
machine: boolean;
detail?: string;
time?: string;
}
export function getPunchDisplayInfo(record: PunchDisplayRecord): PunchDisplayInfo | null {
if (record.status !== 'present' && record.status !== 'late') return null;
if (record.source === 'manual') return { label: '老师手动标记', machine: false };
const source = (record.punchSource || '').trim().toUpperCase();
const machine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some(
(value) => source === value || source.includes(value),
);
const label = machine
? '考勤机打卡'
: source === 'USER'
? '手机打卡'
: source.includes('BEACON') || source.includes('BLE')
? '蓝牙打卡'
: source.includes('WIFI')
? 'Wi-Fi 打卡'
: source.includes('APPROVE')
? '审批补卡'
: source
? `其他打卡(${record.punchSource}`
: '打卡来源未知';
const device = record.punchDeviceName?.trim();
const deviceId = record.punchDeviceId?.trim();
const detail = device
? deviceId && deviceId !== device
? `${device}${deviceId}`
: device
: deviceId || undefined;
return {
label,
machine,
detail,
time: record.punchTime || undefined,
};
}

View File

@@ -502,3 +502,25 @@
.attendance-marking-actions .ant-btn {
min-width: 54px;
}
.punch-device-cell {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.punch-device-cell .ant-tag {
margin-inline-end: 0;
}
.punch-device-cell strong {
color: #1f2937;
font-size: 13px;
}
.punch-device-cell span {
color: #8c8c8c;
font-size: 12px;
}

View File

@@ -41,6 +41,7 @@ import { message } from '../../ui/app-message';
import {
canPullAttendance,
getAttendanceExperience,
getPunchDisplayInfo,
getSchedulePhase,
summarizeLessonCheckins,
type AttendanceSummary,
@@ -96,6 +97,10 @@ interface AttendanceRecordItem {
class: { id: number; name: string } | null;
scheduleId?: number | null;
attendanceSessionId?: number | null;
punchTime?: string | null;
punchSource?: string | null;
punchDeviceName?: string | null;
punchDeviceId?: string | null;
}
interface AssignedClass {
@@ -288,6 +293,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
`/attendance-lessons/schedules/${schedule.id}/pull`,
{ date: today },
);
setSelectedSchedule(data.schedule);
setLessonSession(data.session);
setLessonRecords(data.records);
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
@@ -386,7 +392,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
)}
</Spin>
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={820} title={null} className="attendance-drawer">
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
<div className="lesson-record-header">
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
@@ -438,6 +444,21 @@ const TeacherAttendanceWorkspace: React.FC = () => {
},
},
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
{
title: '打卡设备',
width: 220,
render: (_: unknown, record: AttendanceRecordItem) => {
const info = getPunchDisplayInfo(record);
if (!info) return <span className="muted-text"></span>;
return (
<div className="punch-device-cell">
<Tag color={info.machine ? 'green' : 'blue'}>{info.label}</Tag>
{info.detail && <strong>{info.detail}</strong>}
{info.time && <span>{dayjs(info.time).format('HH:mm:ss')}</span>}
</div>
);
},
},
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text"></span> },
]}
/>

View File

@@ -93,6 +93,31 @@ describe('AttendanceImportService', () => {
expect(entity.userName).toBe('张三');
});
it('stores DingTalk punch source and attendance machine metadata', async () => {
const entity = await (service as any).mapToEntity({
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-1',
checkType: 'OnDuty',
sourceType: 'ATM',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
});
expect(entity).toEqual(
expect.objectContaining({
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
}),
);
});
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{
@@ -138,9 +163,19 @@ describe('AttendanceImportService', () => {
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-1',
checkType: 'OnDuty',
sourceType: 'ATM',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
},
]);
dingRawRepo.find.mockResolvedValue([{ dingId: 'check-1' }]);
dingRawRepo.find.mockResolvedValue([{
dingId: 'check-1',
punchSource: null,
punchDeviceName: null,
punchDeviceId: null,
rawData: '',
}]);
dingRawRepo.save.mockImplementation(async (entities) => entities);
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
const result = await service.importFromDingTalk({
@@ -150,10 +185,57 @@ describe('AttendanceImportService', () => {
autoMatch: true,
});
expect(dingRawRepo.save).toHaveBeenCalledWith(
[expect.objectContaining({
dingId: 'check-1',
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
})],
{ chunk: 50 },
);
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
expect(result.matched).toBe(1);
});
it('preserves existing device metadata when a duplicate response omits it', async () => {
const existing = {
dingId: 'check-keep-device',
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
rawData: '{}',
};
dingTalkService.fetchAttendanceResults.mockResolvedValue([{
userId: 'ding-1',
userName: '张三',
workDate: '2026-07-01',
timeResult: 'Normal',
locationResult: '',
planCheckTime: '',
actualCheckTime: '2026-07-01T08:00:00.000Z',
checkId: 'check-keep-device',
checkType: 'OnDuty',
sourceType: '',
}]);
dingRawRepo.find.mockResolvedValue([existing]);
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 0, total: 1 });
await service.importFromDingTalk({
startDate: '2026-07-01',
endDate: '2026-07-01',
userIds: ['ding-1'],
autoMatch: true,
});
expect(existing).toEqual(expect.objectContaining({
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
}));
expect(dingRawRepo.save).not.toHaveBeenCalled();
});
it('scopes SSE progress events to the importing user', async () => {
dingTalkService.fetchAttendanceResults.mockResolvedValue([
{

View File

@@ -104,8 +104,10 @@ export class AttendanceImportService {
// Stage 2: Parse & deduplicate
this.emit('parsing', 0, total, `Parsing ${total} records...`);
const existingDingIds = await this.getExistingDingIds(rawResults);
const newRecords = rawResults.filter((r) => !existingDingIds.has(r.checkId));
const existingByDingId = await this.getExistingRecordsByDingId(rawResults);
const newRecords = rawResults.filter((r) => !existingByDingId.has(r.checkId));
const duplicateRecords = rawResults.filter((r) => existingByDingId.has(r.checkId));
await this.refreshDuplicatePunchMetadata(duplicateRecords, existingByDingId);
skipped = rawResults.length - newRecords.length;
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
@@ -246,17 +248,45 @@ export class AttendanceImportService {
/**
* Query which dingIds already exist to skip duplicates.
*/
private async getExistingDingIds(
private async getExistingRecordsByDingId(
results: DingTalkAttendanceResult[],
): Promise<Set<string>> {
): Promise<Map<string, DingAttendanceRaw>> {
const dingIds = results.map((r) => r.checkId).filter(Boolean);
if (dingIds.length === 0) return new Set();
if (dingIds.length === 0) return new Map();
const existing = await this.dingRawRepo.find({
where: { dingId: In(dingIds) },
select: ['dingId'],
});
return new Set(existing.map((e) => e.dingId));
return new Map(existing.map((entity) => [entity.dingId, entity]));
}
private async refreshDuplicatePunchMetadata(
results: DingTalkAttendanceResult[],
existingByDingId: Map<string, DingAttendanceRaw>,
): Promise<void> {
const changed: DingAttendanceRaw[] = [];
for (const result of results) {
const entity = existingByDingId.get(result.checkId);
if (!entity) continue;
const punchSource = result.sourceType || entity.punchSource || null;
const punchDeviceName = result.deviceName || entity.punchDeviceName || null;
const punchDeviceId = result.deviceId || entity.punchDeviceId || null;
if (
entity.punchSource === punchSource &&
entity.punchDeviceName === punchDeviceName &&
entity.punchDeviceId === punchDeviceId
) {
continue;
}
entity.punchSource = punchSource;
entity.punchDeviceName = punchDeviceName;
entity.punchDeviceId = punchDeviceId;
entity.rawData = JSON.stringify(result);
changed.push(entity);
}
if (changed.length > 0) {
await this.dingRawRepo.save(changed, { chunk: 50 });
}
}
/**
@@ -271,6 +301,9 @@ export class AttendanceImportService {
entity.attendanceType = r.checkType || 'OnDuty';
entity.timeResult = r.timeResult;
entity.locationResult = r.locationResult || '';
entity.punchSource = r.sourceType || null;
entity.punchDeviceName = r.deviceName || null;
entity.punchDeviceId = r.deviceId || null;
// Parse check-in/out times
if (r.actualCheckTime) {

View File

@@ -79,6 +79,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
},
{
matchedStudentId: 2,
@@ -100,7 +103,15 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
}),
);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present', source: 'dingtalk' }),
expect.objectContaining({
studentId: 1,
status: 'present',
source: 'dingtalk',
punchSource: 'ATM',
punchDeviceName: '东门考勤机',
punchDeviceId: 'ATM-01',
punchTime: new Date('2026-07-11T08:55:00+08:00'),
}),
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),

View File

@@ -200,6 +200,53 @@ export class AttendanceService {
if (hasPunch) return 'present';
return finalize ? 'absent' : 'pending';
}
private getLessonPunchMetadata(
records: DingAttendanceRaw[],
lessonDate: string,
startTime: string,
): Pick<AttendanceRecord, 'punchTime' | 'punchSource' | 'punchDeviceName' | 'punchDeviceId'> {
const punches = records
.map((record) => ({ record, time: record.checkInTime ?? record.checkOutTime }))
.filter((item): item is { record: DingAttendanceRaw; time: Date } => !!item.time);
if (punches.length === 0) {
return {
punchTime: null,
punchSource: null,
punchDeviceName: null,
punchDeviceId: null,
};
}
const lessonStart = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime();
punches.sort(
(left, right) =>
Math.abs(left.time.getTime() - lessonStart) - Math.abs(right.time.getTime() - lessonStart),
);
const primary = punches[0];
const metadataRecord = [...punches]
.filter(({ record }) =>
!!(record.punchSource || record.punchDeviceName || record.punchDeviceId) ||
!['OnDuty', 'OffDuty'].includes(record.attendanceType),
)
.sort(
(left, right) =>
Math.abs(left.time.getTime() - primary.time.getTime()) -
Math.abs(right.time.getTime() - primary.time.getTime()),
)[0]?.record;
const source =
metadataRecord?.punchSource ||
(metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType)
? metadataRecord.attendanceType
: primary.record.punchSource);
return {
punchTime: primary.time,
punchSource: source || null,
punchDeviceName: metadataRecord?.punchDeviceName || primary.record.punchDeviceName || null,
punchDeviceId: metadataRecord?.punchDeviceId || primary.record.punchDeviceId || null,
};
}
async createLessonAttendanceFromDingTalk(
scheduleId: number,
lessonDate: string,
@@ -270,6 +317,11 @@ export class AttendanceService {
schedule.endTime,
);
record.status = this.mapDingTalkStatus(raw, finalize);
Object.assign(record, this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
));
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
? null
: finalize
@@ -296,6 +348,11 @@ export class AttendanceService {
session: this.mapScheduleTimeToSession(schedule.startTime),
status: this.mapDingTalkStatus(raw, finalize),
source: 'dingtalk',
...this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: finalize
@@ -378,6 +435,11 @@ export class AttendanceService {
session: this.mapScheduleTimeToSession(schedule.startTime),
status: this.mapDingTalkStatus(raw, finalize),
source: 'dingtalk',
...this.getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: finalize
@@ -908,6 +970,10 @@ export class AttendanceService {
if (dto.status !== undefined) {
record.status = dto.status;
record.source = 'manual';
record.punchTime = null;
record.punchSource = null;
record.punchDeviceName = null;
record.punchDeviceId = null;
}
if (dto.remark !== undefined) {
record.remark = dto.remark;
@@ -933,6 +999,10 @@ export class AttendanceService {
if (dto.status !== undefined) {
freshRecord.status = dto.status;
freshRecord.source = 'manual';
freshRecord.punchTime = null;
freshRecord.punchSource = null;
freshRecord.punchDeviceName = null;
freshRecord.punchDeviceId = null;
}
if (dto.remark !== undefined) {
freshRecord.remark = dto.remark;

View File

@@ -68,8 +68,10 @@ describe('DingTalkService — attendance records', () => {
userId: 'ding-1',
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
sourceType: 'USER',
sourceType: 'ATM',
checkType: 'OnDuty',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
timeResult: 'Normal',
},
],
@@ -83,6 +85,13 @@ describe('DingTalkService — attendance records', () => {
});
expect(record.workDate).toBe('2026-07-12');
expect(record).toEqual(
expect.objectContaining({
checkType: 'OnDuty',
sourceType: 'ATM',
deviceName: '东门考勤机',
deviceId: 'ATM-01',
}),
);
});
});

View File

@@ -67,6 +67,18 @@ export class AttendanceRecord {
@Column({ name: 'source', length: 20, default: 'manual' })
source: string;
@Column({ name: 'punch_time', type: 'datetime', nullable: true })
punchTime: Date | null;
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
punchSource: string | null;
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
punchDeviceName: string | null;
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
punchDeviceId: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;

View File

@@ -44,6 +44,15 @@ export class DingAttendanceRaw {
@Column({ name: 'location_result', length: 20, nullable: true })
locationResult: string;
@Column({ name: 'punch_source', type: 'varchar', length: 40, nullable: true })
punchSource: string | null;
@Column({ name: 'punch_device_name', type: 'varchar', length: 100, nullable: true })
punchDeviceName: string | null;
@Column({ name: 'punch_device_id', type: 'varchar', length: 100, nullable: true })
punchDeviceId: string | null;
@Column({ name: 'match_status', length: 20, default: 'unmatched' })
matchStatus: string;

View File

@@ -51,6 +51,11 @@ export interface DingTalkAttendanceResult {
actualCheckTime: string;
checkId: string;
checkType: string;
/** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */
sourceType: string;
/** 部分钉钉租户会额外返回考勤机名称或编号。 */
deviceName?: string;
deviceId?: string;
}
// ── 组织架构 API 类型 ──
@@ -505,6 +510,8 @@ export class DingTalkService {
checkType?: string; timeResult?: string;
locationResult?: string; locationMethod?: string;
userAddress?: string; userLongitude?: number; userLatitude?: number;
deviceName?: string; deviceId?: string | number;
attendanceMachineName?: string; attendanceMachineId?: string | number;
}>;
};
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
@@ -520,7 +527,10 @@ export class DingTalkService {
planCheckTime: '',
actualCheckTime: new Date(r.userCheckTime).toISOString(),
checkId: String(r.id),
checkType: r.checkType ?? r.sourceType ?? '',
checkType: r.checkType ?? '',
sourceType: r.sourceType ?? '',
deviceName: r.deviceName ?? r.attendanceMachineName,
deviceId: String(r.deviceId ?? r.attendanceMachineId ?? '') || undefined,
}));
}