ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

工时管理系统设计:RBAC、双轨工时与月结锁实践

工时管理系统设计:RBAC、双轨工时与月结锁实践 简介这份《工时管理系统操作指南》面向企业内使用工时管理系统的各类人员尤其适合系统管理员、部门主管、PM、PL 及需要按网站和系统维度填报工时的一线员工。文档围绕「按网站、系统统计相关部门工时成本」这一目标梳理了系统管理员、超级管理员、管理员与普通用户四类角色的权限边界并给出工单操作流程图的说明帮助不同岗位快速定位自己该做什么、能做什么。正文重点展开个人数据维护、工时填写与报表查看三部分工时填写细分为「需求单工作」与「日常工作」涵盖了会议培训、出差、请假缺勤、主管管理、跨网站跨系统工作等具体场景的选类填写示例报表则区分我的工时报表与我的工单报表支持按需求单与工作类别统计工作量。常见问题部分集中回应了新用户添加、需求单状态不同步、跨月补填限制及每天八小时填报等实操疑问可作为日常填报的对照手册。资源为单个 doc 文档压缩包约 349KB内容紧凑、随用随查。目前已有 79 人学习。1. 工时管理系统操作指南里最容易被跳过的归集口径拿到《工时管理系统操作指南.doc》的人多半翻到第四部分照着点一遍就以为会了。真正让人返工的从来不是按钮在哪而是口径同样是开了两小时的会填在汽车网—报价库还是其它—会议/培训/文职月底按网站归集成本时差出去的是一整块预算。这套系统的目标很明确把人力成本按网站 × 系统两个维度摊开让设计部、制作部、技术部之间的协作账算得清。指南把角色切成系统管理员、超级管理员、管理员、普通用户四层规定了工单从新建到 close 的流转也定死了每天至少填满 8 小时这条硬规矩。下面不重复文档原文而是把那些只给结论、没给理由的规则拆成能落地的模型权限怎么映射成表、同一个小类为什么每天只能有一条、报表口径如何与月结对齐。刚接手这套系统做二次开发的工程师以及被推着催工时的 PM、PL都可以直接拿去用。2. 四类角色的 RBAC 映射与我的用户数据隔离2.1 权限表里的——和部分不是布尔值文档给出的是一张角色 × 功能的矩阵里面出现了三种取值有、——、部分。很多人第一反应是把它翻译成三个 boolean 字段存进数据库结果加一个角色就要改一轮代码分支。更贴近实际的做法是拆成权限点 作用域两段有对应ALL部分对应PARTIAL或SELF——表示该角色压根没有这个权限点直接不落库。从文档整理出来的等价形式大致是这样实际部署时以系统内的角色维护页为准功能点系统管理员超级管理员管理员普通用户角色维护有——————基础设置和修改有有————工单-新建有有部分——工单-指派有有有——工单-查看有有有有工时-填写有有有有工时-修改有有有——报表-查看有有有——报表-导出有有有——注意文档里超级管理员和系统管理员的差别主要体现在角色维护和基础设置上工单与工时部分两者几乎重合。做权限校验时不要把两者合并成同一个枚举值否则后面想单独收权就得改数据。把作用域独立出来以后权限表就变成一行行可插拔的记录。新增一个只读审计角色只需要往role_permission里插记录服务层代码一行不动。2.2 我的用户为什么必须是个人视图指南第一部分写得很清楚我的用户列表显示的是个人自定义数据不是供选择的系统数据点击添加后在弹窗里选已经添加过的不会再出现在弹窗里。这三条合起来说明它是一个带归属人的收藏关系表而不是组织架构的投影。-- 个人自定义的常用联系人/组员列表 CREATE TABLE my_user ( owner_id BIGINT NOT NULL COMMENT 归属人即当前登录用户, member_id BIGINT NOT NULL COMMENT 被收藏的系统用户, sort_no INT DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (owner_id, member_id), KEY idx_owner (owner_id) ) COMMENT 我的用户仅所有者可见;弹窗的候选集查询是这段逻辑的关键用子查询把已添加的人剔掉。-- 弹窗候选在系统用户里排除我已经加过的 SELECT u.id, u.real_name, u.dept_name FROM sys_user u WHERE u.status ACTIVE AND u.id NOT IN ( SELECT member_id FROM my_user WHERE owner_id :me ) ORDER BY u.dept_name, u.real_name;:me是当前登录用户 ID必须从会话里取不能从请求参数取否则越权就能读到别人的收藏列表。status ACTIVE这层过滤对应文档 FAQ 的第一条——如果同事在做my_user的弹窗里找不到先查sys_user里有没有这个人再查他的账号状态新入职员工没进系统得先找本部门超级管理员开通权限光加收藏是加不上的。2.3 服务层校验把作用域判断收口到一个函数权限点散落在各个接口里判断是这套系统后期最容易腐烂的地方。常见做法是收口成一个check函数把ALL / PARTIAL / SELF三种作用域在一个地方处理完。# 权限点 - 角色 - 作用域None 表示该角色没有这个权限点 PERMISSION_MATRIX { SYSTEM_ADMIN: {role:manage: ALL, setting:write: ALL, ticket:create: ALL, ticket:assign: ALL, worklog:write: ALL, report:export: ALL}, SUPER_ADMIN: {setting:write: ALL, ticket:create: ALL, ticket:assign: ALL, worklog:write: ALL, report:export: ALL}, MANAGER: {ticket:create: PARTIAL, ticket:assign: ALL, ticket:view: ALL, worklog:write: ALL, worklog:edit: PARTIAL, report:export: ALL}, USER: {ticket:view: ALL, worklog:write: SELF, worklog:edit: SELF}, } def check(user, perm, resourceNone): scope PERMISSION_MATRIX.get(user.role, {}).get(perm) if scope is None: # 角色没有这个权限点 raise PermissionDenied(perm) if scope ALL: # 不区分归属直接放行 return True if scope SELF: # 只能操作自己的数据 return resource is not None and resource.owner_id user.id if scope PARTIAL: # 只能操作自己参与的那部分 return resource is not None and user.id in resource.assignee_ids raise PermissionDenied(perm)user.role取值来自角色维护表不要硬编码成字符串常量散在各处resource.owner_id对工单来说是创建人对工时细项来说是填报人resource.assignee_ids是工单的指派列表PARTIAL之所以要单独一档就是因为文档里管理员对工单的部分权限指的是我参与的工单我才能改。三者混用会出现主管能改动别人工时、或者组员能越权指派的情况这两类问题在月底对账时才会暴露排查成本很高。3. 需求单工作与日常工作的双轨工时模型3.1 为什么两类工时放进同一张明细表指南把填写分成需求单工作和日常工作两块直觉上会想建两张表。但报表侧要按同一口径聚合——我的工时报表要把两类工时加在一起判断当天够不够 8 小时月结锁也要对两类行一起生效。分成两张表每个聚合查询都得写一次UNION ALL月结逻辑要写两遍漏一处就是数据不一致。我的做法是一张明细表加一个来源标记CREATE TABLE worklog_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL COMMENT 填报人, work_date DATE NOT NULL COMMENT 工作日期, source_type ENUM(DAILY,TICKET) NOT NULL COMMENT 日常/需求单, l1_id INT NOT NULL COMMENT 工时大类网站, l2_id INT NOT NULL COMMENT 工时小类系统或应用, ticket_id BIGINT NULL COMMENT source_typeTICKET 时必填, hours DECIMAL(4,1) NOT NULL DEFAULT 0 COMMENT 工时0.5 小时为最小粒度, remark VARCHAR(500) NULL, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_user_date (user_id, work_date), KEY idx_ticket (ticket_id) ) COMMENT 工时细项;source_type是区分两类工时的唯一依据ticket_id可空。代价是必须靠约束保证TICKET行一定有ticket_id——MySQL 8 可以用CHECK低版本就在服务层写入前校验一次。收益是报表侧一次GROUP BY就能把两类工时合并统计。3.2 大类小类的两级分类与其它的兜底规则文档里举了大量例子本质是在教人怎么把一件模糊的工作塞进网站 × 系统这两级坐标里。把例子摊开看规则其实很清晰场景工时大类工时小类备注要点汽车报价库两个会共 3 小时汽车网报价库两个会议主题写一起开发总监部署部门规划、团队建设其它主管管理管理性工作走这档视频中心同事出差北京拍车展汽车网视频中心出差按实际工作内容归类SEO 出差上海部署全网优化方案综合网站其它跨网站的工作请假一天其它缺勤备注写明请假亲子用品库优化会议亲子网用品库能判定网站就填具体网站全网编辑 CMS 新版操作培训综合网站CMS跨网站但系统明确游戏网年度 SEO 规划讨论会游戏网数据分析按会议所属网站归类部门周例会、新员工培训、整理通讯录其它会议/培训/文职判定不了网站时兜底规则可以压缩成四句话能判定到具体网站和系统的填具体值跨两个及以上网站但系统明确填综合网站 该系统同一网站跨多个系统填该网站 其他跨网站又跨系统才落到综合网站 其他。其它 会议/培训/文职是最后一层兜底只在确实判定不出归属时使用滥用会让按网站归集的成本表失真。提示需求方拉了一个全新项目的研讨会即使它不属于任何现有系统也不要填在综合系统下面。正确路径是由跟进协调人员在系统里录入暂定系统名称未立项的须标明再让参与人员按这个名字填。3.3 同小类每天一条的唯一约束怎么落地文档用汽车报价库的例子说明了累计逻辑同一天开了两个会一个 1 小时一个 2 小时不要建两条记录而是把小类选成报价库、工时填 3、备注里把两个会议主题都写上。也就是同一用户、同一日期、同一大类、同一小类只能存在一条日常工时记录第二次保存是累加而不是新增。MySQL 没有部分唯一索引但唯一索引允许多个 NULL用生成列正好能表达这个只对日常工时生效的约束ALTER TABLE worklog_item ADD COLUMN daily_uniq_key VARCHAR(80) GENERATED ALWAYS AS ( IF(source_type DAILY, CONCAT(user_id, :, work_date, :, l1_id, :, l2_id), NULL) ) STORED COMMENT 日常工时的唯一键需求单工时恒为 NULL, ADD UNIQUE KEY uk_daily_uniq (daily_uniq_key);source_type DAILY时生成形如1001:2024-06-18:3:27的键重复插入直接报唯一键冲突TICKET行生成 NULL不受约束同一天针对同一个需求单可以有多条。保存动作改成累加INSERT INTO worklog_item (user_id, work_date, source_type, l1_id, l2_id, hours, remark) VALUES (:uid, :date, DAILY, :l1, :l2, :hours, :remark) ON DUPLICATE KEY UPDATE hours hours VALUES(hours), remark CONCAT_WS(, remark, VALUES(remark));CONCAT_WS会自动跳过 NULL所以首次插入备注为空时不会留下一个孤零零的分号。:hours传的是本次增量而不是总时长这一点必须在 UI 上说明白否则用户以为在改总数实际在往上加。3.4 需求单工作列表的出现与消失文档 FAQ 里有两条相互关联的说明列表里只显示指派给我的、目前未完成的需求单我把自己的处理状态改成已完成之后它还在是因为同被指派的同事还没改完整个需求 close 之后才会消失。这里有两个独立字段必须分开存工单的整单状态ticket.status以及指派关系上的个人进度ticket_assignee.my_status。-- 我的需求单工作栏数据源 SELECT t.id, t.title, m.my_status, t.priority, t.due_date FROM ticket t JOIN ticket_assignee m ON m.ticket_id t.id AND m.user_id :me WHERE t.status CLOSED AND m.my_status DONE ORDER BY t.priority DESC, t.due_date ASC;整单 close 的触发条件是所有指派人的my_status都为DONE通常在状态更新后异步重算一次。t.status CLOSED这层条件必须保留否则会出现个别指派人进度没同步干净、已经 close 的单子又冒回列表的情况。4. 我的工时报表与我的工单报表的聚合口径4.1 每日工时合计与 8 小时缺口检查我的工时报表要解决两件事看自己或我的用户本月每天的工时总和及明细勾选只显示小于 8 小时的数据后把不达标的日期捞出来补填。这两个查询的差别只在一个HAVING。-- 1) 本月每日工时合计 SELECT work_date, SUM(hours) AS total_hours FROM worklog_item WHERE user_id :uid AND work_date :month_start AND work_date :next_month_start GROUP BY work_date ORDER BY work_date; -- 2) 只显示不足 8 小时的日期 SELECT work_date, SUM(hours) AS total_hours FROM worklog_item WHERE user_id :uid AND work_date :month_start AND work_date :next_month_start GROUP BY work_date HAVING SUM(hours) 8 ORDER BY work_date;过滤条件落在聚合结果上所以必须写HAVING写进WHERE会直接报错。日期用左闭右开区间 month_start AND next_month_start比BETWEEN更稳不用担心月末最后一秒的时间部分被截断。:uid的取值范围由权限决定普通用户只能是自己管理员以上可以在自己加my_user里选人此时把user_id :uid换成AND user_id IN (SELECT member_id FROM my_user WHERE owner_id :me)4.2 我的工单报表按需求单和按网站×系统两个口径我的工单报表分两个视角按需求单查自己在某个单子上的总工作量以及按工作类别、网站、系统查自己的总工作量。两个查询共用同一张明细表只是分组维度不同。-- 按需求单归集 SELECT w.ticket_id, t.title, SUM(w.hours) AS total_hours FROM worklog_item w JOIN ticket t ON t.id w.ticket_id WHERE w.user_id :uid AND w.source_type TICKET GROUP BY w.ticket_id, t.title; -- 按网站 × 系统归集附带小计 SELECT l1.name AS site, l2.name AS system, SUM(w.hours) AS total_hours FROM worklog_item w JOIN category l1 ON l1.id w.l1_id JOIN category l2 ON l2.id w.l2_id WHERE w.user_id :uid GROUP BY l1.name, l2.name WITH ROLLUP;WITH ROLLUP会在结果末尾追加小计行和总计行小计行的system为 NULL、总计行的site和system都为 NULL。前端渲染时要判断这两个字段是否为 NULL 来决定是否加粗不要直接当普通数据行铺出去否则用户会以为真的存在一个叫空字符串的系统。如果要做部门维度的成本归集再 join 用户表拿部门和人力单价即可SELECT d.name AS dept, l1.name AS site, SUM(w.hours) AS total_hours FROM worklog_item w JOIN sys_user u ON u.id w.user_id JOIN dept d ON d.id u.dept_id JOIN category l1 ON l1.id w.l1_id WHERE w.work_date :month_start AND w.work_date :next_month_start GROUP BY d.name, l1.name;这就是指南开篇那句按网站、系统统计相关部门工时成本落到 SQL 上的样子。4.3 月结锁与补填的边界文档写了两条看似矛盾的规则界面上只可补填本月工时、不可跨月填写FAQ 里又说上月若已月结就不能再改。前者是界面层的限制后者才是数据层的硬约束。两者叠加才解释得通为什么我明明选了上个月的日期却查不出数据。用一张月结表把数据层的锁落下来CREATE TABLE period_lock ( period CHAR(7) PRIMARY KEY COMMENT 账期形如 2024-06, locked_at DATETIME NOT NULL, locked_by BIGINT NOT NULL ); -- 写入前校验该日期所属账期是否已月结 SELECT 1 FROM period_lock WHERE period DATE_FORMAT(:work_date, %Y-%m);返回有记录就拒绝写入异常信息里带上账期和解锁联系人比一句操作失败有用得多。各场景的边界整理如下场景是否已月结能否补填处理方式本月任意工作日否能本人选择日期后查询、补充上月未月结否能本人补填上月已月结是不能联系超级管理员解锁账期需求单已 close 且有工时待补视账期不能直接填超级管理员把单据状态改回后再补已月结后又解锁否能补填完成后重新月结5. 排错与巡检月结锁、需求单 close 和跨系统归类现场问题大致能归到六类前五类文档 FAQ 都给了答案但没给排查路径。第一类加不上人先查my_user是否已存在该成员再查sys_user里有没有这条记录、status是否为ACTIVE新员工要等超级管理员开通权限。第二类我改完状态单子还在这不是 Bug是ticket_assignee.my_status还有别人没置为完成查一下指派表就知道卡在谁那里-- 谁还没把个人处理状态改成已完成 SELECT u.real_name, m.my_status, m.updated_at FROM ticket_assignee m JOIN sys_user u ON u.id m.user_id WHERE m.ticket_id :ticket_id AND m.my_status DONE;第三类单子 close 了还要补工时走超级管理员改状态这条路不要为了省事在数据库里手工插明细否则工单报表按需求单归集时会多出一条孤立记录。第四类想补上个月先查period_lock能解锁就走解锁流程不能解锁就认了——这也是为什么主管要在月末最后两个工作日跑一次缺口巡检。巡检用一条 SQL 就能覆盖本月所有工作日比逐个催人快得多-- 本月工作日缺口巡检列出部门内每天不足 8 小时的人 SELECT u.real_name, d.work_date, IFNULL(SUM(w.hours), 0) AS total_hours FROM sys_user u CROSS JOIN ( SELECT DATE_ADD(:month_start, INTERVAL seq DAY) AS work_date FROM seq_0_to_30 WHERE WEEKDAY(DATE_ADD(:month_start, INTERVAL seq DAY)) 5 ) d LEFT JOIN worklog_item w ON w.user_id u.id AND w.work_date d.work_date WHERE u.dept_id :dept_id AND u.status ACTIVE GROUP BY u.real_name, d.work_date HAVING total_hours 8 ORDER BY d.work_date, u.real_name;seq_0_to_30是 0 到 30 的整数序列表WEEKDAY(...) 5把周末排掉LEFT JOIN保证一天都没填的人也会出现在结果里IFNULL把这类人显示为 0 而不是被GROUP BY直接吞掉。第五类跨网站跨系统怎么填和第六类新项目没立项往哪填本质都是分类兜底规则被误用能落到具体网站就落具体网站跨网站就落综合网站加相关系统只有跨网站又跨系统的才落综合网站加其他未立项项目由协调人员先录暂定系统名。把这条规则写进填报页的字段提示里比发十遍通知都管用。本文还有配套的精品资源点击获取
返回列表