feat: add attendance APIs
This commit is contained in:
+368
-1
@@ -367,6 +367,26 @@ type imFeedbackRequest struct {
|
||||
IncludeDiagnostics bool `json:"include_diagnostics,omitempty"`
|
||||
}
|
||||
|
||||
type imAttendanceCheckinRequest struct {
|
||||
Date string `json:"date"`
|
||||
Action string `json:"action"`
|
||||
RuleID string `json:"rule_id,omitempty"`
|
||||
ShiftID string `json:"shift_id,omitempty"`
|
||||
ClientTime string `json:"client_time"`
|
||||
Location types.KVMap `json:"location,omitempty"`
|
||||
Wifi types.KVMap `json:"wifi,omitempty"`
|
||||
Device types.KVMap `json:"device,omitempty"`
|
||||
}
|
||||
|
||||
type imAttendanceAppealRequest struct {
|
||||
RecordID string `json:"record_id"`
|
||||
AppealType string `json:"appeal_type"`
|
||||
Reason string `json:"reason"`
|
||||
TargetAction string `json:"target_action,omitempty"`
|
||||
TargetTime string `json:"target_time,omitempty"`
|
||||
AttachmentFileIDs []string `json:"attachment_file_ids,omitempty"`
|
||||
}
|
||||
|
||||
type imSystemNotificationReadRequest struct {
|
||||
ReadAt string `json:"read_at,omitempty"`
|
||||
}
|
||||
@@ -1661,6 +1681,7 @@ func serveIMBootstrap(wrt http.ResponseWriter, req *http.Request) {
|
||||
"user_search": true,
|
||||
"scan_qr": true,
|
||||
"system_notifications": true,
|
||||
"attendance": true,
|
||||
"message_recall": true,
|
||||
"message_edit": true,
|
||||
},
|
||||
@@ -1724,6 +1745,7 @@ func serveIMWorkbench(wrt http.ResponseWriter, req *http.Request) {
|
||||
"tenant_code": tenant.Code,
|
||||
"feature_flags": map[string]any{
|
||||
"organization": true,
|
||||
"attendance": true,
|
||||
"meetings": true,
|
||||
"daily_reports": true,
|
||||
"monthly_reports": true,
|
||||
@@ -1842,6 +1864,192 @@ func serveIMOrganizationSearch(wrt http.ResponseWriter, req *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
func serveIMAttendanceConfig(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
date, err := parseIMOptionalDate(req.URL.Query().Get("date"), time.Now().UTC())
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
|
||||
return
|
||||
}
|
||||
config, err := store.Attendance.Config(principal.TenantID, principal.Uid, date)
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, attendanceConfigPayload(config))
|
||||
}
|
||||
|
||||
func serveIMAttendanceRecords(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodGet {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !validIMOptionalDateQuery(wrt, req, "date") || !validIMOptionalDateQuery(wrt, req, "from") || !validIMOptionalDateQuery(wrt, req, "to") {
|
||||
return
|
||||
}
|
||||
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
||||
records, nextCursor, stats, err := store.Attendance.ListRecords(principal.TenantID, principal.Uid, types.AttendanceRecordListOptions{
|
||||
Scope: req.URL.Query().Get("scope"),
|
||||
From: strings.TrimSpace(req.URL.Query().Get("from")),
|
||||
To: strings.TrimSpace(req.URL.Query().Get("to")),
|
||||
Date: strings.TrimSpace(req.URL.Query().Get("date")),
|
||||
Status: strings.TrimSpace(req.URL.Query().Get("status")),
|
||||
DepartmentID: strings.TrimSpace(req.URL.Query().Get("department_id")),
|
||||
UserID: strings.TrimSpace(req.URL.Query().Get("user_id")),
|
||||
Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")),
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{
|
||||
"items": attendanceRecordPayloads(records),
|
||||
"next_cursor": nextCursor,
|
||||
"has_more": nextCursor != "",
|
||||
"statistics": attendanceStatsPayload(stats),
|
||||
"permissions": map[string]any{
|
||||
"can_checkin": true,
|
||||
"can_view_managed": true,
|
||||
"can_appeal": true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func serveIMAttendanceCheckins(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method != http.MethodPost {
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
return
|
||||
}
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body imAttendanceCheckinRequest
|
||||
if !decodeIMJSONBody(wrt, req, &body) {
|
||||
return
|
||||
}
|
||||
date, err := parseIMDate(body.Date)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "date"})
|
||||
return
|
||||
}
|
||||
clientTime, err := parseIMTime(body.ClientTime)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "client_time"})
|
||||
return
|
||||
}
|
||||
token, err := randomIMToken(12)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
record, checkin, err := store.Attendance.Checkin(principal.TenantID, principal.Uid, "attc_"+token, types.AttendanceCheckinRequest{
|
||||
Date: date,
|
||||
Action: strings.TrimSpace(body.Action),
|
||||
RuleID: strings.TrimSpace(body.RuleID),
|
||||
ShiftID: strings.TrimSpace(body.ShiftID),
|
||||
ClientTime: clientTime,
|
||||
Location: body.Location,
|
||||
Wifi: body.Wifi,
|
||||
Device: body.Device,
|
||||
IdempotencyKey: idempotencyKey,
|
||||
})
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "attendance", "attendance.record.updated", record.RecordID,
|
||||
[]types.Uid{principal.Uid}, "打卡", record.Date.Format("2006-01-02"), map[string]any{"record_id": record.RecordID, "checkin_id": checkin.CheckinID, "status": record.Status})
|
||||
writeIMSuccess(wrt, req, map[string]any{
|
||||
"record": attendanceRecordPayload(record),
|
||||
"checkin": attendanceCheckinPayload(*checkin),
|
||||
})
|
||||
}
|
||||
|
||||
func serveIMAttendanceAppeals(wrt http.ResponseWriter, req *http.Request) {
|
||||
principal, ok := authenticateIMHTTPBearer(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
switch req.Method {
|
||||
case http.MethodGet:
|
||||
limit := parseIMLimit(req.URL.Query().Get("limit"), 50, 100)
|
||||
items, nextCursor, err := store.Attendance.ListAppeals(principal.TenantID, principal.Uid, types.AttendanceAppealListOptions{
|
||||
Scope: req.URL.Query().Get("scope"),
|
||||
Status: strings.TrimSpace(req.URL.Query().Get("status")),
|
||||
Cursor: strings.TrimSpace(req.URL.Query().Get("cursor")),
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
writeIMSuccess(wrt, req, map[string]any{"items": attendanceAppealPayloads(items), "next_cursor": nextCursor, "has_more": nextCursor != ""})
|
||||
case http.MethodPost:
|
||||
idempotencyKey, ok := requireIMIdempotencyKey(wrt, req)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body imAttendanceAppealRequest
|
||||
if !decodeIMJSONBody(wrt, req, &body) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(body.Reason) == "" || len([]rune(body.Reason)) > 500 {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "reason"})
|
||||
return
|
||||
}
|
||||
var targetTime *time.Time
|
||||
if strings.TrimSpace(body.TargetTime) != "" {
|
||||
parsed, err := parseIMTime(body.TargetTime)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": "target_time"})
|
||||
return
|
||||
}
|
||||
targetTime = &parsed
|
||||
}
|
||||
token, err := randomIMToken(12)
|
||||
if err != nil {
|
||||
writeIMError(wrt, req, http.StatusInternalServerError, 90000, imReasonInternal, "internal error", nil)
|
||||
return
|
||||
}
|
||||
appeal, err := store.Attendance.CreateAppeal(principal.TenantID, principal.Uid, &types.AttendanceAppeal{
|
||||
AppealID: "atta_" + token,
|
||||
RecordID: strings.TrimSpace(body.RecordID),
|
||||
UserID: principal.Uid,
|
||||
AppealType: strings.TrimSpace(body.AppealType),
|
||||
Reason: strings.TrimSpace(body.Reason),
|
||||
TargetAction: strings.TrimSpace(body.TargetAction),
|
||||
TargetTime: targetTime,
|
||||
AttachmentFileIDs: body.AttachmentFileIDs,
|
||||
}, idempotencyKey)
|
||||
if err != nil {
|
||||
writeIMRelationError(wrt, req, err)
|
||||
return
|
||||
}
|
||||
publishIMWorkbenchAndAudit(req.Context(), principal.TenantID, principal.Uid, "attendance", "attendance.appeal.created", appeal.AppealID,
|
||||
[]types.Uid{principal.Uid}, "考勤申诉", appeal.RecordID, map[string]any{"appeal_id": appeal.AppealID, "record_id": appeal.RecordID, "status": appeal.Status})
|
||||
writeIMSuccess(wrt, req, attendanceAppealPayload(appeal))
|
||||
default:
|
||||
writeIMError(wrt, req, http.StatusMethodNotAllowed, 30002, imReasonMethodNotAllowed, "method not allowed", nil)
|
||||
}
|
||||
}
|
||||
|
||||
func serveIMMeetings(wrt http.ResponseWriter, req *http.Request) {
|
||||
if req.Method == http.MethodGet {
|
||||
serveIMMeetingList(wrt, req)
|
||||
@@ -6215,6 +6423,25 @@ func parseIMDate(raw string) (time.Time, error) {
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseIMOptionalDate(raw string, fallback time.Time) (time.Time, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return time.Date(fallback.UTC().Year(), fallback.UTC().Month(), fallback.UTC().Day(), 0, 0, 0, 0, time.UTC), nil
|
||||
}
|
||||
return parseIMDate(raw)
|
||||
}
|
||||
|
||||
func validIMOptionalDateQuery(wrt http.ResponseWriter, req *http.Request, field string) bool {
|
||||
value := strings.TrimSpace(req.URL.Query().Get(field))
|
||||
if value == "" {
|
||||
return true
|
||||
}
|
||||
if _, err := parseIMDate(value); err != nil {
|
||||
writeIMError(wrt, req, http.StatusBadRequest, 30000, imReasonMalformedRequest, "malformed request", map[string]any{"field": field})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validIMMonth(raw string) bool {
|
||||
if _, err := time.ParseInLocation("2006-01", strings.TrimSpace(raw), time.UTC); err != nil {
|
||||
return false
|
||||
@@ -7273,6 +7500,146 @@ func organizationSearchPayloads(items []types.OrganizationSearchItem) []map[stri
|
||||
return payload
|
||||
}
|
||||
|
||||
func attendanceConfigPayload(config *types.AttendanceConfig) map[string]any {
|
||||
return map[string]any{
|
||||
"date": config.Date.Format("2006-01-02"),
|
||||
"timezone": config.Timezone,
|
||||
"rule": attendanceRulePayload(config.Rule),
|
||||
"shifts": attendanceShiftPayloads(config.Shifts),
|
||||
"allowed_actions": config.AllowedActions,
|
||||
"today_record": attendanceRecordPayload(config.TodayRecord),
|
||||
"permissions": map[string]any{
|
||||
"can_checkin": true,
|
||||
"can_view_managed": config.CanViewManaged,
|
||||
"can_appeal": config.Rule.AppealAllowed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceRulePayload(rule types.AttendanceRule) map[string]any {
|
||||
data := map[string]any{
|
||||
"rule_id": rule.RuleID,
|
||||
"name": rule.Name,
|
||||
"workday": rule.Workday,
|
||||
"location_required": rule.LocationRequired,
|
||||
"wifi_required": rule.WifiRequired,
|
||||
"allow_outside_checkin": rule.AllowOutsideCheckin,
|
||||
"appeal_allowed": rule.AppealAllowed,
|
||||
}
|
||||
if rule.AppealDeadline != nil {
|
||||
data["appeal_deadline"] = rule.AppealDeadline.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func attendanceShiftPayloads(shifts []types.AttendanceShift) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(shifts))
|
||||
for _, shift := range shifts {
|
||||
items = append(items, map[string]any{
|
||||
"shift_id": shift.ShiftID,
|
||||
"name": shift.Name,
|
||||
"work_start": shift.WorkStart,
|
||||
"work_end": shift.WorkEnd,
|
||||
"checkin_start": shift.CheckinStart,
|
||||
"checkout_end": shift.CheckoutEnd,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func attendanceRecordPayloads(records []types.AttendanceRecord) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(records))
|
||||
for i := range records {
|
||||
items = append(items, attendanceRecordPayload(&records[i]))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func attendanceRecordPayload(record *types.AttendanceRecord) map[string]any {
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{
|
||||
"record_id": record.RecordID,
|
||||
"user_id": record.UserID.UserId(),
|
||||
"date": record.Date.Format("2006-01-02"),
|
||||
"rule_id": record.RuleID,
|
||||
"shift_id": record.ShiftID,
|
||||
"status": record.Status,
|
||||
"checkins": attendanceCheckinPayloads(record.Checkins),
|
||||
"exceptions": record.Exceptions,
|
||||
"appeal_status": record.AppealStatus,
|
||||
"version": record.Version,
|
||||
"created_at": record.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": record.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceCheckinPayloads(checkins []types.AttendanceCheckin) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(checkins))
|
||||
for _, checkin := range checkins {
|
||||
items = append(items, attendanceCheckinPayload(checkin))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func attendanceCheckinPayload(checkin types.AttendanceCheckin) map[string]any {
|
||||
return map[string]any{
|
||||
"checkin_id": checkin.CheckinID,
|
||||
"action": checkin.Action,
|
||||
"client_time": checkin.ClientTime.UTC().Format(time.RFC3339Nano),
|
||||
"server_time": checkin.ServerTime.UTC().Format(time.RFC3339Nano),
|
||||
"result": checkin.Result,
|
||||
"location": checkin.Location,
|
||||
"wifi": checkin.Wifi,
|
||||
"device": checkin.Device,
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceStatsPayload(stats *types.AttendanceRecordStats) map[string]any {
|
||||
if stats == nil {
|
||||
return map[string]any{"expected_count": 0, "checked_count": 0, "exception_count": 0, "not_checked_count": 0}
|
||||
}
|
||||
return map[string]any{
|
||||
"expected_count": stats.ExpectedCount,
|
||||
"checked_count": stats.CheckedCount,
|
||||
"exception_count": stats.ExceptionCount,
|
||||
"not_checked_count": stats.NotCheckedCount,
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceAppealPayloads(appeals []types.AttendanceAppeal) []map[string]any {
|
||||
items := make([]map[string]any, 0, len(appeals))
|
||||
for i := range appeals {
|
||||
items = append(items, attendanceAppealPayload(&appeals[i]))
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func attendanceAppealPayload(appeal *types.AttendanceAppeal) map[string]any {
|
||||
if appeal == nil {
|
||||
return nil
|
||||
}
|
||||
data := map[string]any{
|
||||
"appeal_id": appeal.AppealID,
|
||||
"record_id": appeal.RecordID,
|
||||
"user_id": appeal.UserID.UserId(),
|
||||
"appeal_type": appeal.AppealType,
|
||||
"reason": appeal.Reason,
|
||||
"target_action": appeal.TargetAction,
|
||||
"attachment_file_ids": appeal.AttachmentFileIDs,
|
||||
"status": appeal.Status,
|
||||
"version": appeal.Version,
|
||||
"submitted_at": appeal.SubmittedAt.UTC().Format(time.RFC3339Nano),
|
||||
"created_at": appeal.CreatedAt.UTC().Format(time.RFC3339Nano),
|
||||
"updated_at": appeal.UpdatedAt.UTC().Format(time.RFC3339Nano),
|
||||
}
|
||||
if appeal.TargetTime != nil {
|
||||
data["target_time"] = appeal.TargetTime.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func meetingPayload(meeting *types.Meeting, viewer types.Uid) map[string]any {
|
||||
return meetingPayloadWithUserSummaries(meeting, viewer, nil)
|
||||
}
|
||||
@@ -7835,7 +8202,7 @@ func tenantEntryFeedbackPayload(feedback *types.TenantEntryFeedback) map[string]
|
||||
|
||||
func isAllowedIMWorkbenchRoute(route string) bool {
|
||||
switch route {
|
||||
case "/organization", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox":
|
||||
case "/organization", "/attendance", "/meetings", "/reports/daily", "/reports/monthly", "/executive-mailbox":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
||||
Reference in New Issue
Block a user