feat: add attendance APIs

This commit is contained in:
richjohnrambo
2026-08-31 13:27:12 +08:00
parent 9f647de4d0
commit cb7dc36cad
10 changed files with 1749 additions and 12 deletions
+156
View File
@@ -279,6 +279,14 @@ type testDailyReportsStore struct {
remindKeys string
}
type testAttendanceStore struct {
config *types.AttendanceConfig
records []types.AttendanceRecord
checkinRequest types.AttendanceCheckinRequest
appeal *types.AttendanceAppeal
idempotencyKey string
}
type testMonthlyReportsStore struct {
report *types.MonthlyReport
saved bool
@@ -695,6 +703,78 @@ func (s *testDailyReportsStore) Remind(_ types.TenantID, _ types.Uid, _ time.Tim
return userIDs, nil
}
func (s *testAttendanceStore) Config(_ types.TenantID, uid types.Uid, date time.Time) (*types.AttendanceConfig, error) {
if s.config != nil {
return s.config, nil
}
now := time.Now().UTC().Round(time.Millisecond)
return &types.AttendanceConfig{
Date: date,
Timezone: "Asia/Shanghai",
Rule: types.AttendanceRule{
RuleID: "att_rule_default",
Name: "默认考勤规则",
Workday: true,
AllowOutsideCheckin: true,
AppealAllowed: true,
CreatedAt: now,
UpdatedAt: now,
},
Shifts: []types.AttendanceShift{{
ShiftID: "shift_default", RuleID: "att_rule_default", Name: "默认班次",
WorkStart: "09:00", WorkEnd: "18:00", CheckinStart: "08:30", CheckoutEnd: "20:00",
CreatedAt: now, UpdatedAt: now,
}},
AllowedActions: []string{types.AttendanceActionCheckIn, types.AttendanceActionCheckOut},
TodayRecord: &types.AttendanceRecord{
RecordID: "evt_record", UserID: uid, Date: date, RuleID: "att_rule_default", ShiftID: "shift_default",
Status: types.AttendanceStatusNotChecked, Version: 1, CreatedAt: now, UpdatedAt: now,
},
CanViewManaged: true,
}, nil
}
func (s *testAttendanceStore) ListRecords(_ types.TenantID, _ types.Uid, opts types.AttendanceRecordListOptions) ([]types.AttendanceRecord, string, *types.AttendanceRecordStats, error) {
return s.records, opts.Cursor, &types.AttendanceRecordStats{ExpectedCount: 1, CheckedCount: len(s.records)}, nil
}
func (s *testAttendanceStore) Checkin(_ types.TenantID, uid types.Uid, checkinID string, req types.AttendanceCheckinRequest) (*types.AttendanceRecord, *types.AttendanceCheckin, error) {
s.checkinRequest = req
now := time.Now().UTC().Round(time.Millisecond)
record := &types.AttendanceRecord{
RecordID: "evt_record", UserID: uid, Date: req.Date, RuleID: "att_rule_default", ShiftID: "shift_default",
Status: types.AttendanceStatusCheckedIn, Version: 2, CreatedAt: now, UpdatedAt: now,
}
checkin := &types.AttendanceCheckin{
CheckinID: checkinID, RecordID: record.RecordID, UserID: uid, Date: req.Date, Action: req.Action,
RuleID: record.RuleID, ShiftID: record.ShiftID, ClientTime: req.ClientTime, ServerTime: now,
Result: types.AttendanceCheckinNormal, Location: req.Location, Wifi: req.Wifi, Device: req.Device,
IdempotencyKey: req.IdempotencyKey, CreatedAt: now,
}
record.Checkins = []types.AttendanceCheckin{*checkin}
return record, checkin, nil
}
func (s *testAttendanceStore) CreateAppeal(_ types.TenantID, uid types.Uid, appeal *types.AttendanceAppeal, idempotencyKey string) (*types.AttendanceAppeal, error) {
s.appeal = appeal
s.idempotencyKey = idempotencyKey
now := time.Now().UTC().Round(time.Millisecond)
appeal.UserID = uid
appeal.Status = types.AttendanceAppealPending
appeal.Version = 1
appeal.SubmittedAt = now
appeal.CreatedAt = now
appeal.UpdatedAt = now
return appeal, nil
}
func (s *testAttendanceStore) ListAppeals(_ types.TenantID, _ types.Uid, opts types.AttendanceAppealListOptions) ([]types.AttendanceAppeal, string, error) {
if s.appeal == nil {
return nil, opts.Cursor, nil
}
return []types.AttendanceAppeal{*s.appeal}, opts.Cursor, nil
}
func (s *testMonthlyReportsStore) List(_ types.TenantID, _ types.Uid, _, _, _, _, _ string, _ int) ([]types.MonthlyReport, string, error) {
if s.report == nil {
return nil, "", nil
@@ -2363,6 +2443,82 @@ func TestServeIMOrganizationSearch(t *testing.T) {
}
}
func TestServeIMAttendanceConfigAndRecords(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldAttendance := store.Attendance
now := time.Now().UTC().Round(time.Millisecond)
attendance := &testAttendanceStore{records: []types.AttendanceRecord{{
RecordID: "evt_record", UserID: 42, Date: time.Date(2026, 8, 31, 0, 0, 0, 0, time.UTC),
RuleID: "att_rule_default", ShiftID: "shift_default", Status: types.AttendanceStatusCheckedIn,
Version: 2, CreatedAt: now, UpdatedAt: now,
}}}
store.Attendance = attendance
defer func() { store.Attendance = oldAttendance }()
req := httptest.NewRequest(http.MethodGet, "/v1/im/attendance/config?date=2026-08-31", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec := httptest.NewRecorder()
serveIMAttendanceConfig(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("config status=%d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if data["date"] != "2026-08-31" || data["timezone"] != "Asia/Shanghai" {
t.Fatalf("unexpected config: %#v", data)
}
req = httptest.NewRequest(http.MethodGet, "/v1/im/attendance/records?scope=mine&from=2026-08-01&to=2026-08-31", nil)
req.Header.Set("Authorization", "Bearer token-1")
rec = httptest.NewRecorder()
serveIMAttendanceRecords(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("records status=%d body=%s", rec.Code, rec.Body.String())
}
data = decodeIMHTTPResponse(t, rec).Data.(map[string]any)
items := data["items"].([]any)
if len(items) != 1 || items[0].(map[string]any)["record_id"] != "evt_record" {
t.Fatalf("unexpected records: %#v", data)
}
}
func TestServeIMAttendanceCheckinAndAppeal(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
expectIMBearer(t, ctrl, "token-1", 7, 42)
oldAttendance := store.Attendance
attendance := &testAttendanceStore{}
store.Attendance = attendance
defer func() { store.Attendance = oldAttendance }()
req := httptest.NewRequest(http.MethodPost, "/v1/im/attendance/checkins", strings.NewReader(`{"date":"2026-08-31","action":"check_in","client_time":"2026-08-31T01:00:00Z","location":{"name":"总部"}}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "att-1")
rec := httptest.NewRecorder()
serveIMAttendanceCheckins(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("checkin status=%d body=%s", rec.Code, rec.Body.String())
}
data := decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if attendance.checkinRequest.Action != types.AttendanceActionCheckIn || data["record"] == nil || data["checkin"] == nil {
t.Fatalf("unexpected checkin: data=%#v req=%#v", data, attendance.checkinRequest)
}
req = httptest.NewRequest(http.MethodPost, "/v1/im/attendance/appeals", strings.NewReader(`{"record_id":"evt_record","appeal_type":"late","reason":"地铁延误","target_action":"check_in","target_time":"2026-08-31T00:58:00Z"}`))
req.Header.Set("Authorization", "Bearer token-1")
req.Header.Set("Idempotency-Key", "appeal-1")
rec = httptest.NewRecorder()
serveIMAttendanceAppeals(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("appeal status=%d body=%s", rec.Code, rec.Body.String())
}
data = decodeIMHTTPResponse(t, rec).Data.(map[string]any)
if attendance.idempotencyKey != "appeal-1" || data["status"] != types.AttendanceAppealPending {
t.Fatalf("unexpected appeal: data=%#v key=%q", data, attendance.idempotencyKey)
}
}
func TestServeIMMeetingsCreate(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()