审批流转
模型起草的单据走同一条审批链,超时与缺席都不改单据状态
审批流转决定一张单从建单到批准走哪条路:沿建单时冻结的那一版链走,谁能签由签核位和签字人的资格决定。模型起草的单据走同一条审批链,没有为模型另开的通道。超时与缺席都不改单据状态,所以没人签时单会积压;一类单据如果延误的损失高于错放的损失,比如必须到点通过的单据,这套取舍不适合它。
链是一行带版本的数据
每种单据在单型注册表里占一行。单型即单据的类型,如采购申请、费用报销。
| 列 | 写什么 |
|---|---|
| 前缀 | 单据号前缀 |
| 可否人工建单 | 是否允许由人发起这类单 |
| 签核位序列 | 按签法版本分组的一串规则 |
| 金额线引用 | 指向金额档表的一行,只引用,不抄数 |
| 时限 | 等待签字的期限 |
| 写入步骤 | 批准后执行业务写入的那一步 |
签核位序列里,每条规则有三项:何时(一个判据名)、位序列或「回落到旧版本」、是否先审。最后一条必须是无条件的默认规则,所以每张单总能找到一条规则可走。
建单时,系统把签法版本和命中的金额档行一起冻结进单据。注册表之后追加的新版本,只影响新建的单。审计时拿出一张旧单,它的链和建单那天一样;反过来,紧急收紧规则时,在途单仍按旧链走完。
「何时」一栏只写判据名,判据的含义在解释器里。用已有判据重排位序列、换金额线,改的是数据,不改代码。注册表与金额档表随版本发布追加新行,旧行由触发器拒改拒删。新增一种判据,要给解释器加代码。所以改动前先问一句:这次用到新判据了吗?没有,就只是追加一行数据。
签核位的六种
签核位:链上需要一个人签字的位置。每个位有一个种类。
| 种类 | 规则 |
|---|---|
| 必签 | 该位必须有人签 |
| 先签 | 有先后:前置位签完,后面的位才轮到 |
| 加签 | 金额达到某条金额线,该位才加入链 |
| n 选 m | 一组人里任 m 人签即可,如任一、任二 |
| 先审 | 只审核内容;该位不可代签 |
| 可代 | 高一级的人可以签低一级的位 |
可代是位上的资格规则;代签是持有人缺席时的补位,见「缺席与代签」。核对一条链有没有缺位,对着这六种逐位看即可。
金额档表
单型只引用金额档表,不抄数。改一条线,所有引用它的单型一起变,不会有哪一处漏改。
| 列 | 含义 |
|---|---|
| 加签线 | 达到它,链上加入加签位 |
| 最高档线 | 达到它,链上加入最高层级的位 |
| 免审线 | 金额在这条线以内的单免审 |
| 累计线与窗口 | 在一个时间窗口内累计比较的金额线 |
| 每日免审单数上限 | 一天最多免审几张,超出即拒单 |
| 含 / 不含 | 金额恰好等于线时算不算达到,单独成列 |
含不含单独成列,因为边界上要有明确的答案。一张恰好 50,000 元的单走不走总经理,取决于比较写成 > 还是 >=。比较符写在程序里,这个决定藏在一行代码中,改它要发版,审计也看不到。写成一列,它就和金额线一起留在表里、一起有版本。
免审单数超过当日上限,系统直接拒单,不转成待审批单,审批人的待签列表里也不会多出一批本该免审的单。
下面一段 SQL 演示:金额存成整数的最小币种单位(分);含不含与金额线并排成列,线和比较符要么都填,要么都空。示意,未执行。
示例的 5,000 元与 50,000 元存成 500000 与 5000000 分,两条线都不含本数,所以恰好 50,000 元的单不加总经理位。填了线却没写含不含,这一行写不进表。
通则
通则不写进链:
- 发起人不签自己的单。
- 一人在一张单上只占一个位。唯一例外:最高档位与加签位可以由同一人一次满足。
- 有一份「不走审批」清单,清单上的事项不建审批单。
通则对所有单型生效,不必每条链各写一遍,也就没有哪条链会漏写。
一张示例单
示例:采购申请,金额 50,000 元,金额档取上一节的示例行。
| 位 | 种类 | 谁可签 | 状态 |
|---|---|---|---|
| 部门负责人 | 先签 | 该职位持有人,发起人本人除外 | 已签 |
| 财务负责人 | 加签,超过 5,000 元加入 | 该职位持有人,已在本单签过的人除外 | 开放,待签 |
| 总经理 | 加签,超过 50,000 元加入 | 无 | 恰好 50,000 元,不含本数,不在链上 |
假设财务负责人出差。这个位的代签向兜底职位开放,兜底人(事先按职位排好的代签人)亲自签,签核记录标「代签」,原因写「出差」。兜底职位上的人如果正是已签的部门负责人,「一人一位」会拦下此人,系统继续往下兜。兜到底无人可签,单据停在待签,并通知上层。

