feat: add attendance APIs
This commit is contained in:
+125
-4
@@ -915,6 +915,9 @@ func (a *adapter) CreateDb(reset bool) error {
|
||||
if err = createIMOrganizationTables(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = createIMAttendanceTables(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = createIMMeetingTables(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1142,10 +1145,11 @@ func createIMWorkbenchTables(tx *sql.Tx) error {
|
||||
`INSERT INTO im_workbench_apps(code,name,route,sort_order,unread_key,enabled,permission)
|
||||
VALUES
|
||||
('organization','组织架构','/organization',10,'organization',1,'enabled'),
|
||||
('meetings','会议通知','/meetings',20,'meetings',1,'enabled'),
|
||||
('daily_reports','日报','/reports/daily',30,'daily_reports',1,'enabled'),
|
||||
('monthly_reports','月报','/reports/monthly',40,'monthly_reports',1,'enabled'),
|
||||
('executive_mailbox','总经理信箱','/executive-mailbox',50,'executive_mailbox',1,'enabled')
|
||||
('attendance','打卡','/attendance',20,'attendance',1,'enabled'),
|
||||
('meetings','会议通知','/meetings',30,'meetings',1,'enabled'),
|
||||
('daily_reports','日报','/reports/daily',40,'daily_reports',1,'enabled'),
|
||||
('monthly_reports','月报','/reports/monthly',50,'monthly_reports',1,'enabled'),
|
||||
('executive_mailbox','总经理信箱','/executive-mailbox',60,'executive_mailbox',1,'enabled')
|
||||
ON DUPLICATE KEY UPDATE
|
||||
name=VALUES(name),
|
||||
route=VALUES(route),
|
||||
@@ -1231,6 +1235,123 @@ func ensureIMOrganizationVisibilityTables(exec sqlExecutor) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func createIMAttendanceTables(tx *sql.Tx) error {
|
||||
statements := []string{
|
||||
`CREATE TABLE im_attendance_rules(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
rule_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
workday TINYINT NOT NULL DEFAULT 1,
|
||||
location_required TINYINT NOT NULL DEFAULT 0,
|
||||
wifi_required TINYINT NOT NULL DEFAULT 0,
|
||||
allow_outside_checkin TINYINT NOT NULL DEFAULT 1,
|
||||
appeal_allowed TINYINT NOT NULL DEFAULT 1,
|
||||
effective_from DATE,
|
||||
effective_to DATE,
|
||||
state VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_attendance_rules_rule(rule_id),
|
||||
INDEX idx_im_attendance_rules_state(state,effective_from,effective_to)
|
||||
)`,
|
||||
`CREATE TABLE im_attendance_shifts(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
shift_id VARCHAR(64) NOT NULL,
|
||||
rule_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(128) NOT NULL,
|
||||
work_start TIME NOT NULL,
|
||||
work_end TIME NOT NULL,
|
||||
checkin_start TIME NOT NULL,
|
||||
checkout_end TIME NOT NULL,
|
||||
state VARCHAR(16) NOT NULL DEFAULT 'active',
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_attendance_shifts_shift(shift_id),
|
||||
INDEX idx_im_attendance_shifts_rule(rule_id,state,sort_order)
|
||||
)`,
|
||||
`CREATE TABLE im_attendance_records(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
record_id VARCHAR(64) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
record_date DATE NOT NULL,
|
||||
rule_id VARCHAR(64) NOT NULL,
|
||||
shift_id VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'not_checked',
|
||||
exceptions JSON,
|
||||
appeal_status VARCHAR(32) NOT NULL DEFAULT '',
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_attendance_records_record(record_id),
|
||||
UNIQUE INDEX uk_im_attendance_records_user_date_shift(user_id,record_date,shift_id),
|
||||
INDEX idx_im_attendance_records_user_id(user_id,id DESC),
|
||||
INDEX idx_im_attendance_records_date_status(record_date,status,id DESC)
|
||||
)`,
|
||||
`CREATE TABLE im_attendance_checkins(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
checkin_id VARCHAR(64) NOT NULL,
|
||||
record_id VARCHAR(64) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
record_date DATE NOT NULL,
|
||||
action VARCHAR(32) NOT NULL,
|
||||
rule_id VARCHAR(64) NOT NULL,
|
||||
shift_id VARCHAR(64) NOT NULL,
|
||||
client_time DATETIME(3) NOT NULL,
|
||||
server_time DATETIME(3) NOT NULL,
|
||||
result VARCHAR(32) NOT NULL,
|
||||
location JSON,
|
||||
wifi JSON,
|
||||
device JSON,
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_attendance_checkins_checkin(checkin_id),
|
||||
UNIQUE INDEX uk_im_attendance_checkins_idempotency(user_id,idempotency_key),
|
||||
INDEX idx_im_attendance_checkins_record(record_id,id)
|
||||
)`,
|
||||
`CREATE TABLE im_attendance_appeals(
|
||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||
appeal_id VARCHAR(64) NOT NULL,
|
||||
record_id VARCHAR(64) NOT NULL,
|
||||
user_id BIGINT NOT NULL,
|
||||
appeal_type VARCHAR(64) NOT NULL,
|
||||
reason VARCHAR(512) NOT NULL,
|
||||
target_action VARCHAR(32) NOT NULL DEFAULT '',
|
||||
target_time DATETIME(3),
|
||||
attachment_file_ids JSON,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'pending',
|
||||
version INT NOT NULL DEFAULT 1,
|
||||
idempotency_key VARCHAR(128) NOT NULL,
|
||||
submitted_at DATETIME(3) NOT NULL,
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||
PRIMARY KEY(id),
|
||||
UNIQUE INDEX uk_im_attendance_appeals_appeal(appeal_id),
|
||||
UNIQUE INDEX uk_im_attendance_appeals_idempotency(user_id,idempotency_key),
|
||||
INDEX idx_im_attendance_appeals_record(record_id),
|
||||
INDEX idx_im_attendance_appeals_user_status(user_id,status,id DESC)
|
||||
)`,
|
||||
}
|
||||
for _, stmt := range statements {
|
||||
if _, err := tx.Exec(stmt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO im_attendance_rules(rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed)
|
||||
VALUES('att_rule_default','默认考勤规则',1,0,0,1,1)
|
||||
ON DUPLICATE KEY UPDATE updated_at=updated_at`); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(`INSERT INTO im_attendance_shifts(shift_id,rule_id,name,work_start,work_end,checkin_start,checkout_end,sort_order)
|
||||
VALUES('shift_default','att_rule_default','默认班次','09:00:00','18:00:00','08:30:00','20:00:00',10)
|
||||
ON DUPLICATE KEY UPDATE updated_at=updated_at`)
|
||||
return err
|
||||
}
|
||||
|
||||
func createIMMeetingTables(tx *sql.Tx) error {
|
||||
statements := []string{
|
||||
`CREATE TABLE im_meetings(
|
||||
|
||||
@@ -3858,6 +3858,707 @@ func uniqueMeetingParticipants(organizer t.Uid, attendeeIDs []t.Uid) []t.Uid {
|
||||
return participants
|
||||
}
|
||||
|
||||
func (a *adapter) TenantAttendanceConfig(id t.TenantID, uid t.Uid, date time.Time) (*t.AttendanceConfig, error) {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantAttendanceConfig(id, uid, date)
|
||||
}
|
||||
}
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || date.IsZero() {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
rule, err := a.activeAttendanceRule(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shifts, err := a.attendanceShifts(ctx, rule.RuleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record, err := a.attendanceRecordByDate(ctx, uid, date, "")
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
if err == sql.ErrNoRows {
|
||||
record = nil
|
||||
}
|
||||
return &t.AttendanceConfig{
|
||||
Date: date,
|
||||
Timezone: "Asia/Shanghai",
|
||||
Rule: *rule,
|
||||
Shifts: shifts,
|
||||
AllowedActions: []string{t.AttendanceActionCheckIn, t.AttendanceActionCheckOut},
|
||||
TodayRecord: record,
|
||||
CanViewManaged: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantAttendanceRecordList(id t.TenantID, uid t.Uid, opts t.AttendanceRecordListOptions) ([]t.AttendanceRecord, string, *t.AttendanceRecordStats, error) {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantAttendanceRecordList(id, uid, opts)
|
||||
}
|
||||
}
|
||||
if err := validTenant(id); err != nil || uid.IsZero() {
|
||||
return nil, "", nil, t.ErrMalformed
|
||||
}
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if opts.Scope == "" || opts.Scope == "mine" {
|
||||
where = append(where, "user_id=?")
|
||||
args = append(args, decodeUID(uid))
|
||||
} else if opts.Scope == "managed" {
|
||||
if raw := strings.TrimSpace(opts.UserID); raw != "" {
|
||||
target := t.ParseUserId(raw)
|
||||
if target.IsZero() {
|
||||
return nil, "", nil, t.ErrMalformed
|
||||
}
|
||||
where = append(where, "user_id=?")
|
||||
args = append(args, decodeUID(target))
|
||||
}
|
||||
} else {
|
||||
return nil, "", nil, t.ErrMalformed
|
||||
}
|
||||
if opts.Date != "" {
|
||||
where = append(where, "record_date=?")
|
||||
args = append(args, opts.Date)
|
||||
}
|
||||
if opts.From != "" {
|
||||
where = append(where, "record_date>=?")
|
||||
args = append(args, opts.From)
|
||||
}
|
||||
if opts.To != "" {
|
||||
where = append(where, "record_date<=?")
|
||||
args = append(args, opts.To)
|
||||
}
|
||||
if opts.Status != "" {
|
||||
where = append(where, "status=?")
|
||||
args = append(args, opts.Status)
|
||||
}
|
||||
if opts.Cursor != "" {
|
||||
parsed, err := strconv.ParseInt(opts.Cursor, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return nil, "", nil, t.ErrMalformed
|
||||
}
|
||||
where = append(where, "id<?")
|
||||
args = append(args, parsed)
|
||||
}
|
||||
limit := opts.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
query := `SELECT * FROM im_attendance_records WHERE ` + strings.Join(where, " AND ") + ` ORDER BY id DESC LIMIT ?`
|
||||
rows, err := a.db.QueryxContext(ctx, query, append(args, limit+1)...)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
records := make([]t.AttendanceRecord, 0, limit)
|
||||
nextCursor := ""
|
||||
for rows.Next() {
|
||||
record, err := scanAttendanceRecord(rows)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
if len(records) == limit {
|
||||
nextCursor = strconv.FormatInt(record.ID, 10)
|
||||
continue
|
||||
}
|
||||
checkins, err := a.attendanceCheckins(ctx, record.RecordID)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
record.Checkins = checkins
|
||||
records = append(records, *record)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
stats, err := a.attendanceRecordStats(ctx, opts, uid)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return records, nextCursor, stats, nil
|
||||
}
|
||||
|
||||
func (a *adapter) TenantAttendanceCheckin(id t.TenantID, uid t.Uid, checkinID string, req t.AttendanceCheckinRequest) (*t.AttendanceRecord, *t.AttendanceCheckin, error) {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantAttendanceCheckin(id, uid, checkinID, req)
|
||||
}
|
||||
}
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || strings.TrimSpace(checkinID) == "" || strings.TrimSpace(req.IdempotencyKey) == "" {
|
||||
return nil, nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
if existing, err := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey); err == nil {
|
||||
record, recErr := a.attendanceRecordByID(ctx, existing.RecordID)
|
||||
return record, existing, recErr
|
||||
} else if err != sql.ErrNoRows {
|
||||
return nil, nil, err
|
||||
}
|
||||
rule, err := a.activeAttendanceRule(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
shifts, err := a.attendanceShifts(ctx, rule.RuleID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
shift := shifts[0]
|
||||
if strings.TrimSpace(req.ShiftID) != "" {
|
||||
for _, candidate := range shifts {
|
||||
if candidate.ShiftID == strings.TrimSpace(req.ShiftID) {
|
||||
shift = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
recordID := t.StableEventID("attendance_record", uid.UserId(), req.Date.Format("2006-01-02"), shift.ShiftID)
|
||||
result := attendanceCheckinResult(req.Action, req.ClientTime, req.Date, shift)
|
||||
status := attendanceRecordStatus(req.Action, result, "")
|
||||
exceptions := attendanceExceptions(status)
|
||||
locationJSON, _ := json.Marshal(req.Location)
|
||||
wifiJSON, _ := json.Marshal(req.Wifi)
|
||||
deviceJSON, _ := json.Marshal(req.Device)
|
||||
exceptionsJSON, _ := json.Marshal(exceptions)
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
tx, err := a.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer func() {
|
||||
if tx != nil {
|
||||
tx.Rollback()
|
||||
}
|
||||
}()
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_records(record_id,user_id,record_date,rule_id,shift_id,status,exceptions,appeal_status,version)
|
||||
VALUES(?,?,?,?,?,?,?,?,1)
|
||||
ON DUPLICATE KEY UPDATE record_id=record_id`, recordID, decodeUID(uid), req.Date.Format("2006-01-02"), rule.RuleID, shift.ShiftID,
|
||||
t.AttendanceStatusNotChecked, "[]", ""); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
current := struct {
|
||||
RecordID string `db:"record_id"`
|
||||
Status string
|
||||
Version int
|
||||
}{}
|
||||
if err = sqlx.GetContext(ctx, tx, ¤t, `SELECT record_id,status,version FROM im_attendance_records WHERE user_id=? AND record_date=? AND shift_id=?`,
|
||||
decodeUID(uid), req.Date.Format("2006-01-02"), shift.ShiftID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
status = attendanceRecordStatus(req.Action, result, current.Status)
|
||||
exceptionsJSON, _ = json.Marshal(attendanceExceptions(status))
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO im_attendance_checkins(checkin_id,record_id,user_id,record_date,action,rule_id,shift_id,client_time,server_time,result,location,wifi,device,idempotency_key)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, checkinID, current.RecordID, decodeUID(uid), req.Date.Format("2006-01-02"), req.Action, rule.RuleID, shift.ShiftID,
|
||||
req.ClientTime.UTC().Round(time.Millisecond), now, result, string(locationJSON), string(wifiJSON), string(deviceJSON), req.IdempotencyKey); err != nil {
|
||||
if isDupe(err) {
|
||||
tx.Rollback()
|
||||
tx = nil
|
||||
existing, lookupErr := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey)
|
||||
if lookupErr != nil {
|
||||
return nil, nil, lookupErr
|
||||
}
|
||||
record, recErr := a.attendanceRecordByID(ctx, existing.RecordID)
|
||||
return record, existing, recErr
|
||||
}
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE im_attendance_records SET status=?,exceptions=?,version=version+1,updated_at=CURRENT_TIMESTAMP(3)
|
||||
WHERE record_id=?`, status, string(exceptionsJSON), current.RecordID); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
tx = nil
|
||||
checkin, err := a.attendanceCheckinByIdempotency(ctx, uid, req.IdempotencyKey)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
record, err := a.attendanceRecordByID(ctx, checkin.RecordID)
|
||||
return record, checkin, err
|
||||
}
|
||||
|
||||
func (a *adapter) TenantAttendanceAppealCreate(id t.TenantID, uid t.Uid, appeal *t.AttendanceAppeal, idempotencyKey string) (*t.AttendanceAppeal, error) {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantAttendanceAppealCreate(id, uid, appeal, idempotencyKey)
|
||||
}
|
||||
}
|
||||
if err := validTenant(id); err != nil || uid.IsZero() || appeal == nil || strings.TrimSpace(idempotencyKey) == "" {
|
||||
return nil, t.ErrMalformed
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
if existing, err := a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey); err == nil {
|
||||
return existing, nil
|
||||
} else if err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
record, err := a.attendanceRecordByID(ctx, strings.TrimSpace(appeal.RecordID))
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, t.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if record.UserID != uid {
|
||||
return nil, t.ErrPermissionDenied
|
||||
}
|
||||
attachmentsJSON, _ := json.Marshal(appeal.AttachmentFileIDs)
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
if appeal.TargetTime != nil {
|
||||
tm := appeal.TargetTime.UTC().Round(time.Millisecond)
|
||||
appeal.TargetTime = &tm
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `INSERT INTO im_attendance_appeals(appeal_id,record_id,user_id,appeal_type,reason,target_action,target_time,attachment_file_ids,status,version,idempotency_key,submitted_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, strings.TrimSpace(appeal.AppealID), strings.TrimSpace(appeal.RecordID), decodeUID(uid), strings.TrimSpace(appeal.AppealType),
|
||||
strings.TrimSpace(appeal.Reason), strings.TrimSpace(appeal.TargetAction), appeal.TargetTime, string(attachmentsJSON), t.AttendanceAppealPending, 1,
|
||||
idempotencyKey, now)
|
||||
if err != nil {
|
||||
if isDupe(err) {
|
||||
return a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
_, _ = a.db.ExecContext(ctx, `UPDATE im_attendance_records SET appeal_status=?,updated_at=CURRENT_TIMESTAMP(3) WHERE record_id=?`,
|
||||
t.AttendanceAppealPending, strings.TrimSpace(appeal.RecordID))
|
||||
return a.attendanceAppealByIdempotency(ctx, uid, idempotencyKey)
|
||||
}
|
||||
|
||||
func (a *adapter) TenantAttendanceAppealList(id t.TenantID, uid t.Uid, opts t.AttendanceAppealListOptions) ([]t.AttendanceAppeal, string, error) {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if tenantAdp != a {
|
||||
return tenantAdp.TenantAttendanceAppealList(id, uid, opts)
|
||||
}
|
||||
}
|
||||
if err := validTenant(id); err != nil || uid.IsZero() {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if opts.Scope == "" || opts.Scope == "mine" {
|
||||
where = append(where, "user_id=?")
|
||||
args = append(args, decodeUID(uid))
|
||||
} else if opts.Scope != "managed" {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
if opts.Status != "" {
|
||||
where = append(where, "status=?")
|
||||
args = append(args, opts.Status)
|
||||
}
|
||||
if opts.Cursor != "" {
|
||||
parsed, err := strconv.ParseInt(opts.Cursor, 10, 64)
|
||||
if err != nil || parsed <= 0 {
|
||||
return nil, "", t.ErrMalformed
|
||||
}
|
||||
where = append(where, "id<?")
|
||||
args = append(args, parsed)
|
||||
}
|
||||
limit := opts.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
ctx, cancel := a.getContext()
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_appeals WHERE `+strings.Join(where, " AND ")+` ORDER BY id DESC LIMIT ?`, append(args, limit+1)...)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer rows.Close()
|
||||
appeals := make([]t.AttendanceAppeal, 0, limit)
|
||||
nextCursor := ""
|
||||
for rows.Next() {
|
||||
appeal, err := scanAttendanceAppeal(rows)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(appeals) == limit {
|
||||
nextCursor = strconv.FormatInt(appeal.ID, 10)
|
||||
continue
|
||||
}
|
||||
appeals = append(appeals, *appeal)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return appeals, nextCursor, nil
|
||||
}
|
||||
|
||||
func (a *adapter) activeAttendanceRule(ctx context.Context) (*t.AttendanceRule, error) {
|
||||
row := struct {
|
||||
ID int64
|
||||
RuleID string `db:"rule_id"`
|
||||
Name string
|
||||
Workday bool
|
||||
LocationRequired bool `db:"location_required"`
|
||||
WifiRequired bool `db:"wifi_required"`
|
||||
AllowOutsideCheckin bool `db:"allow_outside_checkin"`
|
||||
AppealAllowed bool `db:"appeal_allowed"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}{}
|
||||
if err := sqlx.GetContext(ctx, a.db, &row, `SELECT id,rule_id,name,workday,location_required,wifi_required,allow_outside_checkin,appeal_allowed,created_at,updated_at
|
||||
FROM im_attendance_rules WHERE state='active' ORDER BY id ASC LIMIT 1`); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
return &t.AttendanceRule{RuleID: "att_rule_default", Name: "默认考勤规则", Workday: true, AllowOutsideCheckin: true, AppealAllowed: true, CreatedAt: now, UpdatedAt: now}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &t.AttendanceRule{
|
||||
ID: row.ID, RuleID: row.RuleID, Name: row.Name, Workday: row.Workday, LocationRequired: row.LocationRequired,
|
||||
WifiRequired: row.WifiRequired, AllowOutsideCheckin: row.AllowOutsideCheckin, AppealAllowed: row.AppealAllowed,
|
||||
CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *adapter) attendanceShifts(ctx context.Context, ruleID string) ([]t.AttendanceShift, error) {
|
||||
rows, err := a.db.QueryxContext(ctx, `SELECT id,shift_id,rule_id,name,TIME_FORMAT(work_start,'%H:%i') AS work_start,TIME_FORMAT(work_end,'%H:%i') AS work_end,
|
||||
TIME_FORMAT(checkin_start,'%H:%i') AS checkin_start,TIME_FORMAT(checkout_end,'%H:%i') AS checkout_end,sort_order,created_at,updated_at
|
||||
FROM im_attendance_shifts WHERE rule_id=? AND state='active' ORDER BY sort_order ASC,id ASC`, ruleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
shifts := make([]t.AttendanceShift, 0, 2)
|
||||
for rows.Next() {
|
||||
var shift t.AttendanceShift
|
||||
if err = rows.StructScan(&shift); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
shifts = append(shifts, shift)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(shifts) == 0 {
|
||||
now := time.Now().UTC().Round(time.Millisecond)
|
||||
shifts = append(shifts, t.AttendanceShift{ShiftID: "shift_default", RuleID: ruleID, Name: "默认班次", WorkStart: "09:00", WorkEnd: "18:00", CheckinStart: "08:30", CheckoutEnd: "20:00", SortOrder: 10, CreatedAt: now, UpdatedAt: now})
|
||||
}
|
||||
return shifts, nil
|
||||
}
|
||||
|
||||
func (a *adapter) attendanceRecordByDate(ctx context.Context, uid t.Uid, date time.Time, shiftID string) (*t.AttendanceRecord, error) {
|
||||
query := `SELECT * FROM im_attendance_records WHERE user_id=? AND record_date=?`
|
||||
args := []any{decodeUID(uid), date.Format("2006-01-02")}
|
||||
if shiftID != "" {
|
||||
query += ` AND shift_id=?`
|
||||
args = append(args, shiftID)
|
||||
}
|
||||
query += ` ORDER BY id ASC LIMIT 1`
|
||||
rows, err := a.db.QueryxContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if rows.Next() {
|
||||
record, err := scanAttendanceRecord(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record.Checkins, err = a.attendanceCheckins(ctx, record.RecordID)
|
||||
return record, err
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (a *adapter) attendanceRecordByID(ctx context.Context, recordID string) (*t.AttendanceRecord, error) {
|
||||
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_records WHERE record_id=?`, recordID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if rows.Next() {
|
||||
record, err := scanAttendanceRecord(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record.Checkins, err = a.attendanceCheckins(ctx, record.RecordID)
|
||||
return record, err
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (a *adapter) attendanceCheckins(ctx context.Context, recordID string) ([]t.AttendanceCheckin, error) {
|
||||
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_checkins WHERE record_id=? ORDER BY id ASC`, recordID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]t.AttendanceCheckin, 0, 2)
|
||||
for rows.Next() {
|
||||
item, err := scanAttendanceCheckin(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, *item)
|
||||
}
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (a *adapter) attendanceCheckinByIdempotency(ctx context.Context, uid t.Uid, key string) (*t.AttendanceCheckin, error) {
|
||||
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_checkins WHERE user_id=? AND idempotency_key=?`, decodeUID(uid), key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if rows.Next() {
|
||||
return scanAttendanceCheckin(rows)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (a *adapter) attendanceAppealByIdempotency(ctx context.Context, uid t.Uid, key string) (*t.AttendanceAppeal, error) {
|
||||
rows, err := a.db.QueryxContext(ctx, `SELECT * FROM im_attendance_appeals WHERE user_id=? AND idempotency_key=?`, decodeUID(uid), key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if rows.Next() {
|
||||
return scanAttendanceAppeal(rows)
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
func (a *adapter) attendanceRecordStats(ctx context.Context, opts t.AttendanceRecordListOptions, viewer t.Uid) (*t.AttendanceRecordStats, error) {
|
||||
where := []string{"1=1"}
|
||||
args := []any{}
|
||||
if opts.Scope == "" || opts.Scope == "mine" {
|
||||
where = append(where, "user_id=?")
|
||||
args = append(args, decodeUID(viewer))
|
||||
}
|
||||
if opts.Date != "" {
|
||||
where = append(where, "record_date=?")
|
||||
args = append(args, opts.Date)
|
||||
}
|
||||
if opts.From != "" {
|
||||
where = append(where, "record_date>=?")
|
||||
args = append(args, opts.From)
|
||||
}
|
||||
if opts.To != "" {
|
||||
where = append(where, "record_date<=?")
|
||||
args = append(args, opts.To)
|
||||
}
|
||||
stats := &t.AttendanceRecordStats{}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_attendance_records WHERE `+strings.Join(where, " AND "), args...).Scan(&stats.CheckedCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exceptionWhere := append([]string{}, where...)
|
||||
exceptionWhere = append(exceptionWhere, "status IN (?,?,?)")
|
||||
exceptionArgs := append(append([]any{}, args...), t.AttendanceStatusLate, t.AttendanceStatusEarlyLeave, t.AttendanceStatusAbnormal)
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM im_attendance_records WHERE `+strings.Join(exceptionWhere, " AND "), exceptionArgs...).Scan(&stats.ExceptionCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := a.db.QueryRowContext(ctx, `SELECT COUNT(DISTINCT user_id) FROM im_org_members WHERE employment_state='active'`).Scan(&stats.ExpectedCount); err != nil {
|
||||
stats.ExpectedCount = stats.CheckedCount
|
||||
}
|
||||
if opts.Scope == "" || opts.Scope == "mine" {
|
||||
stats.ExpectedCount = 1
|
||||
}
|
||||
stats.NotCheckedCount = stats.ExpectedCount - stats.CheckedCount
|
||||
if stats.NotCheckedCount < 0 {
|
||||
stats.NotCheckedCount = 0
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func scanAttendanceRecord(rows *sqlx.Rows) (*t.AttendanceRecord, error) {
|
||||
row := struct {
|
||||
ID int64
|
||||
RecordID string `db:"record_id"`
|
||||
UserID int64 `db:"user_id"`
|
||||
Date time.Time `db:"record_date"`
|
||||
RuleID string `db:"rule_id"`
|
||||
ShiftID string `db:"shift_id"`
|
||||
Status string
|
||||
Exceptions []byte
|
||||
AppealStatus string `db:"appeal_status"`
|
||||
Version int
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}{}
|
||||
if err := rows.StructScan(&row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
record := &t.AttendanceRecord{
|
||||
ID: row.ID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID), Date: row.Date,
|
||||
RuleID: row.RuleID, ShiftID: row.ShiftID, Status: row.Status, AppealStatus: row.AppealStatus,
|
||||
Version: row.Version, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
if len(row.Exceptions) > 0 {
|
||||
_ = json.Unmarshal(row.Exceptions, &record.Exceptions)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func scanAttendanceCheckin(rows *sqlx.Rows) (*t.AttendanceCheckin, error) {
|
||||
row := struct {
|
||||
ID int64
|
||||
CheckinID string `db:"checkin_id"`
|
||||
RecordID string `db:"record_id"`
|
||||
UserID int64 `db:"user_id"`
|
||||
Date time.Time `db:"record_date"`
|
||||
Action string
|
||||
RuleID string `db:"rule_id"`
|
||||
ShiftID string `db:"shift_id"`
|
||||
ClientTime time.Time `db:"client_time"`
|
||||
ServerTime time.Time `db:"server_time"`
|
||||
Result string
|
||||
Location []byte
|
||||
Wifi []byte
|
||||
Device []byte
|
||||
IdempotencyKey string `db:"idempotency_key"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
}{}
|
||||
if err := rows.StructScan(&row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := &t.AttendanceCheckin{
|
||||
ID: row.ID, CheckinID: row.CheckinID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID), Date: row.Date,
|
||||
Action: row.Action, RuleID: row.RuleID, ShiftID: row.ShiftID, ClientTime: row.ClientTime, ServerTime: row.ServerTime,
|
||||
Result: row.Result, IdempotencyKey: row.IdempotencyKey, CreatedAt: row.CreatedAt,
|
||||
}
|
||||
_ = json.Unmarshal(row.Location, &item.Location)
|
||||
_ = json.Unmarshal(row.Wifi, &item.Wifi)
|
||||
_ = json.Unmarshal(row.Device, &item.Device)
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func scanAttendanceAppeal(rows *sqlx.Rows) (*t.AttendanceAppeal, error) {
|
||||
row := struct {
|
||||
ID int64
|
||||
AppealID string `db:"appeal_id"`
|
||||
RecordID string `db:"record_id"`
|
||||
UserID int64 `db:"user_id"`
|
||||
AppealType string `db:"appeal_type"`
|
||||
Reason string
|
||||
TargetAction string `db:"target_action"`
|
||||
TargetTime *time.Time `db:"target_time"`
|
||||
AttachmentFileIDs []byte `db:"attachment_file_ids"`
|
||||
Status string
|
||||
Version int
|
||||
SubmittedAt time.Time `db:"submitted_at"`
|
||||
CreatedAt time.Time `db:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at"`
|
||||
}{}
|
||||
if err := rows.StructScan(&row); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appeal := &t.AttendanceAppeal{
|
||||
ID: row.ID, AppealID: row.AppealID, RecordID: row.RecordID, UserID: store.EncodeUid(row.UserID),
|
||||
AppealType: row.AppealType, Reason: row.Reason, TargetAction: row.TargetAction, TargetTime: row.TargetTime,
|
||||
Status: row.Status, Version: row.Version, SubmittedAt: row.SubmittedAt, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt,
|
||||
}
|
||||
if len(row.AttachmentFileIDs) > 0 {
|
||||
_ = json.Unmarshal(row.AttachmentFileIDs, &appeal.AttachmentFileIDs)
|
||||
}
|
||||
return appeal, nil
|
||||
}
|
||||
|
||||
func attendanceCheckinResult(action string, clientTime, date time.Time, shift t.AttendanceShift) string {
|
||||
switch action {
|
||||
case t.AttendanceActionCheckIn:
|
||||
if clientTime.After(attendanceTimeOnDate(date, shift.WorkStart)) {
|
||||
return t.AttendanceCheckinLate
|
||||
}
|
||||
case t.AttendanceActionCheckOut:
|
||||
if clientTime.Before(attendanceTimeOnDate(date, shift.WorkEnd)) {
|
||||
return t.AttendanceCheckinEarlyLeave
|
||||
}
|
||||
}
|
||||
return t.AttendanceCheckinNormal
|
||||
}
|
||||
|
||||
func attendanceRecordStatus(action, result, current string) string {
|
||||
if result == t.AttendanceCheckinLate {
|
||||
return t.AttendanceStatusLate
|
||||
}
|
||||
if result == t.AttendanceCheckinEarlyLeave {
|
||||
return t.AttendanceStatusEarlyLeave
|
||||
}
|
||||
if current == t.AttendanceStatusLate || current == t.AttendanceStatusEarlyLeave || current == t.AttendanceStatusAbnormal {
|
||||
return current
|
||||
}
|
||||
if action == t.AttendanceActionCheckOut {
|
||||
return t.AttendanceStatusNormal
|
||||
}
|
||||
return t.AttendanceStatusCheckedIn
|
||||
}
|
||||
|
||||
func attendanceExceptions(status string) []string {
|
||||
switch status {
|
||||
case t.AttendanceStatusLate:
|
||||
return []string{"late"}
|
||||
case t.AttendanceStatusEarlyLeave:
|
||||
return []string{"early_leave"}
|
||||
case t.AttendanceStatusAbnormal:
|
||||
return []string{"abnormal"}
|
||||
default:
|
||||
return []string{}
|
||||
}
|
||||
}
|
||||
|
||||
func attendanceTimeOnDate(date time.Time, clock string) time.Time {
|
||||
parts := strings.Split(clock, ":")
|
||||
hour, minute := 0, 0
|
||||
if len(parts) >= 2 {
|
||||
hour, _ = strconv.Atoi(parts[0])
|
||||
minute, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
loc := time.FixedZone("CST", 8*3600)
|
||||
localDate := date.In(loc)
|
||||
return time.Date(localDate.Year(), localDate.Month(), localDate.Day(), hour, minute, 0, 0, loc).UTC()
|
||||
}
|
||||
|
||||
func (a *adapter) TenantDailyReportList(id t.TenantID, uid t.Uid, scope, from, to, status, cursor string, limit int) ([]t.DailyReport, string, error) {
|
||||
if !a.tenantScopedDB {
|
||||
tenantAdp, err := a.routedTenantAdapter(id)
|
||||
@@ -5393,7 +6094,7 @@ func (a *adapter) userPrivacyByUID(ctx context.Context, uid t.Uid) (*t.UserPriva
|
||||
|
||||
func isAllowedWorkbenchRoute(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