feat: add attendance APIs
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user