单据状态
图里只有签字和撤销能让单据离开待签,时间不能。
单据分三个阶段:open(待签,可裁决)、decided(已批准)、closed(已驳回或已作废)。状态本身是状态注册表里的行,加一种状态是加一行;注册表里恰有一个状态处在可裁决阶段。
- 已批准之后,状态与决审字段冻结,只许追加附签。
- 签核记录只追加,已签的内容不能改;单据不能删。
- 签核人单签驳回,单据即终态。驳回后重提,生成一张新单。
- 撤销业务单时,同一事务作废它挂着的审批单,已撤销的业务不会再获批执行。
状态转移由一支纯函数判定:输入前后两版单据,输出允许或一个错误码。纯函数:同样的输入得到同样的输出,不读外部状态。这支函数写在 PostgreSQL 里,守卫触发器只读状态注册表,再调用它。所以应用之外的连接写库,同样要过这道判定;能停用触发器的角色(表属主、超级用户)除外。
超时处理
时限到了,系统做下面这些事:
- 到点标红,并按配置的比例提前提示。
- 每天定时提醒仍可签的人,每人每单有次数上限;另给管理层发汇总。
- 不改单据状态:不自动批准,不自动驳回。
等满时限后,该位的代签向兜底人开放(见下一节),但签字仍要人。少数申请类单据另有规定:到期即失效,须重新申请。失效同样不算通过。
超时按失败安全(fail-closed)处理:系统缺少输入时,停在不通过的一侧。审批里最坏的结果是无人签字的单据通过,钱或货出去了,留痕里那一笔找不到签字人。积压看得见,有提醒和汇总追着;错放往往事后才发现。SZV 选积压。
缺席与代签
系统里没有「转交单据」的操作。持有人缺席时,开放代签位:
- 兜底表按职位排好:某个位的持有人不在,由哪个职位接。
- 三种情形触发,按单、按位分别计算:持有人全部请假、出差或停用;持有人在岗,但长时间未出现;该位等满时限。
- 兜底人仍须亲自签,仍受「一人一位」「发起人不自签」约束;先审位不可代签。
- 代签的开启与关闭都留痕,签核记录打「代签」标记。
- 兜到底无人可签,单据停在待签,并通知上层;单据本身不变。
每个位因此有一份开合状态:位、是否开放、可签的人、代签的人。列表页与详情页读同一份,两处显示不会不一致。
限制与不适用
- 积压:无人签的单一直停在待签。提醒、代签、汇总能缓解,消不掉。
- 在途单不追溯:改链只管新单,旧单按旧链走完。
- 一票驳回:单签驳回即终态。
- 新判据要发版:链是数据,判据的含义是代码。
- 延误损失高于错放损失的单据,比如必须到点通过的单据,不适用。
其他做法
| 做法 | 它解决什么 | 为什么不用 |
|---|---|---|
| 超时自动批准 | 单据不积压 | 出现无人签字就通过的单据,事后找不到负责的人 |
| 超时自动驳回 | 单据按期结束 | 驳回也是裁决,应当由人做出;积压转给发起人,重提一张新单,链从头再走 |
| 转交给临时指定的人 | 换人灵活 | 谁能签取决于转交人当时的选择,只能事后追问理由;兜底人由职位表算出,事前可查 |
与常见做法对照
Temporal 是一个开源的持久化执行框架,它把等待、计时器、升级和改派当作核心能力。它的文档审批指南里,工作流等待审批信号;时限到了,升级给备用联系人,再给一个时限;两人都不回应,自动驳回,状态记为 TIMED_OUT。
| 问题 | Temporal 审批示例 | SZV |
|---|---|---|
| 等待状态存在哪 | 工作流的事件历史,重启后重放恢复 | 单据状态与每个位的开合状态 |
| 时限到了 | 升级给备用人;仍无回应,自动驳回 | 提醒、汇总,并开放代签位;状态不变 |
| 换人 | 通知备用审批人 | 代签位,兜底人按职位表算出 |
| 事后核对 | 事件历史 | 只追加的签核记录与建单快照 |
两边在超时后都不批准,分歧在于要不要替人做出驳回。SZV 把等待状态存成单据上的数据,和规则表放在同一个 PostgreSQL 库里,由同一套触发器与纯函数守住。
验收时可以问的问题
- 请 SZV 演示:一个位的持有人全部请假后,代签位怎样开放、怎样关闭,签核记录里的「代签」标记在哪里。
- 兜底表由谁维护,「长时间未出现」按多久算。这两项按企业要求在合同中约定。