feat: add attendance APIs
This commit is contained in:
+19
-2
@@ -2061,6 +2061,17 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
- 全局搜索:`GET /v1/im/organization/search?q=&types=department,user&cursor=`。服务端执行组织范围授权,离职用户标记不可联系或从结果移除。
|
||||
- 组织架构可见范围由服务端按当前登录用户裁剪:上架测试账号可看全部组织;手机号注册账号默认只能看到自己与好友所在组织路径及成员;邀请码注册账号默认只能看到自己所在组织路径及本人。上架测试账号通过租户业务库 `im_org_visibility_overrides` 配置 `visibility_scope=all`,客户端不传可见范围参数。
|
||||
|
||||
### 9.2.1 打卡考勤
|
||||
|
||||
**状态:已实现基础版。**
|
||||
|
||||
- 打卡规则、班次、位置/Wi-Fi 约束、补卡窗口、异常判定和统计结果必须以后端为真源;客户端只负责展示、采集设备侧上下文和临时离线队列,不能把正式打卡记录或规则只保存在本地。
|
||||
- 配置:`GET /v1/im/attendance/config?date=2026-08-31` 返回当前用户当天适用的规则、班次、允许打卡动作和前端展示文案。服务端按租户、部门、人员、节假日和特殊排班裁剪,前端不得自行推导是否迟到/早退/缺卡。
|
||||
- 记录:`GET /v1/im/attendance/records?from=2026-08-01&to=2026-08-31&scope=mine` 返回当前用户打卡记录、异常状态和补卡状态;管理员用 `scope=managed&department_id=&user_id=&cursor=` 查询权限范围内记录。
|
||||
- 打卡:`POST /v1/im/attendance/checkins`,Body 带 `date,action,rule_id,shift_id,client_time,location,wifi,device`,写操作必须带 `Idempotency-Key`。服务端校验打卡窗口、地理围栏/Wi-Fi、重复提交和设备风险,成功后返回服务端判定状态。
|
||||
- 补卡/异常申诉:`POST /v1/im/attendance/appeals` 创建申请,`GET /v1/im/attendance/appeals?scope=mine|managed&status=&cursor=` 查询列表,审批动作由管理端或后续办公审批接口处理。
|
||||
- 主动下行:规则变更、打卡状态变化、补卡审批变化可通过 `info.what="im"`,事件 `attendance.rule.updated/attendance.record.updated/attendance.appeal.updated` 下发;前端收到后刷新配置、记录或列表。
|
||||
|
||||
### 9.3 会议通知-发起人状态.png
|
||||
|
||||
<img src="images/会议通知-发起人状态.png" alt="会议通知-发起人状态" width="360">
|
||||
@@ -2180,15 +2191,21 @@ X-MeetingIM-APIKey: <预置的普通 API Key>
|
||||
- 失败响应统一包裹为 `{"code":30000,"reason":"MALFORMED_REQUEST","message":"...","details":{},"request_id":"...","server_time":"..."}`。
|
||||
- 标注“待实现/需修复”的接口是产品目标协议,前端可按示例准备,但联调前以后端发布状态为准。
|
||||
|
||||
#### 9.14.1 工作台、组织、会议、日报、月报、信箱
|
||||
#### 9.14.1 工作台、组织、打卡、会议、日报、月报、信箱
|
||||
|
||||
| 接口 | 输入示例 | 成功 `data` 示例 |
|
||||
| --- | --- | --- |
|
||||
| `GET /v1/im/workbench` | Query only | `{"layout_version":1,"modules":[{"code":"organization","name":"组织架构","icon_file_id":"","route":"/organization","unread_count":0,"enabled":true,"permission":"enabled"}],"enterprise":{"tenant_code":"default","feature_flags":{"organization":true,"meetings":true}}}` |
|
||||
| `GET /v1/im/workbench` | Query only | `{"layout_version":1,"modules":[{"code":"organization","name":"组织架构","icon_file_id":"","route":"/organization","unread_count":0,"enabled":true,"permission":"enabled"},{"code":"attendance","name":"打卡考勤","icon_file_id":"","route":"/attendance","unread_count":0,"enabled":true,"permission":"enabled"}],"enterprise":{"tenant_code":"default","feature_flags":{"organization":true,"attendance":true,"meetings":true}}}` |
|
||||
| `GET /v1/im/organization/departments?parent_id=root&cursor=` | Query only | `{"items":[{"department_id":"dep_1","name":"研发部","parent_id":"root","child_count":0,"member_count":12,"version":1}],"next_cursor":""}` |
|
||||
| `GET /v1/im/organization/departments/{id}/members?cursor=` | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","avatar_file_id":"","department_id":"dep_1","title":"工程师","can_contact":true}],"next_cursor":""}` |
|
||||
| `GET /v1/im/organization/members?query=&department_id=&cursor=` 待实现/可由部门成员接口替代 | Query only | `{"items":[{"user_id":"usr...","display_name":"张三","department_id":"dep_1","department_name":"研发部","can_contact":true}],"next_cursor":""}` |
|
||||
| `GET /v1/im/organization/search?q=&types=department,user&cursor=` | Query only | `{"items":[{"type":"user","user_id":"usr...","display_name":"张三","department_name":"研发部","can_contact":true}],"next_cursor":""}` |
|
||||
| `GET /v1/im/attendance/config?date=2026-08-31` | Query only | `{"date":"2026-08-31","timezone":"Asia/Shanghai","rule":{"rule_id":"att_rule_default","name":"默认考勤规则","workday":true,"location_required":false,"wifi_required":false,"allow_outside_checkin":true,"appeal_allowed":true},"shifts":[{"shift_id":"shift_default","name":"默认班次","checkin_start":"08:30","work_start":"09:00","work_end":"18:00","checkout_end":"20:00"}],"allowed_actions":["check_in","check_out"],"today_record":{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"checked_in","checkins":[]},"permissions":{"can_checkin":true,"can_appeal":true,"can_view_managed":true}}` |
|
||||
| `GET /v1/im/attendance/records?scope=mine&from=2026-08-01&to=2026-08-31&cursor=` | Query only | `{"items":[{"record_id":"evt_...","user_id":"usr_me","date":"2026-08-31","rule_id":"att_rule_default","shift_id":"shift_default","status":"normal","checkins":[{"checkin_id":"attc_...","action":"check_in","client_time":"2026-08-31T00:55:00Z","server_time":"2026-08-31T00:55:02Z","result":"normal","location":{"name":"上海办公室","distance_meters":38},"wifi":{"ssid":"Office-WiFi"},"device":{"platform":"ios"}}],"exceptions":[],"appeal_status":"","version":3}],"next_cursor":"","has_more":false,"statistics":{"expected_count":1,"checked_count":1,"exception_count":0,"not_checked_count":0},"permissions":{"can_checkin":true,"can_view_managed":true,"can_appeal":true}}` |
|
||||
| `GET /v1/im/attendance/records?scope=managed&department_id=dep_1&date=2026-08-31&status=late&cursor=` | Query only | `{"items":[{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"late","exceptions":["late"],"appeal_status":"pending","version":2}],"next_cursor":"","has_more":false,"statistics":{"expected_count":20,"checked_count":19,"exception_count":2,"not_checked_count":1}}` |
|
||||
| `POST /v1/im/attendance/checkins` | Header `Idempotency-Key`;Body `{"date":"2026-08-31","action":"check_in","rule_id":"att_rule_default","shift_id":"shift_default","client_time":"2026-08-31T08:55:00+08:00","location":{"latitude":31.2304,"longitude":121.4737,"accuracy_meters":30},"wifi":{"ssid":"Office-WiFi","bssid":"00:11:22:33:44:55"},"device":{"device_id":"ios-device-1","platform":"ios"}}` | `{"record":{"record_id":"evt_...","user_id":"usr...","date":"2026-08-31","status":"checked_in","version":2,"checkins":[{"checkin_id":"attc_...","action":"check_in","result":"normal"}]},"checkin":{"checkin_id":"attc_...","action":"check_in","client_time":"2026-08-31T00:55:00Z","server_time":"2026-08-31T00:55:02Z","result":"normal","location":{"latitude":31.2304,"longitude":121.4737},"wifi":{"ssid":"Office-WiFi"},"device":{"platform":"ios"}}}` |
|
||||
| `POST /v1/im/attendance/appeals` | Header `Idempotency-Key`;Body `{"record_id":"evt_...","appeal_type":"missing_checkin","reason":"外出拜访客户,忘记打卡","target_action":"check_in","target_time":"2026-08-31T09:00:00+08:00","attachment_file_ids":["file_1"]}` | `{"appeal_id":"atta_...","record_id":"evt_...","user_id":"usr...","appeal_type":"missing_checkin","reason":"外出拜访客户,忘记打卡","target_action":"check_in","target_time":"2026-08-31T01:00:00Z","attachment_file_ids":["file_1"],"status":"pending","submitted_at":"2026-08-31T03:00:00Z","version":1}` |
|
||||
| `GET /v1/im/attendance/appeals?scope=mine&status=pending&cursor=` | Query only | `{"items":[{"appeal_id":"atta_...","record_id":"evt_...","user_id":"usr...","appeal_type":"missing_checkin","status":"pending","submitted_at":"2026-08-31T03:00:00Z","version":1}],"next_cursor":"","has_more":false}` |
|
||||
| `GET /v1/im/meetings?cursor=` 待补列表 | Query only | `{"items":[{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","my_response":"pending","state":"scheduled"}],"next_cursor":""}` |
|
||||
| `POST /v1/im/meetings` | `{"title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","attendee_user_ids":["usr..."],"reminder_minutes":[10]}` | `{"meeting_id":"mtg_1","title":"周会","start_at":"2026-08-21T10:00:00Z","end_at":"2026-08-21T10:30:00Z","location":"线上","participants":[{"user_id":"usr_me","response":"accepted"},{"user_id":"usr...","response":"pending"}],"permissions":{"can_update":true,"can_cancel":true,"can_remind":true,"can_respond":false},"version":1}` |
|
||||
| `GET /v1/im/meetings/{meeting_id}` | Path meeting ID | `{"meeting_id":"mtg_1","title":"周会","participants":[{"user_id":"usr...","response":"pending"}],"permissions":{"can_respond":true},"version":1}` |
|
||||
|
||||
+120
-4
@@ -818,6 +818,113 @@ CREATE TABLE IF NOT EXISTS tinode.im_feedback_submissions (
|
||||
INDEX idx_im_feedback_submissions_user (user_id, id DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
-- ============================================================
|
||||
-- Attendance.
|
||||
-- ============================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tinode.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)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tinode.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)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tinode.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)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tinode.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)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tinode.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)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
|
||||
|
||||
-- ============================================================
|
||||
-- Seed data.
|
||||
-- ============================================================
|
||||
@@ -837,10 +944,11 @@ ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP(3);
|
||||
INSERT INTO tinode.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),
|
||||
@@ -848,6 +956,14 @@ ON DUPLICATE KEY UPDATE
|
||||
unread_key = VALUES(unread_key),
|
||||
updated_at = CURRENT_TIMESTAMP(3);
|
||||
|
||||
INSERT INTO tinode.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;
|
||||
|
||||
INSERT INTO tinode.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;
|
||||
|
||||
INSERT INTO tinode.im_mailbox_config(id, enabled, allow_anonymous, required_fields, max_length, attachment_policy, notice_title, notice_content)
|
||||
VALUES (1, 1, 1, '["subject","content"]', 5000, '{"max_files":5,"purpose":"executive_mailbox"}', '总经理信箱', '请如实填写诉求,匿名提交将按企业政策保护展示身份。')
|
||||
ON DUPLICATE KEY UPDATE updated_at = updated_at;
|
||||
|
||||
@@ -404,6 +404,11 @@ type TenantBusinessAdapter interface {
|
||||
TenantOrganizationMembers(t.TenantID, t.Uid, string, string, int) ([]t.OrganizationMember, string, error)
|
||||
TenantOrganizationMembersAll(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationMember, string, error)
|
||||
TenantOrganizationSearch(t.TenantID, t.Uid, string, string, string, int) ([]t.OrganizationSearchItem, string, error)
|
||||
TenantAttendanceConfig(t.TenantID, t.Uid, time.Time) (*t.AttendanceConfig, error)
|
||||
TenantAttendanceRecordList(t.TenantID, t.Uid, t.AttendanceRecordListOptions) ([]t.AttendanceRecord, string, *t.AttendanceRecordStats, error)
|
||||
TenantAttendanceCheckin(t.TenantID, t.Uid, string, t.AttendanceCheckinRequest) (*t.AttendanceRecord, *t.AttendanceCheckin, error)
|
||||
TenantAttendanceAppealCreate(t.TenantID, t.Uid, *t.AttendanceAppeal, string) (*t.AttendanceAppeal, error)
|
||||
TenantAttendanceAppealList(t.TenantID, t.Uid, t.AttendanceAppealListOptions) ([]t.AttendanceAppeal, string, error)
|
||||
TenantMeetingCreate(t.TenantID, t.Uid, *t.Meeting, []t.Uid) (*t.Meeting, error)
|
||||
TenantMeetingList(t.TenantID, t.Uid, string, int) ([]t.Meeting, string, error)
|
||||
TenantMeetingGet(t.TenantID, t.Uid, string) (*t.Meeting, error)
|
||||
|
||||
+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
|
||||
|
||||
+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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -825,6 +825,10 @@ func main() {
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/organization/departments/", serveIMOrganizationDepartmentResource)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/organization/members", serveIMOrganizationMembers)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/organization/search", serveIMOrganizationSearch)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/attendance/config", serveIMAttendanceConfig)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/attendance/records", serveIMAttendanceRecords)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/attendance/checkins", serveIMAttendanceCheckins)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/attendance/appeals", serveIMAttendanceAppeals)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/meetings", serveIMMeetings)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/meetings/", serveIMMeetingResource)
|
||||
mux.HandleFunc(config.ApiPath+"v1/im/reports/abuse/reasons", serveIMAbuseReasons)
|
||||
|
||||
@@ -2125,6 +2125,102 @@ func (organizationMapper) Search(tenantID types.TenantID, uid types.Uid, queryTe
|
||||
return tenantAdp.TenantOrganizationSearch(tenantID, uid, strings.TrimSpace(queryText), strings.TrimSpace(resultTypes), strings.TrimSpace(cursor), limit)
|
||||
}
|
||||
|
||||
type AttendancePersistenceInterface interface {
|
||||
Config(tenantID types.TenantID, uid types.Uid, date time.Time) (*types.AttendanceConfig, error)
|
||||
ListRecords(tenantID types.TenantID, uid types.Uid, opts types.AttendanceRecordListOptions) ([]types.AttendanceRecord, string, *types.AttendanceRecordStats, error)
|
||||
Checkin(tenantID types.TenantID, uid types.Uid, checkinID string, req types.AttendanceCheckinRequest) (*types.AttendanceRecord, *types.AttendanceCheckin, error)
|
||||
CreateAppeal(tenantID types.TenantID, uid types.Uid, appeal *types.AttendanceAppeal, idempotencyKey string) (*types.AttendanceAppeal, error)
|
||||
ListAppeals(tenantID types.TenantID, uid types.Uid, opts types.AttendanceAppealListOptions) ([]types.AttendanceAppeal, string, error)
|
||||
}
|
||||
|
||||
type attendanceMapper struct{}
|
||||
|
||||
var Attendance AttendancePersistenceInterface
|
||||
|
||||
func (attendanceMapper) Config(tenantID types.TenantID, uid types.Uid, date time.Time) (*types.AttendanceConfig, error) {
|
||||
if uid.IsZero() || date.IsZero() {
|
||||
return nil, types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantAttendanceConfig(tenantID, uid, date)
|
||||
}
|
||||
|
||||
func (attendanceMapper) ListRecords(tenantID types.TenantID, uid types.Uid, opts types.AttendanceRecordListOptions) ([]types.AttendanceRecord, string, *types.AttendanceRecordStats, error) {
|
||||
if uid.IsZero() {
|
||||
return nil, "", nil, types.ErrMalformed
|
||||
}
|
||||
if opts.Limit <= 0 || opts.Limit > 100 {
|
||||
opts.Limit = 50
|
||||
}
|
||||
opts.Scope = strings.TrimSpace(opts.Scope)
|
||||
if opts.Scope == "" {
|
||||
opts.Scope = "mine"
|
||||
}
|
||||
if opts.Scope != "mine" && opts.Scope != "managed" {
|
||||
return nil, "", nil, types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, "", nil, err
|
||||
}
|
||||
return tenantAdp.TenantAttendanceRecordList(tenantID, uid, opts)
|
||||
}
|
||||
|
||||
func (attendanceMapper) Checkin(tenantID types.TenantID, uid types.Uid, checkinID string, req types.AttendanceCheckinRequest) (*types.AttendanceRecord, *types.AttendanceCheckin, error) {
|
||||
if uid.IsZero() || strings.TrimSpace(checkinID) == "" || req.Date.IsZero() || req.ClientTime.IsZero() || !validAttendanceAction(req.Action) {
|
||||
return nil, nil, types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return tenantAdp.TenantAttendanceCheckin(tenantID, uid, strings.TrimSpace(checkinID), req)
|
||||
}
|
||||
|
||||
func (attendanceMapper) CreateAppeal(tenantID types.TenantID, uid types.Uid, appeal *types.AttendanceAppeal, idempotencyKey string) (*types.AttendanceAppeal, error) {
|
||||
if uid.IsZero() || appeal == nil || strings.TrimSpace(appeal.AppealID) == "" || strings.TrimSpace(appeal.RecordID) == "" || strings.TrimSpace(appeal.Reason) == "" {
|
||||
return nil, types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tenantAdp.TenantAttendanceAppealCreate(tenantID, uid, appeal, strings.TrimSpace(idempotencyKey))
|
||||
}
|
||||
|
||||
func (attendanceMapper) ListAppeals(tenantID types.TenantID, uid types.Uid, opts types.AttendanceAppealListOptions) ([]types.AttendanceAppeal, string, error) {
|
||||
if uid.IsZero() {
|
||||
return nil, "", types.ErrMalformed
|
||||
}
|
||||
if opts.Limit <= 0 || opts.Limit > 100 {
|
||||
opts.Limit = 50
|
||||
}
|
||||
opts.Scope = strings.TrimSpace(opts.Scope)
|
||||
if opts.Scope == "" {
|
||||
opts.Scope = "mine"
|
||||
}
|
||||
if opts.Scope != "mine" && opts.Scope != "managed" {
|
||||
return nil, "", types.ErrMalformed
|
||||
}
|
||||
tenantAdp, err := tenantBusinessAdapter(tenantID)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return tenantAdp.TenantAttendanceAppealList(tenantID, uid, opts)
|
||||
}
|
||||
|
||||
func validAttendanceAction(action string) bool {
|
||||
switch strings.TrimSpace(action) {
|
||||
case types.AttendanceActionCheckIn, types.AttendanceActionCheckOut:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type MeetingsPersistenceInterface interface {
|
||||
Create(tenantID types.TenantID, uid types.Uid, meeting *types.Meeting, attendeeIDs []types.Uid) (*types.Meeting, error)
|
||||
List(tenantID types.TenantID, uid types.Uid, cursor string, limit int) ([]types.Meeting, string, error)
|
||||
@@ -4490,6 +4586,7 @@ func init() {
|
||||
GroupBusiness = groupBusinessMapper{}
|
||||
Workbench = workbenchMapper{}
|
||||
Organization = organizationMapper{}
|
||||
Attendance = attendanceMapper{}
|
||||
Meetings = meetingsMapper{}
|
||||
DailyReports = dailyReportsMapper{}
|
||||
MonthlyReports = monthlyReportsMapper{}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
AttendanceActionCheckIn = "check_in"
|
||||
AttendanceActionCheckOut = "check_out"
|
||||
|
||||
AttendanceStatusNotChecked = "not_checked"
|
||||
AttendanceStatusCheckedIn = "checked_in"
|
||||
AttendanceStatusNormal = "normal"
|
||||
AttendanceStatusLate = "late"
|
||||
AttendanceStatusEarlyLeave = "early_leave"
|
||||
AttendanceStatusAbnormal = "abnormal"
|
||||
|
||||
AttendanceCheckinNormal = "normal"
|
||||
AttendanceCheckinLate = "late"
|
||||
AttendanceCheckinEarlyLeave = "early_leave"
|
||||
AttendanceCheckinOutsideRange = "outside_range"
|
||||
|
||||
AttendanceAppealPending = "pending"
|
||||
AttendanceAppealApproved = "approved"
|
||||
AttendanceAppealRejected = "rejected"
|
||||
AttendanceAppealCanceled = "canceled"
|
||||
)
|
||||
|
||||
type AttendanceRule struct {
|
||||
ID int64
|
||||
RuleID string
|
||||
Name string
|
||||
Workday bool
|
||||
LocationRequired bool
|
||||
WifiRequired bool
|
||||
AllowOutsideCheckin bool
|
||||
AppealAllowed bool
|
||||
AppealDeadline *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AttendanceShift struct {
|
||||
ID int64
|
||||
ShiftID string
|
||||
RuleID string
|
||||
Name string
|
||||
WorkStart string
|
||||
WorkEnd string
|
||||
CheckinStart string
|
||||
CheckoutEnd string
|
||||
SortOrder int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AttendanceCheckin struct {
|
||||
ID int64
|
||||
CheckinID string
|
||||
RecordID string
|
||||
UserID Uid
|
||||
Date time.Time
|
||||
Action string
|
||||
RuleID string
|
||||
ShiftID string
|
||||
ClientTime time.Time
|
||||
ServerTime time.Time
|
||||
Result string
|
||||
Location KVMap
|
||||
Wifi KVMap
|
||||
Device KVMap
|
||||
IdempotencyKey string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type AttendanceRecord struct {
|
||||
ID int64
|
||||
RecordID string
|
||||
UserID Uid
|
||||
Date time.Time
|
||||
RuleID string
|
||||
ShiftID string
|
||||
Status string
|
||||
Checkins []AttendanceCheckin
|
||||
Exceptions []string
|
||||
AppealStatus string
|
||||
Version int
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AttendanceConfig struct {
|
||||
Date time.Time
|
||||
Timezone string
|
||||
Rule AttendanceRule
|
||||
Shifts []AttendanceShift
|
||||
AllowedActions []string
|
||||
TodayRecord *AttendanceRecord
|
||||
CanViewManaged bool
|
||||
}
|
||||
|
||||
type AttendanceRecordListOptions struct {
|
||||
Scope string
|
||||
From string
|
||||
To string
|
||||
Date string
|
||||
Status string
|
||||
DepartmentID string
|
||||
UserID string
|
||||
Cursor string
|
||||
Limit int
|
||||
}
|
||||
|
||||
type AttendanceRecordStats struct {
|
||||
ExpectedCount int
|
||||
CheckedCount int
|
||||
ExceptionCount int
|
||||
NotCheckedCount int
|
||||
}
|
||||
|
||||
type AttendanceCheckinRequest struct {
|
||||
Date time.Time
|
||||
Action string
|
||||
RuleID string
|
||||
ShiftID string
|
||||
ClientTime time.Time
|
||||
Location KVMap
|
||||
Wifi KVMap
|
||||
Device KVMap
|
||||
IdempotencyKey string
|
||||
}
|
||||
|
||||
type AttendanceAppeal struct {
|
||||
ID int64
|
||||
AppealID string
|
||||
RecordID string
|
||||
UserID Uid
|
||||
AppealType string
|
||||
Reason string
|
||||
TargetAction string
|
||||
TargetTime *time.Time
|
||||
AttachmentFileIDs []string
|
||||
Status string
|
||||
Version int
|
||||
SubmittedAt time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type AttendanceAppealListOptions struct {
|
||||
Scope string
|
||||
Status string
|
||||
Cursor string
|
||||
Limit int
|
||||
}
|
||||
Reference in New Issue
Block a user