forked from wangziqi/gongxue-base
feat: show DingTalk punch device details
This commit is contained in:
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
canPullAttendance,
|
canPullAttendance,
|
||||||
getAttendanceExperience,
|
getAttendanceExperience,
|
||||||
|
getPunchDisplayInfo,
|
||||||
getSchedulePhase,
|
getSchedulePhase,
|
||||||
summarizeAttendance,
|
summarizeAttendance,
|
||||||
summarizeLessonCheckins,
|
summarizeLessonCheckins,
|
||||||
@@ -65,3 +66,34 @@ describe('lesson check-in summary', () => {
|
|||||||
).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 });
|
).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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -82,3 +82,56 @@ export function summarizeLessonCheckins(
|
|||||||
notCheckedIn: records.length - checkedIn,
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -502,3 +502,25 @@
|
|||||||
.attendance-marking-actions .ant-btn {
|
.attendance-marking-actions .ant-btn {
|
||||||
min-width: 54px;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { message } from '../../ui/app-message';
|
|||||||
import {
|
import {
|
||||||
canPullAttendance,
|
canPullAttendance,
|
||||||
getAttendanceExperience,
|
getAttendanceExperience,
|
||||||
|
getPunchDisplayInfo,
|
||||||
getSchedulePhase,
|
getSchedulePhase,
|
||||||
summarizeLessonCheckins,
|
summarizeLessonCheckins,
|
||||||
type AttendanceSummary,
|
type AttendanceSummary,
|
||||||
@@ -96,6 +97,10 @@ interface AttendanceRecordItem {
|
|||||||
class: { id: number; name: string } | null;
|
class: { id: number; name: string } | null;
|
||||||
scheduleId?: number | null;
|
scheduleId?: number | null;
|
||||||
attendanceSessionId?: number | null;
|
attendanceSessionId?: number | null;
|
||||||
|
punchTime?: string | null;
|
||||||
|
punchSource?: string | null;
|
||||||
|
punchDeviceName?: string | null;
|
||||||
|
punchDeviceId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AssignedClass {
|
interface AssignedClass {
|
||||||
@@ -288,6 +293,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
`/attendance-lessons/schedules/${schedule.id}/pull`,
|
`/attendance-lessons/schedules/${schedule.id}/pull`,
|
||||||
{ date: today },
|
{ date: today },
|
||||||
);
|
);
|
||||||
|
setSelectedSchedule(data.schedule);
|
||||||
setLessonSession(data.session);
|
setLessonSession(data.session);
|
||||||
setLessonRecords(data.records);
|
setLessonRecords(data.records);
|
||||||
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
|
||||||
@@ -386,7 +392,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</Spin>
|
</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">
|
<div className="lesson-record-header">
|
||||||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||||||
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
<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: '当前状态', 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> },
|
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">—</span> },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -93,6 +93,31 @@ describe('AttendanceImportService', () => {
|
|||||||
expect(entity.userName).toBe('张三');
|
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 () => {
|
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
|
||||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||||
{
|
{
|
||||||
@@ -138,9 +163,19 @@ describe('AttendanceImportService', () => {
|
|||||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||||
checkId: 'check-1',
|
checkId: 'check-1',
|
||||||
checkType: 'OnDuty',
|
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 });
|
attendanceService.autoMatchDingRecords.mockResolvedValue({ matched: 1, total: 1 });
|
||||||
|
|
||||||
const result = await service.importFromDingTalk({
|
const result = await service.importFromDingTalk({
|
||||||
@@ -150,10 +185,57 @@ describe('AttendanceImportService', () => {
|
|||||||
autoMatch: true,
|
autoMatch: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
expect(dingRawRepo.save).toHaveBeenCalledWith(
|
||||||
|
[expect.objectContaining({
|
||||||
|
dingId: 'check-1',
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
|
})],
|
||||||
|
{ chunk: 50 },
|
||||||
|
);
|
||||||
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
|
expect(attendanceService.autoMatchDingRecords).toHaveBeenCalled();
|
||||||
expect(result.matched).toBe(1);
|
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 () => {
|
it('scopes SSE progress events to the importing user', async () => {
|
||||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -104,8 +104,10 @@ export class AttendanceImportService {
|
|||||||
|
|
||||||
// Stage 2: Parse & deduplicate
|
// Stage 2: Parse & deduplicate
|
||||||
this.emit('parsing', 0, total, `Parsing ${total} records...`);
|
this.emit('parsing', 0, total, `Parsing ${total} records...`);
|
||||||
const existingDingIds = await this.getExistingDingIds(rawResults);
|
const existingByDingId = await this.getExistingRecordsByDingId(rawResults);
|
||||||
const newRecords = rawResults.filter((r) => !existingDingIds.has(r.checkId));
|
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;
|
skipped = rawResults.length - newRecords.length;
|
||||||
this.emit('parsing', newRecords.length, total, `${newRecords.length} new records, ${skipped} duplicates skipped`);
|
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.
|
* Query which dingIds already exist to skip duplicates.
|
||||||
*/
|
*/
|
||||||
private async getExistingDingIds(
|
private async getExistingRecordsByDingId(
|
||||||
results: DingTalkAttendanceResult[],
|
results: DingTalkAttendanceResult[],
|
||||||
): Promise<Set<string>> {
|
): Promise<Map<string, DingAttendanceRaw>> {
|
||||||
const dingIds = results.map((r) => r.checkId).filter(Boolean);
|
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({
|
const existing = await this.dingRawRepo.find({
|
||||||
where: { dingId: In(dingIds) },
|
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.attendanceType = r.checkType || 'OnDuty';
|
||||||
entity.timeResult = r.timeResult;
|
entity.timeResult = r.timeResult;
|
||||||
entity.locationResult = r.locationResult || '';
|
entity.locationResult = r.locationResult || '';
|
||||||
|
entity.punchSource = r.sourceType || null;
|
||||||
|
entity.punchDeviceName = r.deviceName || null;
|
||||||
|
entity.punchDeviceId = r.deviceId || null;
|
||||||
|
|
||||||
// Parse check-in/out times
|
// Parse check-in/out times
|
||||||
if (r.actualCheckTime) {
|
if (r.actualCheckTime) {
|
||||||
|
|||||||
@@ -79,6 +79,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
attendanceType: 'OnDuty',
|
attendanceType: 'OnDuty',
|
||||||
timeResult: 'Normal',
|
timeResult: 'Normal',
|
||||||
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||||
|
punchSource: 'ATM',
|
||||||
|
punchDeviceName: '东门考勤机',
|
||||||
|
punchDeviceId: 'ATM-01',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
matchedStudentId: 2,
|
matchedStudentId: 2,
|
||||||
@@ -100,7 +103,15 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
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: 2, status: 'present', source: 'dingtalk' }),
|
||||||
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
|
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
|
||||||
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
|
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
|
||||||
|
|||||||
@@ -200,6 +200,53 @@ export class AttendanceService {
|
|||||||
if (hasPunch) return 'present';
|
if (hasPunch) return 'present';
|
||||||
return finalize ? 'absent' : 'pending';
|
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(
|
async createLessonAttendanceFromDingTalk(
|
||||||
scheduleId: number,
|
scheduleId: number,
|
||||||
lessonDate: string,
|
lessonDate: string,
|
||||||
@@ -270,6 +317,11 @@ export class AttendanceService {
|
|||||||
schedule.endTime,
|
schedule.endTime,
|
||||||
);
|
);
|
||||||
record.status = this.mapDingTalkStatus(raw, finalize);
|
record.status = this.mapDingTalkStatus(raw, finalize);
|
||||||
|
Object.assign(record, this.getLessonPunchMetadata(
|
||||||
|
raw,
|
||||||
|
lessonDate,
|
||||||
|
schedule.startTime,
|
||||||
|
));
|
||||||
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||||
? null
|
? null
|
||||||
: finalize
|
: finalize
|
||||||
@@ -296,6 +348,11 @@ export class AttendanceService {
|
|||||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||||
status: this.mapDingTalkStatus(raw, finalize),
|
status: this.mapDingTalkStatus(raw, finalize),
|
||||||
source: 'dingtalk',
|
source: 'dingtalk',
|
||||||
|
...this.getLessonPunchMetadata(
|
||||||
|
raw,
|
||||||
|
lessonDate,
|
||||||
|
schedule.startTime,
|
||||||
|
),
|
||||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||||
? undefined
|
? undefined
|
||||||
: finalize
|
: finalize
|
||||||
@@ -378,6 +435,11 @@ export class AttendanceService {
|
|||||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||||
status: this.mapDingTalkStatus(raw, finalize),
|
status: this.mapDingTalkStatus(raw, finalize),
|
||||||
source: 'dingtalk',
|
source: 'dingtalk',
|
||||||
|
...this.getLessonPunchMetadata(
|
||||||
|
raw,
|
||||||
|
lessonDate,
|
||||||
|
schedule.startTime,
|
||||||
|
),
|
||||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||||
? undefined
|
? undefined
|
||||||
: finalize
|
: finalize
|
||||||
@@ -908,6 +970,10 @@ export class AttendanceService {
|
|||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
record.status = dto.status;
|
record.status = dto.status;
|
||||||
record.source = 'manual';
|
record.source = 'manual';
|
||||||
|
record.punchTime = null;
|
||||||
|
record.punchSource = null;
|
||||||
|
record.punchDeviceName = null;
|
||||||
|
record.punchDeviceId = null;
|
||||||
}
|
}
|
||||||
if (dto.remark !== undefined) {
|
if (dto.remark !== undefined) {
|
||||||
record.remark = dto.remark;
|
record.remark = dto.remark;
|
||||||
@@ -933,6 +999,10 @@ export class AttendanceService {
|
|||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
freshRecord.status = dto.status;
|
freshRecord.status = dto.status;
|
||||||
freshRecord.source = 'manual';
|
freshRecord.source = 'manual';
|
||||||
|
freshRecord.punchTime = null;
|
||||||
|
freshRecord.punchSource = null;
|
||||||
|
freshRecord.punchDeviceName = null;
|
||||||
|
freshRecord.punchDeviceId = null;
|
||||||
}
|
}
|
||||||
if (dto.remark !== undefined) {
|
if (dto.remark !== undefined) {
|
||||||
freshRecord.remark = dto.remark;
|
freshRecord.remark = dto.remark;
|
||||||
|
|||||||
@@ -68,8 +68,10 @@ describe('DingTalkService — attendance records', () => {
|
|||||||
userId: 'ding-1',
|
userId: 'ding-1',
|
||||||
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
|
workDate: Date.parse('2026-07-12T00:00:00+08:00'),
|
||||||
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
|
userCheckTime: Date.parse('2026-07-12T21:05:00+08:00'),
|
||||||
sourceType: 'USER',
|
sourceType: 'ATM',
|
||||||
checkType: 'OnDuty',
|
checkType: 'OnDuty',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
timeResult: 'Normal',
|
timeResult: 'Normal',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -83,6 +85,13 @@ describe('DingTalkService — attendance records', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(record.workDate).toBe('2026-07-12');
|
expect(record.workDate).toBe('2026-07-12');
|
||||||
|
expect(record).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
checkType: 'OnDuty',
|
||||||
|
sourceType: 'ATM',
|
||||||
|
deviceName: '东门考勤机',
|
||||||
|
deviceId: 'ATM-01',
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,18 @@ export class AttendanceRecord {
|
|||||||
@Column({ name: 'source', length: 20, default: 'manual' })
|
@Column({ name: 'source', length: 20, default: 'manual' })
|
||||||
source: string;
|
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' })
|
@CreateDateColumn({ name: 'created_at' })
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,15 @@ export class DingAttendanceRaw {
|
|||||||
@Column({ name: 'location_result', length: 20, nullable: true })
|
@Column({ name: 'location_result', length: 20, nullable: true })
|
||||||
locationResult: string;
|
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' })
|
@Column({ name: 'match_status', length: 20, default: 'unmatched' })
|
||||||
matchStatus: string;
|
matchStatus: string;
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,11 @@ export interface DingTalkAttendanceResult {
|
|||||||
actualCheckTime: string;
|
actualCheckTime: string;
|
||||||
checkId: string;
|
checkId: string;
|
||||||
checkType: string;
|
checkType: string;
|
||||||
|
/** 钉钉返回的打卡来源,例如 ATM / USER / BEACON。 */
|
||||||
|
sourceType: string;
|
||||||
|
/** 部分钉钉租户会额外返回考勤机名称或编号。 */
|
||||||
|
deviceName?: string;
|
||||||
|
deviceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 组织架构 API 类型 ──
|
// ── 组织架构 API 类型 ──
|
||||||
@@ -505,6 +510,8 @@ export class DingTalkService {
|
|||||||
checkType?: string; timeResult?: string;
|
checkType?: string; timeResult?: string;
|
||||||
locationResult?: string; locationMethod?: string;
|
locationResult?: string; locationMethod?: string;
|
||||||
userAddress?: string; userLongitude?: number; userLatitude?: number;
|
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}`);
|
if (data.errcode !== 0) throw new Error(`钉钉考勤获取失败: ${data.errmsg}`);
|
||||||
@@ -520,7 +527,10 @@ export class DingTalkService {
|
|||||||
planCheckTime: '',
|
planCheckTime: '',
|
||||||
actualCheckTime: new Date(r.userCheckTime).toISOString(),
|
actualCheckTime: new Date(r.userCheckTime).toISOString(),
|
||||||
checkId: String(r.id),
|
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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user