U8-ERP-CO-API用友 U8+ 业务组件 HTTP API

单据事件#

events/(u8co-events,Python 3.12 以上)是一个独立的小服务:定时轮询桥的只读列表,和上一轮的状态对比,把变化写成事件,发到 Redis Streams。下游程序订阅 stream 就能知道 U8 里哪些单据变了,不用自己轮询,也不用在 U8 里装插件。

能订阅的数据源(accounts[].sources,缺省只开 vouchers):

数据源 事件 type 读的桥路由 说明
vouchers 全部单据类型(accounts[].types,缺省为 co/client/u8co_kinds.py 的 KIND_NAMES) vouchers/list 新增、修改、审核、关闭、审批流、删除
notes ar_note、ap_note vouchers/list、notes/get 应收 / 应付票据,规则同单据,见 §2「票据」
arap_process ar_process、ap_process arap/process/list 应收应付处理批次(核销、转账、并账、红票对冲、汇兑损益、票据处理等)的处理、取消、制单
gl gl_voucher gl/vouchers/digest 总账凭证的新增、修改、审核、出纳签字、记账、作废、删除
archives archive:<档案> archives/list 基础档案的新增、修改、停用、启用、删除

所有数据源进同一个 stream(<stream_prefix><账套>),按 type 区分;投递语义(§1)、出箱、健康检查都相同。

桥的只读路由 --HTTP,签名--> u8co-events --XADD--> Redis Streams(u8co:events:<账套>)--XREADGROUP--> 你的程序
                                   |
                                   +-- SQLite 状态库(水位、快照、出箱)

它只读 U8:平时只调上表的列表 / 摘要路由(都走桥的只读 SQL 线程池,不登录 U8 业务组件),只有删除扫描(或水位倒退后的整轮读取)为空时才逐条确认(单据调 vouchers/load,要登录 U8;票据调 notes/get、档案调 archives/get、总账凭证调 gl/vouchers/load,都只跑 SQL;见 §2「删除」),不改任何数据。

1. 投递语义#

Redis 必须配置成断电不丢#

服务启动时用 CONFIG GET 核对 Redis,不合格就拒绝启动:

配置 要求
appendonly yes
appendfsync always;Redis 7.2 以上也接受 everysec(靠 WAITAOF 确认落盘)。no 一律拒绝
no-appendfsync-on-rewrite Redis 7.2 以下必须是 no(否则 AOF 重写期间不 fsync)
maxmemory-policy 设了 maxmemory 时必须是 noeviction(否则内存满时 stream 会被淘汰)

托管 Redis 常禁用 CONFIG,这时也拒绝启动。确认能接受丢数据时才设 redis.allow_nondurable_redis=true(只打警告,照常启动)。启动时连不上 Redis 是另一回事:退出码 3,稍后重启即可。

落盘证明和写入在同一条连接上。 Redis 7.2 以上,一批 XADD 和末尾的 WAITAOF 1 0 10000 放在同一个 pipeline 里发出,WAITAOF 回复本机已 fsync 才算这批成功;appendfsync always 时它立即返回,运行中有人把 appendonly 关掉时它会报错,事件留在出箱里。Redis 客户端关闭了自动重试:重连后在新连接上单独补发的 WAITAOF 前面没有写入,会立即返回,证明不了这批已经落盘。失败一律由出箱整批重发(可能产生重复,按 event_id 去重)。

compose.example.yml 里自带一个 redis:7-alpine,参数是 --appendonly yes --appendfsync always --no-appendfsync-on-rewrite no --maxmemory-policy noeviction,数据放在命名卷里。

2. 事件#

种类#

kind 什么时候
created 出现了以前没见过的 id
modified ufts 变了,且审核、关闭状态都没变
verified / unverified 审核状态 0→1 / 1→0
closed / opened 关闭状态 0→1 / 1→0
workflow 只有质量单据(qm_incoming_check、qm_product_check、qm_incoming_reject、qm_product_reject):审批状态 wf_state 或当前审核人 current_auditor 变了
deleted 全量扫描里不见了(见下文「删除」)

一次轮询里同一张单据可能同时出现审核、关闭、审批流变化,这时各发一个事件(比如终审通过同时发 verified 和 workflow,弃审发 unverified 和 workflow);有这些变化时不再另发 modified。

审批流#

