Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 73 additions & 7 deletions content/docs/build/automation/approvals.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,65 @@ The node declares **who approves** and how their decisions aggregate:
| Config | What it does |
|---|---|
| `approvers` | Who must decide — a named user, a position, or a user resolved from a record field (e.g. the submitter's manager) |
| `behavior` | How multiple approvers aggregate, e.g. `'unanimous'` |
| `behavior` | How multiple approvers aggregate: `'first_response'` (default), `'unanimous'`, `'quorum'`, `'per_group'` |
| `minApprovals` | Approvals required — total for `quorum` (M of N), per group for `per_group` (default 1) |
| `approvalStatusField` | Optional record field the plugin mirrors the request status into |
| `lockRecord` | Lock the record against edits while the request is pending |

> **Warning — `position` vs `role`.** `{ type: 'position', value: 'finance_manager' }`
> routes to the holders of a position (`sys_user_position`). The `role` approver
> type is the org-membership tier (`sys_member.role`: `owner`/`admin`/`member`) —
> a position name authored as `type: 'role'` matches nobody and the request
> stalls. `os lint` flags this (`approval-role-not-membership-tier`).
> **Warning — `position` vs the membership tier.**
> `{ type: 'position', value: 'finance_manager' }` routes to the holders of a
> position (`sys_user_position`). The org-membership tier (`sys_member.role`:
> `owner`/`admin`/`member`) is addressed as `type: 'org_membership_level'`
> since 16.0; the old spelling `role` is a **deprecated alias for one
> release** — it still loads and resolves identically, with a warning, and is
> removed in the next major. A position name authored as the membership-tier
> type matches nobody and the request stalls. `os lint` flags both cases
> (`approval-approver-not-membership-tier`,
> `approval-approver-type-deprecated`); if the value names an org position,
> the fix is `type: 'position'`.

### Quorum and per-group sign-off (16.0)

Beyond `first_response` and `unanimous`, 16.0 adds two aggregation modes.
**Quorum** finalizes on M-of-N approvals:

```ts
config: {
approvers: [
{ type: 'user', value: 'director_a' },
{ type: 'user', value: 'director_b' },
{ type: 'user', value: 'director_c' },
],
behavior: 'quorum',
minApprovals: 2, // any 2 of the 3 approve
}
```

**Per-group** requires a sign-off from *each* labeled group (会签) — label
approvers with `group`, and the node advances only once every group has
`minApprovals` approvals (default 1 per group):

```ts
config: {
approvers: [
{ type: 'position', value: 'legal_counsel', group: 'legal' },
{ type: 'position', value: 'finance_manager', group: 'finance' },
],
behavior: 'per_group', // one sign-off from legal AND one from finance
}
```

Semantics that hold across every mode:

- Approver sets are tallied against an **open-time snapshot** with
out-of-office substitution — who must respond is fixed when the request
opens, and OOO approvers are substituted rather than stalling it.
- A **single rejection is still a veto**: it finalizes the node as
`rejected` in every mode.
- Thresholds **clamp** at runtime to the resolvable approver count, so a
misconfigured `minApprovals` can never deadlock a request.
- Approvers without a `group` label each form their own group, so a plain
approver list still behaves sensibly under `per_group`.

## A complete approval flow

Expand Down Expand Up @@ -133,7 +183,23 @@ While a request is pending:
3. If you set `approvalStatusField`, the record's own field mirrors the request
status, so views and reports can filter on it.
4. The approver approves or rejects; the plugin resumes the paused flow down
the matching edge.
the matching edge. Since 16.0 a decision can carry **file attachments**
(persisted on `sys_approval_action.attachments`, threaded through the
decide/comment routes and the client SDK) — signed PDFs and evidence live
with the decision.
5. The request exposes a server-computed **`decision_progress`** —
approvals got vs. needed for `unanimous`/`quorum`, a per-group breakdown
for `per_group` — so the inbox and your own UIs render progress without
re-implementing the tally.

**Decisions are declared actions (16.0).** The full decision set — approve,
reject, reassign, send-back for revision (`/revise`), request-info, remind,
recall, resubmit — is declared as `type: 'api'` actions on
`sys_approval_request`, with typed params, pending-only visibility gates, and
submitter-vs-approver predicates (e.g. `record.submitter_id == ctx.user.id`).
The Console approvals inbox renders those declared actions instead of
hand-written buttons, so new decision capabilities ship as metadata — no
client release required.

Approving is itself a gated action. Model "may approve" as a capability (e.g.
`approve_invoice`) granted by the approver's permission set, and gate the
Expand Down
45 changes: 42 additions & 3 deletions content/docs/build/automation/approvals.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,47 @@ description: 把记录路由给人签核 —— 同时不让自动化悄悄绕
| 配置 | 作用 |
|---|---|
| `approvers` | 谁必须决策 —— 具名用户、岗位,或从记录字段解析出的用户(如提交人的经理) |
| `behavior` | 多个审批人如何聚合,如 `'unanimous'`(一致同意) |
| `behavior` | 多个审批人如何聚合:`'first_response'`(默认)、`'unanimous'`、`'quorum'`、`'per_group'` |
| `minApprovals` | 所需批准数 —— `quorum` 为总数(N 中取 M),`per_group` 为每组所需数(默认 1) |
| `approvalStatusField` | 可选的记录字段,插件把请求状态镜像进去 |
| `lockRecord` | 请求待定期间锁定记录、禁止编辑 |

> **警告 —— `position` vs `role`。**`{ type: 'position', value: 'finance_manager' }` 路由给某个岗位的持有者(`sys_user_position`)。而 `role` 审批人类型是组织成员层级(`sys_member.role`:`owner`/`admin`/`member`)—— 把岗位名写成 `type: 'role'` 谁也匹配不上,请求就会卡住。`os lint` 会标记这个问题(`approval-role-not-membership-tier`)。
> **警告 —— `position` 与成员层级。**`{ type: 'position', value: 'finance_manager' }` 路由给某个岗位的持有者(`sys_user_position`)。组织成员层级(`sys_member.role`:`owner`/`admin`/`member`)自 16.0 起写作 `type: 'org_membership_level'`;旧写法 `role` 是**保留一个版本的弃用别名** —— 仍能加载并按相同方式解析,但会给出警告,下个大版本移除。把岗位名写成成员层级类型谁也匹配不上,请求就会卡住。`os lint` 对两种情况都会标记(`approval-approver-not-membership-tier`、`approval-approver-type-deprecated`);如果值是岗位名,正确写法是 `type: 'position'`。

### 法定人数与会签(16.0)

在 `first_response` 和 `unanimous` 之外,16.0 新增两种聚合模式。**Quorum(法定人数)**在达到 N 中取 M 个批准时定案:

```ts
config: {
approvers: [
{ type: 'user', value: 'director_a' },
{ type: 'user', value: 'director_b' },
{ type: 'user', value: 'director_c' },
],
behavior: 'quorum',
minApprovals: 2, // 3 人中任意 2 人批准
}
```

**Per-group(会签)**要求*每个*带标签的组都签核 —— 用 `group` 给审批人打标签,只有当每个组都达到 `minApprovals` 个批准(默认每组 1 个)时节点才会前进:

```ts
config: {
approvers: [
{ type: 'position', value: 'legal_counsel', group: 'legal' },
{ type: 'position', value: 'finance_manager', group: 'finance' },
],
behavior: 'per_group', // 法务一个签核,且财务一个签核
}
```

对所有模式都成立的语义:

- 审批人集合按**开启时快照**统计,并带休假(OOO)替补 —— 谁必须响应在请求开启时就已确定,休假的审批人会被替补而不是卡住请求。
- **单个拒绝仍然是一票否决**:在任何模式下它都把节点定案为 `rejected`。
- 阈值在运行时**收拢(clamp)**到可解析的审批人数量,因此配置错误的 `minApprovals` 永远不会让请求死锁。
- 没有 `group` 标签的审批人各自成组,所以普通的审批人列表在 `per_group` 下依然行为合理。

## 一个完整的审批流程

Expand Down Expand Up @@ -114,7 +150,10 @@ export const opportunityApproval = defineFlow({
1. 插件持久化请求(`sys_approval_request`)以及针对它的每个决策(`sys_approval_action`)—— 你的审批历史是可查询的数据。
2. 设置 `lockRecord: true` 时,记录被锁定、禁止编辑,直到决策落地。
3. 如果设置了 `approvalStatusField`,记录自身的字段会镜像请求状态,视图和报表就能按它筛选。
4. 审批人批准或拒绝;插件沿匹配的边恢复被暂停的流程。
4. 审批人批准或拒绝;插件沿匹配的边恢复被暂停的流程。自 16.0 起,决策可以携带**文件附件**(持久化在 `sys_approval_action.attachments`,贯通决策/评论路由与客户端 SDK)—— 签署的 PDF 和证据材料与决策存放在一起。
5. 请求暴露服务端计算的 **`decision_progress`** —— `unanimous`/`quorum` 为已获批准数 vs. 所需数,`per_group` 为按组明细 —— 收件箱和你自己的 UI 直接渲染进度,无需重新实现计票。

**决策即声明式操作(16.0)。**完整的决策集合 —— 批准、拒绝、转办、退回修改(`/revise`)、补充材料、催办、撤回、重新提交 —— 以 `type: 'api'` 操作的形式声明在 `sys_approval_request` 上,带类型化参数、仅待定时可见的门控,以及提交人 vs 审批人谓词(如 `record.submitter_id == ctx.user.id`)。Console 审批收件箱渲染的是这些声明的操作,而不是手写按钮,因此新的决策能力以元数据形式发布 —— 无需客户端发版。

批准本身也是被门控的操作。把"可以批准"建模为一个能力(如 `approve_invoice`),由审批人的权限集授予,并把批准操作的 `requiredPermissions` 建立在它之上 —— 这样门控就在 **UI 和服务端**两侧同时强制,而不只是从屏幕上藏起来。

Expand Down
77 changes: 76 additions & 1 deletion content/docs/build/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,13 @@ export default defineStack({
});
```

## Three flow types
## Flow types

| Type | Triggered by | Use for |
|---|---|---|
| **Autolaunched** | A record change (insert/update/delete) | "Send welcome email when user registers" |
| **Scheduled** | Cron expression or interval | "Mark stale tasks every night at 2am" |
| **Time-relative** *(16.0)* | A date field's proximity to today, via a daily sweep | "Remind me 60 days before the contract ends" |
| **Manual** | User clicks a button in Console, or an API call | "Approve invoice" actions |

## Autolaunched: react to a record change
Expand Down Expand Up @@ -107,6 +108,80 @@ export const nightlyCleanup = defineFlow({
Backed by the `@objectstack/service-job` capability — see
[Runtime Capabilities](/docs/reference/runtime-capabilities).

## Time-relative: fire relative to a date field (16.0)

"Remind me 60 days before the contract ends" used to be authored as a
record-change flow gated on `record.end_date == daysFromNow(60)` — a
predicate that is only evaluated when the record *happens to change*, so
unattended it fires almost never. Don't write that. Declare a
**time-relative trigger** instead: the flow's start node carries a
`config.timeRelative` descriptor, and the runtime sweeps the object on a
schedule and launches the flow **once per matching record**:

```ts
export const renewalReminder = defineFlow({
name: 'contract_renewal_reminder',
type: 'schedule',
status: 'active',
nodes: [
{
id: 'start',
type: 'start',
config: {
timeRelative: {
object: 'contracts',
dateField: 'end_date',
offsetDays: [60, 30, 7], // T-minus thresholds
filter: { status: 'active' }, // AND-ed with the date window
},
// Optional sweep cadence — defaults to daily at 08:00 UTC.
schedule: { type: 'cron', expression: '0 8 * * *' },
},
},
{ id: 'notify_owner', type: 'notify', label: 'Notify Owner' },
{ id: 'end', type: 'end' },
],
edges: [
{ id: 'e1', source: 'start', target: 'notify_owner' },
{ id: 'e2', source: 'notify_owner', target: 'end' },
],
});
```

| Key | What it declares |
|---|---|
| `object` | Object (machine name) whose records the sweep queries |
| `dateField` | `date` / `datetime` field evaluated relative to today (day-granular) |
| `offsetDays` | **Offset mode** — fire when `dateField` is exactly today + each listed offset (`[60, 30, 7]`; negative = past, e.g. `[-1]` the day after) |
| `withinDays` | **Range mode** — fire every day `dateField` is within N days of today: positive = upcoming ("expiring soon"), negative = bounded overdue lookback, `0` = due today |
| `filter` | Optional ObjectQL where-map AND-ed with the computed date window (e.g. `{ status: 'active' }`) |
| `maxRecords` | Cap on records launched per sweep (default 1000; the sweep logs when it clamps) |

Exactly **one** of `offsetDays` or `withinDays` must be set. The two other
common shapes:

```ts
// "Expiring soon" — fires every day a document is within 30 days of expiry.
timeRelative: { object: 'hr_document', dateField: 'expires_on', withinDays: 30 }

// Overdue sweep — fires for POs up to 14 days past due (bounded lookback).
timeRelative: { object: 'purchase_order', dateField: 'due_date',
withinDays: -14, filter: { status: 'open' } }
```

Each launch puts the matching record on the automation context, so the
start-node `condition` and `{record.<field>}` interpolation work exactly
as they do for record-change flows. The discovery query runs as system,
with per-record failure isolation. `os validate` gains readiness checks —
it warns when `timeRelative.object` names an object the stack doesn't
define, and when an auto-triggered flow is left in `draft` status.

> **Genuine same-day checks are fine now (#3183).** In 16.0
> `record.due_date == today()` matches — the engine coerces temporal
> `==` / `!=` comparisons so a date field compares correctly against
> `today()`. Use that for a real "due today" condition; use
> `timeRelative` for anything of the form "N days before/after a date".

## Manual: actions and approvals

```ts
Expand Down
61 changes: 60 additions & 1 deletion content/docs/build/automation/flows.zh-Hans.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,13 @@ export default defineStack({
});
```

## 三种流程类型
## 流程类型

| 类型 | 触发方式 | 用于 |
|---|---|---|
| **Autolaunched** | 记录变更(insert/update/delete) | "用户注册时发欢迎邮件" |
| **Scheduled** | Cron 表达式或间隔 | "每晚 2 点标记过期任务" |
| **Time-relative** *(16.0)* | 日期字段与今天的距离,经由每日扫描 | "合同到期前 60 天提醒我" |
| **Manual** | 用户在 Console 点按钮,或 API 调用 | "审批发票"等 Action |

## Autolaunched:对记录变更做出反应
Expand Down Expand Up @@ -96,6 +97,64 @@ export const nightlyCleanup = defineFlow({

由 `@objectstack/service-job` 能力承载 —— 见 [Runtime Capabilities](/docs/reference/runtime-capabilities)。

## Time-relative:相对日期字段触发(16.0)

"合同到期前 60 天提醒我"以前只能写成一个用 `record.end_date == daysFromNow(60)` 做门控的记录变更流程 —— 这个谓词只有在记录*恰好被修改*时才会求值,无人照看时几乎永远不会触发。别这么写。改为声明**时间相对触发器**:流程的开始节点携带一个 `config.timeRelative` 描述符,运行时按计划扫描该对象,并为**每条匹配记录各启动一次**流程:

```ts
export const renewalReminder = defineFlow({
name: 'contract_renewal_reminder',
type: 'schedule',
status: 'active',
nodes: [
{
id: 'start',
type: 'start',
config: {
timeRelative: {
object: 'contracts',
dateField: 'end_date',
offsetDays: [60, 30, 7], // T-minus thresholds
filter: { status: 'active' }, // AND-ed with the date window
},
// Optional sweep cadence — defaults to daily at 08:00 UTC.
schedule: { type: 'cron', expression: '0 8 * * *' },
},
},
{ id: 'notify_owner', type: 'notify', label: 'Notify Owner' },
{ id: 'end', type: 'end' },
],
edges: [
{ id: 'e1', source: 'start', target: 'notify_owner' },
{ id: 'e2', source: 'notify_owner', target: 'end' },
],
});
```

| 键 | 声明什么 |
|---|---|
| `object` | 扫描其记录的对象(机器名) |
| `dateField` | 相对今天求值的 `date` / `datetime` 字段(按天粒度) |
| `offsetDays` | **偏移模式** —— 当 `dateField` 恰好等于今天 + 所列各偏移时触发(`[60, 30, 7]`;负数 = 过去,如 `[-1]` 表示次日) |
| `withinDays` | **区间模式** —— `dateField` 落在距今天 N 天内的每一天都触发:正数 = 即将到来("即将过期"),负数 = 有界的逾期回看,`0` = 今天到期 |
| `filter` | 可选的 ObjectQL where 映射,与计算出的日期窗口做 AND(如 `{ status: 'active' }`) |
| `maxRecords` | 每次扫描启动流程的记录数上限(默认 1000;触发收拢时扫描会记日志) |

`offsetDays` 与 `withinDays` 必须**恰好设置一个**。另外两种常见形态:

```ts
// "Expiring soon" — fires every day a document is within 30 days of expiry.
timeRelative: { object: 'hr_document', dateField: 'expires_on', withinDays: 30 }

// Overdue sweep — fires for POs up to 14 days past due (bounded lookback).
timeRelative: { object: 'purchase_order', dateField: 'due_date',
withinDays: -14, filter: { status: 'open' } }
```

每次启动都会把匹配记录放到自动化上下文中,因此开始节点的 `condition` 和 `{record.<field>}` 插值与记录变更流程完全一致。发现查询以 system 身份运行,并做按记录的故障隔离。`os validate` 新增就绪性检查 —— 当 `timeRelative.object` 指向 stack 未定义的对象、或自动触发的流程停留在 `draft` 状态时都会警告。

> **真正的"当天"检查现在没问题了(#3183)。**16.0 中 `record.due_date == today()` 能匹配了 —— 引擎会对时间类 `==` / `!=` 比较做强制转换,让日期字段与 `today()` 正确比较。真正的"今天到期"条件用它;而任何"某日期前/后 N 天"形态的需求,用 `timeRelative`。

## Manual:Action 与审批

```ts
Expand Down
10 changes: 7 additions & 3 deletions content/docs/build/automation/workflows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -137,15 +137,19 @@ If you're coming from a platform with Workflow Rules, here's the mapping:
| Old concept | Current equivalent |
|---|---|
| Workflow Rule | Flow |
| Time trigger | Scheduled flow |
| Time trigger ("N days before/after a date") | Time-relative trigger — `config.timeRelative` on a flow's start node (16.0, see [Flows](/docs/build/automation/flows)) |
| Time trigger (fixed clock) | Scheduled flow |
| Field update action | `update_record` node |
| Email alert | `notify` node |
| HTTP call | `http` node |
| Approval Process | Flow with one or more `approval` nodes |

The test: if the old rule was "when X happens, do Y", model it as a small
flow. If it was "this record must move through controlled states", model the
lifecycle as a state machine and use flows for the side effects.
flow. If it was "N days before/after a date field, do Y", declare a
time-relative trigger — never a date-equality condition on a record-change
flow, which only evaluates when the record happens to change. If it was
"this record must move through controlled states", model the lifecycle as a
state machine and use flows for the side effects.

## Where to go next

Expand Down
Loading
Loading