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
+125 -4
View File
@@ -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(
+702 -1
View File
@@ -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, &current, `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