简介:这是面向企业、机关单位日常办公场景的微信小程序审批系统,覆盖请假、报销、外出、合同、采购、入职等常见审批流程,并按管理员、员工、审批人员三类角色划分权限,审批结果可通过微信消息实时通知申请者。整个资源包共489个文件,压缩后约2.12MB,以JS逻辑文件、WXML页面结构、WXSS样式及JSON配置为主,同时附带PNG/JPG图片素材、安装使用手册和若干工具脚本,便于直接导入微信开发者工具查看运行效果。目前已有47人学习下载,适合需要快速搭建内部审批应用或进行小程序开发练习的开发者。资源中除完整前端页面外,还包含数据库操作工具、页面辅助函数、图表库及二维码库等模块,目录结构清晰,对照操作手册即可理解项目组织方式,能有效缩短二次开发与部署上手的成本。
1. 企业办公审批小程序:把请假、报销、采购审批搬上线,先解决两件事
某公司行政在月底核对报销时,发现一张差旅单在两位负责人之间来回传送了一周,原因仅仅是某人在午休前点了“已读”但没点“通过”。这种场景几乎每家单位都有:请假靠口头、报销靠截图、合同审批靠邮件往返。企业办公审批小程序要解决的,就是把请假审批、报销审批、外出审批、合同审批、采购审批这些流程从聊天记录和纸质单据里抽出来,变成一份有状态、有节点、有留痕的数字单据。适合正在做内部系统建设的企业IT同事,也适合接定制交付的开发团队。这篇文章会直接讲清楚怎么建模流程、怎么设计数据表、怎么写审批引擎,以及最容易翻车的五个地方。
2. 审批流建模:先定状态机,再定节点,最后用 YAML 描述规则
2.1 审批流不是一张流程表,而是一个状态机
很多第一次做审批系统的人会把流程设计成线性表格:发起人 → 部门主管 → 财务 → 结束。这样画流程图没问题,但落地到系统里会立刻遇到三个现实问题:审批人驳回后单子回到哪、发起人能不能撤回、转交之后当前节点怎么算。如果不先把状态定义清楚,代码迟早绕成一团。
我一般会先把全局状态定死:审批中、已通过、已驳回、已撤回、已作废。再给每个审批节点定义局部状态:待处理、已通过、已驳回、已转交。全局状态和节点状态分开记录,因为一个审批单可能卡在第二个节点,但整体状态仍然是审批中。驳回不是把整个单子作废,而是回到发起人可修改的节点,改完重新提交再进入审批流。撤回只能发生在审批中的单据上,而且要判断当前是否已经有人处理过,否则会产生并发冲突。
这里的核心是区分“单据状态”和“节点状态”。单据状态描述整条审批的生死,节点状态描述某一个环节的进度。后端存储时用一张表单主表记单据状态,用一张审批记录表记节点状态,后续做列表筛选、超时提醒、统计报表都靠这两个维度。
2.2 节点类型:单人审批、会签、或签、条件节点
业务流程里常见的审批节点可以分成四种类型,我在实际建模时基本只用这四种,多了容易把配置搞复杂。
| 节点类型 | 处理方式 | 典型场景 |
|---|---|---|
| 单人审批 | 指定一个审批人,通过即进入下一节点 | 部门主管审批请假 |
| 或签 | 多个审批人中任意一人通过即通过 | 外出审批、小额报销 |
| 会签 | 多个审批人全部通过才通过 | 合同审批、采购审批 |
| 条件节点 | 按表单字段值路由到不同分支 | 按请假天数、报销金额分流 |
会签和或签是翻车重灾区,后面避坑章会细说。条件节点我建议写在每个业务表单里,而不是写死在代码里。比如请假审批按天数分流,报销审批按金额分流,合同审批按合同类型和金额组合分流,采购审批按预算和供应商类型分流。条件规则用表达式字符串存起来,运行时解析,这样业务人员改规则不用改代码。
2.3 用 YAML 描述一条请假审批流
建模我用 YAML 做流程定义,比直接在数据库里建几十张流程表更直观,也方便版本管理。下面是一条带条件分支的请假审批流。
workflow: leave_approval name: 请假审批 version: 1.0 start: condition_days nodes: - id: condition_days type: condition conditions: - expr: "leave_days <= 3" next: manager_approve - expr: "leave_days > 3" next: manager_approve_and_hr - id: manager_approve type: approval assignee: "${department_manager}" action: or reject_to: start next: end - id: manager_approve_and_hr type: approval assignee: ["${department_manager}", "hr_manager"] action: and reject_to: start next: end - id: end type: end这个 YAML 文件的执行逻辑:发起人提交请假单后进入条件节点,系统读取表单里的 leave_days 字段,小于等于 3 天走单人审批,超过 3 天走会签。${department_manager} 是占位符,运行时从组织架构接口解析成具体员工ID,避免把审批人写死在流程定义里。action 为 or 表示或签,为 and 表示会签。reject_to 指向 start,表示驳回后回到发起人节点,发起人修改后重新提交。
把这个 YAML 转换成引擎可执行的 DAG 也很简单:遍历 nodes,根据 next 字段建立邻接关系,形成一个有向无环图。条件节点在运行时根据表单值选择唯一出口,审批节点根据 action 决定等待策略。流程定义和业务数据分离之后,新增一种审批类型只需要加一个 YAML 文件。
2.4 条件分支的顺序与规则写法
配置条件节点时最容易忽视的是顺序问题。以报销审批为例,如果先写“金额小于等于 5000 走财务初审”,再写“金额大于 5000 走财务总监”,看起来没错。但一旦金额字段为空或者填成了字符串,两条表达式都会匹配失败,流程直接跑挂。所以每条条件我都会配置一个 fallback 节点,表达式匹配不到时默认走 fallback。另外条件判断的顺序是从上到下短路执行,匹配到第一条就不再往下看,所以要把更严格、更具体的条件写在前面。比如金额小于 5000 且属于差旅费,就应该放在普通小额报销的前面。
3. 表单与数据层设计:一张主表存公共字段,一张 JSON 存差异字段
3.1 请假、报销、外出、合同、采购五类单据的字段拆分
标题里列的几类审批单,字段差异很大,但公共部分其实只有发起人、部门、时间、状态、当前节点。我把每类单据的差异字段单独列一张表,设计的时候照着填就行。
| 单据类型 | 差异字段 |
|---|---|
| 请假审批 | 开始时间、结束时间、请假天数、请假类型(年假/病假/事假)、事由、紧急联系人 |
| 报销审批 | 费用类型、报销金额、发生日期、发票号数组、收款账户、报销说明 |
| 外出审批 | 外出开始/结束时间、目的地、同行人、交通方式、外出事由 |
| 合同审批 | 合同名称、合同类型、甲方、乙方、合同金额、签订日期、生效日期、合同附件URL |
| 采购审批 | 采购物品清单、预算金额、供应商、收货地址、期望到货日期、采购事由 |
字段拆完之后你会发现,请假和外出几乎是同一套结构,只是业务含义不同。合同和采购都有金额和供应商,但合同还牵扯附件和签署日期。用 JSON 存差异字段是合理的,不要为每一类单据建一张完整的大宽表,否则加一个字段就要改表结构,后期维护成本很高。
3.2 数据表设计:form 主表加审批记录表
我常用的表结构是两张核心表加一张流程定义表。流程定义表存 YAML 内容,另外两张表负责运行时数据。
CREATE TABLE approval_form ( id BIGINT PRIMARY KEY AUTO_INCREMENT, form_no VARCHAR(32) NOT NULL COMMENT '单据编号', form_type TINYINT NOT NULL COMMENT '1请假 2报销 3外出 4合同 5采购', applicant_id BIGINT NOT NULL COMMENT '发起人', dept_id BIGINT COMMENT '所属部门', current_node_id VARCHAR(64) COMMENT '当前节点ID', status TINYINT NOT NULL DEFAULT 0 COMMENT '0审批中 1已通过 2已驳回 3已撤回', form_data JSON NOT NULL COMMENT '差异字段', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_status (status), KEY idx_applicant (applicant_id) ); CREATE TABLE approval_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, form_id BIGINT NOT NULL COMMENT '表单ID', node_id VARCHAR(64) NOT NULL COMMENT '节点ID', approver_id BIGINT NOT NULL COMMENT '处理人', action VARCHAR(16) NOT NULL COMMENT 'approve/reject/transfer', comment TEXT COMMENT '审批意见', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_form (form_id) );form_data 字段用 JSON 类型,请假单存 leave_days、start_time、end_time,报销单存 amount、invoice_list、receiver_account。查询时如果只需要按申请人或者状态筛选,走主表普通索引就够了。如果业务上有“按报销金额范围筛选”的需求,就把金额抽出到冗余列,尽量不要用 JSON 字段做范围查询,性能会很难看。
approval_record 表记录每一次审批动作,不只是通过和驳回,转交、退回、撤回我也都会写一条记录,方便事后追溯。这张表会随着审批量增长得很快,建议按月份归档或者分表,保留热数据一年。前端展示审批时间线时直接查这张表,按 created_at 排序即可。
3.3 审批详情页的数据组装逻辑
详情页是审批系统用得最多的页面,组装逻辑我放在一个函数里处理。
def get_approval_detail(form_id): form = load_form(form_id) workflow = load_workflow(form["form_type"]) records = load_records(form_id) node_map = build_node_map(workflow) return { "form": form, "timeline": build_timeline(records, node_map), "current_node": node_map.get(form["current_node_id"]), }load_form 只查主表,返回一条记录。load_workflow 按 form_type 找到对应的流程定义 YAML。load_records 查 approval_record 表,拿到这个单子的所有审批记录。build_node_map 把 YAML 解析成以节点ID为键的字典,这样时间线上的每一步都能知道当时处理的是哪个节点、当前卡在哪个节点。前端拿到这个结构,一屏渲染完,不需要再发多次请求。这里有个细节:审批记录里的 node_id 必须存节点ID,不要存节点名称,因为流程定义有版本升级的可能,节点ID不变,名称可能改。
4. 审批引擎与消息通知:最小实现代码与三层集成策略
4.1 审批引擎的最小实现:或签、会签与驳回
审批引擎是整个系统的心脏。我见过有人把审批逻辑写在 controllers 里,每个接口各自判断,最后流程一复杂就没人敢动。正确做法是抽出一个独立模块,输入是当前表单、动作、操作人,输出是更新后的表单和待通知的审批人。
def process_approval(workflow, form, action, operator, comment=""): node = get_node(workflow, form["current_node_id"]) if action == "reject": form["status"] = 2 # 已驳回 form["current_node_id"] = node.reject_to add_record(form["id"], node.id, operator, "reject", comment) notify_user(form["applicant_id"], "审批被驳回", comment) return form add_record(form["id"], node.id, operator, "approve", comment) if node.action == "or": form["current_node_id"] = node.next elif node.action == "and": approved_count = count_approved(form["id"], node.id) if approved_count < len(node.assignee_ids): notify_next_approver(node.assignee_ids, form) return form form["current_node_id"] = node.next if form["current_node_id"] == "end": form["status"] = 1 # 已通过 trigger_hook(form) notify_user(form["applicant_id"], "审批通过", "") else: next_node = get_node(workflow, form["current_node_id"]) notify_next_approver(next_node.assignee_ids, form) return form这个函数把核心逻辑压到了最简。reject 分支会把状态改成已驳回,把 current_node_id 回退到配置的节点,一般是 start。or 分支只要有一个审批人点击通过,当前节点立即完成,进入下一节点。and 分支每收到一个通过动作就统计当前节点已通过人数,全部通过才继续。这里有一个关键点:count_approved 统计的是 approval_record 表里 form_id 加 node_id 相同且 action 为 approve 的去重人数,同一节点同一人重复点击不能重复计数。
这个版本是单机跑的逻辑。生产环境要考虑并发,比如两个人同时点了同一个会签节点的通过,count_approved 可能出现脏读。常见做法是在 approval_record 表上建唯一约束,约束字段是 form_id、node_id、approver_id,重复插入直接报错,业务捕获异常提示“请勿重复审批”。状态机表的乐观锁也建议加上,更新时带上 status 原值做条件,更新行数为 0 说明状态已被其他请求修改。
4.2 消息通知与超时提醒:定时扫描加分级升级
审批系统是否好用,一半看消息通知是否及时。我的做法是事件驱动加定时兜底。事件驱动就是在 process_approval 里主动向下一步审批人推送待办;定时兜底则是扫描那些超过 24 小时没人处理的记录。
def scan_timeout_forms(): threshold = now() - timedelta(hours=24) pending_records = find_pending_records(threshold) for record in pending_records: remind(record.approver_id, record.form_id)超时策略一般分三级:24 小时未处理发普通提醒;48 小时未处理再次提醒并抄送发起人;72 小时未处理升级到管理员,由管理员转交或者人工介入。等级和时间阈值我建议做成配置项,不同公司节奏不一样,机关单位可能春节后会积压大量单据,固定死反而误事。
定时任务部署上,我习惯放一个独立服务,每小时跑一次。用系统 crontab 就行。
0 * * * * cd /opt/approval && python3 scan_timeout.py >> logs/scan.log 2>&1扫描逻辑只做两件事:查 approval_record 表里最新的处理时间,超过阈值就发消息。注意这里查的是“最新处理时间”,不是“创建时间”,因为一个人处理完之后,下一节点的人可能会拖很久,节点创建时间和处理时间差距很大。
4.3 与主流企业IM工作台集成的三条路:免登、H5、机器人
审批小程序往往不是孤立运行的,要嵌到企业办公平台上才有价值。我接触过三类集成方式,按控制力排序列举。
| 集成方式 | 控制力 | 数据存储 | 适用场景 |
|---|---|---|---|
| 复用平台自带审批模板 | 低 | 平台侧 | 中小企业,流程简单 |
| 自建H5接入工作台 | 高 | 自己数据库 | 需要合同、采购等多表单关联 |
| 自建H5加机器人待办推送 | 高 | 自己数据库 | 机关单位、数据不出内网 |
第一种方式不适合标题里提到的复杂场景。平台自带模板能建请假和报销,但合同审批要关联附件、采购审批要带明细列表,平台模板很难满足。第二种方式是用免登接口换取用户身份,审批页面完全自己开发,工作台只负责入口。第三种方式最灵活,纯自建,只借用工作台的机器人接口做消息触达,审批数据完全留在自己这边。
我一般建议:如果合同审批和采购审批是核心需求,直接走第三条路。前期成本高,但后期改流程、加统计、接财务系统都不用看平台脸色。机关单位往往也是这条路线,因为审批数据涉及内部敏感信息。
5. 办公审批小程序避坑指南:五个最常见的流程翻车现场
5.1 请假天数被自然日坑了:周五到周一算成了 4 天
现象:某个员工周五请假到下周一,系统自动算出 4 天,实际工作日只有 1 天。员工投诉,HR 手工改天数,审批流形同虚设。
原因:计算请假天数时直接用 end_date - start_date 按自然日相减,没有剔除周末和法定节假日。很多公司周五到周一这种情况非常普遍,年中调休、法定假日补班让纯函数计算根本算不准。
解决:建立一张节假日表,把周末、法定假日、调休补班都存进去。计算天数时遍历开始到结束每个自然日,如果当天是休息日就跳过,是工作日就加 1。补班日单独标记为工作日。这张表每年更新一次,数据库里存两列:date、is_workday。计算逻辑放在后端,不要放在前端,因为不同部门可能使用不同的假期规则。
5.2 审批人离职或转岗后流程空转
现象:一张采购审批单卡在已离职员工名下,管理员进入后台也找不到转交入口,最后只能把数据库里的 current_node_id 手工改掉。
原因:流程定义里把审批人写成了具体员工ID。员工离职或转岗后,这个ID在组织架构里已经失效,但流程定义里还挂在节点上。
解决:流程定义一律存角色占位符,比如部门主管、财务初审、合同管理员,运行时通过接口解析成具体员工。会签节点也一样,存角色列表,解析后得到员工列表。如果解析结果为空,直接走“该角色无匹配人员”的异常分支,通知管理员配置审批人,不要让它静默卡住。另外审批详情页一定要有转交功能,当前节点处理人可以把单子转给其他人,转交记录写进 approval_record。
5.3 会签误配成或签,合同单被单人直接通过
现象:合同审批本来要求法务、财务、总经理三人会签,结果法务一个人点通过,单子直接流转到下一步。
原因:建模时把节点 action 配成了 or。会签和或签在视觉上很像,配置界面如果不加说明,很容易选错。
解决:配置流程时,把“会签”和“或签”直接放在节点类型里做成单选,而不是让用户填写英文字段。同时做前置校验:会签节点必须配置至少两个审批人,或签节点允许配置一个或多个。我在代码里会把 or 的节点做成单独的标记,流程定义校验器检测到 or 节点只有一个人时直接报警。合同和采购这两类单据默认走会签,系统里做一个类型默认值,降低误配概率。
5.4 报销金额精度:浮点运算和汇率冻结
现象:报销单金额乘以税率后出现 0.009 的尾巴,或者有外币报销需求时,汇率按审批当天汇率换算,审批到一半汇率变了,最终金额对不上。
原因:金额字段用了 Float 存储,人民币换算外币时多次四舍五入。汇率如果从外部接口实时拉取,每次打开详情页金额都可能变化。
解决:金额一律用分存储,数据库字段类型用整数,展示时除以 100 格式化。涉及税率时先乘再加,不要在中间步骤做四舍五入。外币报销在发起表单时就冻结汇率,把汇率快照存进 form_data,审批过程中任何人打开详情页看到的都是同一个金额。财务付款时以冻结汇率为准,汇率波动风险由公司承担或走补充审批流程,这是业务决策,系统层面只保证一致性。
5.5 条件分支优先级写错,大额采购走了普通通道
现象:一笔 8 万元的采购审批单直接走了“小于等于 5 万走财务初审”的分支,财务初审通过了,总裁办完全不知情。
原因:条件节点的表达式写成了金额范围,但第一个条件只判断了“采购预算不为空”,没有约束金额上限。按短路顺序,这个宽泛条件先匹配成功,后面更严格的条件根本没机会执行。
解决:条件节点按优先级从严格到宽松排列,或者给每条条件加一个 priority 字段。每次保存流程定义时做静态检查:同一层级如果有两条条件表达式,对比它们的约束范围,如果前一条是后一条的超集,直接提示错误。条件表达式里我还会支持 whitelist 白名单,只有白名单内的字段才能参与条件判断,避免表单字段被随意引用。流程定义上线后做一次全量审批单模拟,用历史数据跑一遍,看有多少单子会走错分支,比上了线再发现强得多。
6. 进阶:用表达式和回调钩子让审批流支持自定义
6.1 把条件表达式配置化而不是写死在代码里
前面的 YAML 已经能做到条件分支,但表达式本身是字符串,需要一套安全的解析器。我只开放白名单函数和字段,不允许任意代码执行。比如金额判断:
def eval_condition(expr, form_data): if expr.startswith("amount >"): limit = float(expr.split(">")[1]) return form_data["amount"] > limit raise ValueError(f"unsupported expr: {expr}")这个函数看起来简单,但它指向一个原则:条件引擎只支持系统预设的几种比较操作,新增操作符要走代码评审,不能靠动态执行用户输入的字符串。
6.2 审批通过后的回调钩子
单据通过以后往往需要触发其他系统动作,这里我统一用一个回调钩子。
def trigger_hook(form): if form["form_type"] == "contract": write_contract_ledger(form) elif form["form_type"] == "purchase": create_purchase_order(form) elif form["form_type"] == "reimbursement": notify_finance_payment(form)审批引擎只负责流程状态机,不关心业务动作。通过之后要写合同台账、要创建采购订单、要通知财务付款,都挂在这个钩子里。钩子执行失败要记录日志并重试,不要影响审批结果的展示。我一般把钩子做成异步任务,审批通过状态先落库,任务队列慢慢执行。
做第一版审批系统时,我把业务动作直接写在 controller 里,结果每次加一种单据类型都要改动审批主流程,后来抽了回调钩子才解决。现在我的习惯是:任何通过之后的动作,一律走钩子,不在主流程里写具体的业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取