质量单据走 U8 审批流时,审批可能在 U8 客户端、U8 移动端,或经本项目的 workflow/* 接口完成。上层应用(例如消息平台的待办)订阅 workflow 事件,就能及时知道哪张单据进入审批、换了审核人、审批结束,再按 type + id 调 workflow/state 取待办人(pending)去建或撤自己的待办(见 api-reference.md 的审批流一节)。

字段 内容
wf_state 表头 iVerifyStateNew:0 未提交、1 审批中、2 通过、-1 不通过;U8 里为 NULL 时是 null
current_auditor 表头 cCurrentAuditor,当前审核人姓名(U8 的原值),没有时是 null

这两个字段只出现在质量单据事件的 prev / curr 里(所有种类都带,不只 workflow);其他类型的事件没有这两个键。常见的变化:提交 0→1 且出现审核人;中间节点同意后审核人换人、wf_state 仍是 1;终审通过 1→2(同时 verified);不同意结束 1→-1;撤销提交或退回提交人回到 0。同一轮之间来回变只能看到最终状态。

示例(提交后):

{
  "kind": "workflow",
  "type": "qm_product_check",
  "id": 1000123,
  "code": "QC-0001",
  "prev": {"verified": false, "closed": false, "red": false, "verifier": null, "closer": null, "wf_state": 0, "current_auditor": null},
  "curr": {"verified": false, "closed": false, "red": false, "verifier": null, "closer": null, "wf_state": 1, "current_auditor": "审批人乙"}
}

(省略了 event_id、account、ufts、detected_at,与其他种类相同。)

桥返回的列表行不带这两个键时(桥的版本早于事件服务)不对比、不发 workflow;请先升级桥,再升级事件服务。

字段#

stream:<stream_prefix><账套>,缺省 u8co:events:999。每条消息的字段:

字段 内容
event_id 去重键,64 位十六进制
account、type、kind、id 账套、单据类型、种类、单据主表 ID(十进制字符串)
payload 完整事件 JSON(UTF-8,键排序、无空格)

payload 的内容:

{
  "event_id": "3f9a…",
  "account": "999",
  "type": "sale_order",
  "id": 1000123,
  "code": "SO-0001",
  "kind": "verified",
  "ufts": "123456789",
  "detected_at": "2026-09-28T01:02:03Z",
  "prev": {"verified": false, "closed": false, "red": false, "verifier": null, "closer": null},
  "curr": {"verified": true, "closed": false, "red": false, "verifier": "张三", "closer": null}
}

删除#

rowversion 看不到删除(行已经没了)。服务每隔 delete_scan_minutes 用 keys_only 把每种单据的主键整轮扫一遍,快照里有、扫描里没有的就发 deleted。扫描中途任何一页出错,这一轮就作废,不发删除事件,下一轮再扫。

空扫描要逐张确认。 一张都没扫到而快照里有单据时,可能真的都删了(比如某种单据本来就只有几张,被全部删除),也可能是账套或权限出了问题。服务按快照里的 id 逐张调 vouchers/load 确认(最多 empty_scan_confirm_max 张,缺省 50):

vouchers/load 按单据类型登录 U8 对应子系统(与 API 的读取相同),比列表重,每一张都占一次登录和加密点数(点数紧张时见 u8-notes 的「许可点数」一节),也占着轮询线程(每张最长 bridge.timeout_seconds)。所以只在空扫描时才调,读到一张还在的就不再往下读。确认删除后快照清空,之后不再重复;确认失败(列表或权限问题没解决)时,每次扫描最多读到第一张还在的那张为止。

票据(ar_note、ap_note)规则相同,只是逐张确认改调 notes/get(只跑 SQL,不登录 U8、不占点数)。

票据#

sources 含 notes 时,应收票据 ar_note、应付票据 ap_note(AP_Note,按 cFlag 分)跟单据走同一套轮询:按表头 Ufts 增量、keys_only 主键扫描找删除,字段、event_id、首次运行的规则都同上。id 是 Auto_ID,code 是票据号 cVouchID。

kind 什么时候
created / deleted 登记新票据 / 票据被删除
closed / opened 余额 iRAmount 变为 0(结算、贴现、背书、退回完)/ 由 0 恢复(取消处理)
modified 表头 Ufts 变了而余额是否为 0 没变(改票据、部分处理回写余额等)

基础档案#

sources 含 archives 时,accounts[].archives 里的每种档案各是一个类型 archive:<档案>(如 archive:customer),读桥的 archives/list(只跑 SQL,不登录 U8、不占点数)。id 恒为 0,code 是档案编码(两段主键的档案写成 <第一段>:<第二段>,与 archives/get 相同),ufts 是指纹,当不透明值用。

kind 什么时候
created 出现了以前没见过的编码
modified 有时间戳的档案:ufts 变了;没有时间戳的:名称、分类编码变了。停用状态没变
disabled / enabled 停用状态 否→是 / 是→否
deleted 编码扫描(或整表比对)里不见了

停用状态变了只发 disabled / enabled,不再另发 modified。prev / curr 是 {name, class_code, disabled, end_date}:

两类档案读法不同:

其他规则:

总账凭证#

sources 含 gl 时多一个类型 gl_voucher,读桥的 gl/vouchers/digest(只跑 SQL,不登录 U8、不占点数)。GL_accvouch 没有 rowversion,所以每一轮(poll_interval_seconds)都把扫描范围内的凭证摘要整轮读完,与快照逐张比对;每轮都是完整扫描,删除不必等 delete_scan_minutes。

kind 什么时候
created / deleted 扫描范围内出现新凭证 / 凭证不见了
audited / unaudited 审核人空→有 / 有→空
signed / unsigned 出纳签字人空→有 / 有→空
posted 记账标志 0→1
voided 作废标志 0→1
modified 金额、分录数、制单人、日期等变了,且上面的状态都没变;取消记账、取消作废也发 modified

prev / curr:year、period、sign、no、date、maker、checker、cashier、bookkeeper(记账人)、posted、void、debit_total(借方合计)、lines(分录数)、digest(桥算的内容指纹);人名为空时是 null。审核和签字同一轮发生时各发一条,有状态类事件时不再发 modified。

应收应付处理#

sources 含 arap_process 时多两个类型 ar_process、ap_process,读桥的 arap/process/list(只跑 SQL,不登录 U8、不占点数)。一个事件对应一批处理:同一侧往来明细(Ar_Detail / Ap_Detail)里处理方式 cProcStyle + 处理号 cCancelNo 相同的那些行,不含单据自身的审核行。应收冲应付、应付冲应收、并账、票据背书等两侧都记账的处理,两个类型各发一条。id 是这批的最小 Auto_ID,code 是处理号,ufts 是指纹(当不透明值用)。

kind 什么时候
processed 出现新批次(核销、转账、并账、红票对冲、汇兑损益、票据处理等);prev 为 null
cancelled 批次被取消(行被删);curr 为 null,prev 是最后一次看到的样子
vouchered / unvouchered 制单(凭证号 cPZid 空→有,或换了凭证)/ 取消制单(有→空)
modified 金额合计或行数变了而凭证号没变(正常不会出现)

prev / curr:flag(AR / AP)、style、style_name(9P 核销、9I 应收冲应付、9J 应付冲应收、BZ 并账、9N 红票对冲、9M 汇兑损益、9A 票据托收、9C 票据退回、9D 票据贴现、9E 票据背书、9F / 9G / 9H 坏账、9K 应收冲应收、9L 应付冲应付、XJ 现结;不认识的照写代码)、partners(往来单位编码)、docs([{type, id, line_id, debit_f, credit_f}],单据类型、单据号、行 ID、原币借贷,最多 200 行)、voucher_id(cPZid,未制单 null)、gl_sign、gl_no、rows(总行数)、min_id、max_id、debit_f、credit_f(原币合计,两位小数字符串)、year、period。制单类事件不再另发 modified;同一处理号取消后又做了一批(最小 Auto_ID 变了)发 cancelled + processed。

往来明细没有 rowversion,读法分两步,每一轮都做:

其他规则:

首次运行#

backfill_events=false(缺省)时,第一次运行只记下现有单据的快照和水位,不为已有单据发 created。之后的变化才发事件。设为 true 会把现有单据全部当作 created 发一遍。

对附加数据源(arap_process、archives、gl),backfill_events 只对状态库全新时就开着的类型生效:状态库已经有数据之后再开的数据源或档案,即使 backfill_events=true,首轮也只记快照,不会把已有的客户、存货、凭证、处理批次整批当作新事件发出。单据类型和票据(vouchers、notes)仍按各类型自己的首轮判断。

看不到的变化#

3. 消费示例#

用消费组读,处理完再 XACK;按 event_id 去重(示例用 Redis 集合,保留 7 天)。消费方崩溃后,未 XACK 的消息还在该消费者的待处理列表里,重启后先读 0 把它们处理完。

import json
import redis

r = redis.Redis(host="redis", port=6379, decode_responses=True)
stream, group, me = "u8co:events:999", "erp-sync", "worker-1"
try:
    r.xgroup_create(stream, group, id="0", mkstream=True)
except redis.ResponseError as exc:
    if "BUSYGROUP" not in str(exc):
        raise

def handle(event: dict) -> None:
    print(event["kind"], event["type"], event["id"], event["code"])

start = "0"  # 先处理自己名下没确认的,再读新的
while True:
    got = r.xreadgroup(group, me, {stream: start}, count=100, block=5000)
    if not got or not got[0][1]:
        start = ">"
        continue
    for msg_id, fields in got[0][1]:
        # 去重标记必须在业务处理成功之后写;处理本身最好也是幂等的
        if not r.exists("seen:" + fields["event_id"]):
            handle(json.loads(fields["payload"]))
            r.set("seen:" + fields["event_id"], 1, ex=7 * 86400)
        r.xack(stream, group, msg_id)

同样的逻辑用 redis-cli 看:

redis-cli XRANGE u8co:events:999 - + COUNT 5
redis-cli XINFO GROUPS u8co:events:999

stream 的裁剪只删所有消费组都已确认的事件。 XADD 不带 MAXLEN。每个 stream 最多每分钟检查一次,长度超过 redis.maxlen(缺省 100 万条)时:

不再使用的消费组要删掉(XGROUP DESTROY),否则它会让 stream 一直增长。Redis 内存要按「最慢的消费者可能积压多少」来留。

4. 部署#

Docker Compose#

cd events
cp compose.example.yml compose.yml
mkdir -p secrets config
printf '%s' '<64 位小写十六进制>' > secrets/bridge.secret
cat > secrets/operator-999.json <<'JSON'
{"acc": "999", "year": "2026", "operator": "<只读操作员>", "password": "<口令>"}
JSON
sudo chown 10001 secrets/* && sudo chmod 0600 secrets/*
cp config.example.json config/config.json     # 按实际环境改
docker compose up -d --build
docker compose exec u8co-events u8co-events status

直接运行#

cd events
uv sync --frozen
export PYTHONPATH=..                                   # 要能 import co.client
export U8CO_EVENTS_CONFIG=/etc/u8co-events/config.json
uv run u8co-events check                               # 检查配置、密钥文件权限、状态库、Redis 持久化
uv run u8co-events run

命令#

命令 作用
u8co-events run 启动轮询和发布,SIGTERM / SIGINT 停止
u8co-events run --init 状态库为空而 Redis 里已有 stream 时仍然启动(重新回填),效果同配置 allow_reseed=true
u8co-events check 只做启动检查(含状态卷是否丢失),不轮询
u8co-events status 用只读连接打印每种单据的水位、快照数、延迟、删除扫描、最后错误,以及出箱积压(JSON)
u8co-events healthcheck 请求本机 /healthz,健康时退出码 0(镜像的 HEALTHCHECK 用它)

退出码:0 正常;1 运行中某个线程异常退出(交给容器重启),或 status 读不了状态库;2 配置错误(含 Redis 持久化不合格);3 启动时连不上 Redis;4 状态库为空而 Redis 里已有 stream,拒绝启动。

健康检查#

health.listen(缺省 127.0.0.1:8090,留空关闭)上有两个只读路径,正文相同:

健康检查用只读连接(mode=ro)读状态库,不建库、不写库;状态库不存在时返回 503。只看配置里的(账套, 类型),配置里去掉的类型留下的旧记录不算。

types[] 每个(账套, 类型)一行,附加数据源也一样:开了哪些数据源,就多出哪些行——票据 ar_note、ap_note,应收应付处理 ar_process、ap_process,总账凭证 gl_voucher,档案每种一行 archive:<档案>(如 archive:customer)。下面「某种单据」的各项检查对这些行同样适用。

不健康(problems)的情况:

启动后的前 max(10 分钟, 10 个轮询周期) 不检查「多久没成功」。warnings 只提示、不影响 200/503,目前是 stream 超过 maxlen 但有消费组没读完。types[].lag_seconds 是距上次成功轮询的秒数,scan_age_seconds 是距上次完成删除扫描的秒数,outbox_size 是还没发出的事件数。

5. 配置#

JSON 文件,未知键直接报错。路径取 --config,其次环境变量 U8CO_EVENTS_CONFIG,缺省 /etc/u8co-events/config.json。样例见 events/config.example.json。

键 缺省 说明
state_path /var/lib/u8co-events/state.sqlite3 SQLite 状态库;目录 0700、文件 0600
bridge.base_url 必填 桥地址,形如 http://192.0.2.10:18089/u8co
bridge.secret_file 必填 桥的共享密钥文件,必须 0600 或 0400
bridge.timeout_seconds 60 单次桥调用超时,5–600
accounts[].acc 必填 账套号,3 位数字
accounts[].operator_file 必填 操作员凭证 JSON(acc、year、operator、password),必须 0600
accounts[].types 全部单据类型 要轮询的单据类型(票据不在这里,用 sources 的 notes)
accounts[].sources ["vouchers"] 开哪些数据源:vouchers(单据,类型见 types)、arap_process(应收应付处理,类型 ar_process、ap_process)、archives(基础档案,类型 archive:<档案>)、gl(总账凭证,类型 gl_voucher)、notes(应收应付票据,类型 ar_note、ap_note)。不含 vouchers 时不能写 types。每种类型在 status 里各占一行,延迟、扫描、水位倒退照常检查
accounts[].archives 有时间戳的 29 种档案 开了 archives 时轮询哪些档案(archives/list 的档案名)。缺省是有时间戳、能按水位增量读的那些;没有时间戳的(voucher_sign、project、customer_address、customer_bank、vendor_bank、fa_card、operator、role)按删除扫描的节奏整表比对,要写明才开,其中 fa_card、operator、role 数据量大或只有账套主管能读
poll_interval_seconds 30 轮询间隔,5–3600
page_limit 500 每页条数,1–500
delete_scan_minutes 30 删除扫描间隔,0 表示不扫(不会有 deleted 事件)
empty_scan_confirm_max 50 删除扫描(或水位倒退后的整轮读取)为空而快照里有单据时,逐张 vouchers/load 确认的最多张数,0–500;0 表示不确认,空扫描一律作废(见 §2「删除」)
auto_id_lag 500 应收应付处理按自增号取增量时每轮回看的最少条数,50–1000000;防止晚提交的事务占用的较小号被漏掉
gl_closed_periods 1 总账凭证除未结账月份外再比对最近几个已结账月份,0–12
backfill_events false 首次运行是否为已有单据发 created;附加数据源只对状态库全新时就开着的类型生效(见 §2「首次运行」)
outbox_high_water 100000 出箱积压达到这个数就暂停轮询(水位不前移,不丢变化),等发布追上
redis.url redis://redis:6379/0 redis://、rediss:// 或 unix://;不能带口令(user:口令@ 或 ?password= 直接报错)
redis.password_file 空 Redis 口令文件(0600),口令只能放这里
redis.stream_prefix u8co:events: stream 名 = 前缀 + 账套
redis.maxlen 1000000 stream 超过这个长度才裁剪,且只裁所有消费组都已确认的(见 §3)
redis.allow_nondurable_redis false 见 §1
publisher.kind redis stdout 把事件逐行打到标准输出,只用于开发调试
publisher.batch 100 每批发布条数
publisher.idle_seconds 1.0 出箱空闲时的检查间隔
health.listen 127.0.0.1:8090 健康检查地址,空串关闭
allow_reseed false 状态库是新的、但 Redis 里该账套的流已存在时,是否允许重新回填(见 §6 的「状态库丢失」一条)

只有路径可以用环境变量覆盖:U8CO_EVENTS_STATE(state_path)、U8CO_EVENTS_SECRET_FILE(bridge.secret_file)、U8CO_EVENTS_REDIS_PASSWORD_FILE(redis.password_file)。

6. 运维#

类型 / 数据源 功能 id
ar_bill / ap_bill AR21101 或 AR0601021 / AP21101 或 AP0601021
ar_refund / ap_refund 同收款单 AR0601031、AR22101 / 同付款单 AP0601031、AP22101
qm_incoming_inspect、qm_product_inspect QM02010101、QM02020101
qm_other_inspect QM02060101 或列表 QM030601
qm_other_check QM02060201 或列表 QM030603
purchase_settle 结算单列表查询 PU040305
position_adjust ST010807
ia_adjust 入库调整单 IA1001 / 列表 IA02040201,或出库调整单 IA1004 / 列表 IA02040301
inventory_price_adjust SA03120202 或列表 SA0312020301
sale_return_apply SA03250104 或列表 SA03250201
archives 中的货位、收发类别、本单位开户银行、行业分类 AS030Q、AS016Q、AS013Q、AS050Q
arap_process(arap/process/list) AR0807、AR060107、AR0503 之一(应付同理);读不到时看不到的批次可能被当作已取消