diff --git a/backend/app/api/copilot_blueprint.py b/backend/app/api/copilot_blueprint.py index 521d1e94..8068edc0 100644 --- a/backend/app/api/copilot_blueprint.py +++ b/backend/app/api/copilot_blueprint.py @@ -59,7 +59,7 @@ BLUEPRINT_SYSTEM_PROMPT = """\ "sensitive":false};type 只能是 string、number、integer、boolean、array,x-widget 可写 textarea、select、date 这类控件 - /writeback 写回哪里:{"capability":"能力登记里的写入能力","mapping":{"参数":"form.字段 | metric.指标 id | rows | '常量'"}, "confirm":true} -- 谁能看(/access)由用户自己在卡片上定,你不填 +- 谁能看(/access)、写回的结果不明时谁去核实(/writeback/reconciler)由用户自己在卡片上定,你不填 规则: - 只提议:你写的每一项都是「待确认」,用户确认了才算数;用户已经确认过的项不要改,除非他这一轮明确要改 diff --git a/backend/app/blueprints/ops.py b/backend/app/blueprints/ops.py index 87f3f859..a626fed5 100644 --- a/backend/app/blueprints/ops.py +++ b/backend/app/blueprints/ops.py @@ -9,7 +9,8 @@ - /asks:bp_ask 追加一个反问(只有助手问),/asks//answer 记用户的回答; - 选模板只走 bp_template:记下模板和版本,按模板预置还没填的卡片(来源记「模板默认」、状态「待确认」)。 -「谁能看」(access)只收用户的操作:授权是人的决定,助手提的没有「待确认」可挂(P2)。 +「谁能看」(access)和写回的「结果不明时谁去核实」(/writeback/reconciler)只收用户的操作:授权、派活给谁是人的决定, +助手提的没有「待确认」可挂(P2)。 落地前后都按 agentlab.blueprint/1 的形状核一遍(spec.parse_blueprint),不合形状的一律拒。 """ from __future__ import annotations @@ -45,9 +46,17 @@ POLICY_KINDS = ("any", "all", "quorum") TIMEOUT_ACTIONS = ("remind", "escalate", "reject") #: 金额线只许「字段 比较 常量」 THRESHOLD_OPS = (">", ">=", "<", "<=", "==", "!=") -#: 「谁能看」的三档和能授权的对象 +#: 「谁能看」的三档和能授权的对象(写回的核实人也只能是人或组:绑到应用的核实岗位) ACCESS_KINDS = ("use", "observe", "manage") GRANTEE_KINDS = ("user", "group") +#: 写回卡片上的项 +WRITEBACK_KEYS = ("capability", "mapping", "confirm", "reconciler") +#: 只收用户操作的位置(JSON 指针的前几段)和助手动它时交回的话(按位置的最后一段认) +USER_ONLY = (("access",), ("writeback", "reconciler")) +_USER_ONLY_TEXT = { + "access": "「谁能看」要由你来定:授权是人的决定,助手不替你填", + "reconciler": "「结果不明时谁去核实」要由你来定:派活给谁是人的决定,助手不替你选", +} #: 入口字段里模板自己要用的名字(统计期),「填什么」不能占 RESERVED_FIELDS = frozenset({"period", "period_start", "period_end", "mode"}) #: 列表的上限:再多就不是普通人在卡片上能判断对错的颗粒度了 @@ -363,6 +372,18 @@ def _grantees(value: Any, _spec: dict[str, Any]) -> Any: return [_principal(v, GRANTEE_KINDS, "授权的对象") for v in value] +def _reconciler(value: Any, _spec: dict[str, Any]) -> Any: + """结果不明时谁去核实:一组人或组(绑到应用的核实岗位,核实待办派给他们)。""" + if not isinstance(value, list) or len(value) > 20: + raise BpRefused("核实的人要写成一组人或组(最多 20 个)") + out: list[dict[str, str]] = [] + for v in value: + p = _principal(v, GRANTEE_KINDS, "核实的人") + if p not in out: + out.append(p) + return out or None + + #: 各项的检查。键是 (卡片, 项);卡片本身就是一项的写 (卡片, None) _CHECKS: dict[tuple[str, str | None], Callable[[Any, dict[str, Any]], Any]] = { ("purpose", None): _text(500, "目的"), @@ -383,6 +404,7 @@ _CHECKS: dict[tuple[str, str | None], Callable[[Any, dict[str, Any]], Any]] = { ("writeback", "capability"): _capability, ("writeback", "mapping"): _mapping, ("writeback", "confirm"): _confirm, + ("writeback", "reconciler"): _reconciler, } @@ -441,12 +463,22 @@ def _new_entry(card: str, raw: Any, source: str, spec: dict[str, Any]) -> dict[s def _writeback(raw: Any, source: str, spec: dict[str, Any]) -> dict[str, Any] | None: + """整张写回卡片换掉。核实人只由用户定:助手写了要拒;没写的沿用卡片上原来的(助手改目标能力、字段对应时,不能把 + 用户选好的人冲掉)。""" if raw is None: return None - if not isinstance(raw, dict) or set(raw) - {"capability", "mapping", "confirm"}: - raise BpRefused("「写回哪里」只能写目标能力、字段对应和要不要人确认") - return {key: _item(_checked("writeback", key, raw.get(key), spec), source) - for key in ("capability", "mapping", "confirm")} + if not isinstance(raw, dict) or set(raw) - set(WRITEBACK_KEYS): + raise BpRefused("「写回哪里」只能写目标能力、字段对应、要不要人确认和结果不明时谁去核实") + if "reconciler" in raw and source != "user": + raise BpRefused(_USER_ONLY_TEXT["reconciler"]) + out = {key: _item(_checked("writeback", key, raw.get(key), spec), source) + for key in ("capability", "mapping", "confirm")} + if "reconciler" in raw: + out["reconciler"] = _item(_checked("writeback", "reconciler", raw["reconciler"], spec), source) + else: + before = (spec.get("writeback") or {}).get("reconciler") + out["reconciler"] = copy.deepcopy(before) if isinstance(before, dict) else _item(None, "template") + return out def _set_item_part(item: dict[str, Any], part: str, value: Any, source: str) -> None: @@ -481,9 +513,9 @@ def _container(spec: dict[str, Any], tokens: list[str]) -> tuple[dict[str, Any], if card == "fields" and len(tokens) == 3 and tokens[2] == "spec": entries = spec.get("fields") or [] return entries[_index(tokens[1], len(entries))], "spec" - if card == "writeback" and len(tokens) == 2 and tokens[1] in ("capability", "mapping", "confirm"): + if card == "writeback" and len(tokens) == 2 and tokens[1] in WRITEBACK_KEYS: if spec.get("writeback") is None: - spec["writeback"] = {k: _item(None, "template") for k in ("capability", "mapping", "confirm")} + spec["writeback"] = {k: _item(None, "template") for k in WRITEBACK_KEYS} return spec["writeback"], tokens[1] raise BpRefused(f"不能改这个位置:/{'/'.join(tokens)}") @@ -642,8 +674,8 @@ def apply_bp_op(spec: dict[str, Any], op: dict[str, Any], *, source: str) -> dic tokens = parse_pointer(parsed.path) if tokens[0] not in _LABEL or tokens[0] == "template": raise BpRefused(f"没有这张卡片:/{tokens[0]}(选模板用 bp_template)") - if tokens[0] == "access" and source != "user": - raise BpRefused("「谁能看」要由你来定:授权是人的决定,助手不替你填") + if source != "user" and (only := next((k for k in USER_ONLY if tuple(tokens[:len(k)]) == k), None)): + raise BpRefused(_USER_ONLY_TEXT[only[-1]]) if parsed.op == "bp_set": _set(out, tokens, parsed.value, source) elif parsed.op == "bp_add": @@ -720,6 +752,15 @@ def blueprint_issues(spec: dict[str, Any], *, for_: str) -> list[dict[str, Any]] add("bp.missing", "/writeback", "「写回哪里」还没定:要写回哪个系统、哪些字段") elif isinstance(wb, dict) and _value(wb.get("capability")) is not None and _value(wb.get("mapping")) is None: add("bp.missing", "/writeback/mapping", "写回的字段对应还没写:目标系统的每个参数取哪个字段或指标") + if "writeback" in used and for_ != "personal" and isinstance(wb, dict) and _value(wb.get("reconciler")) is None: + from app.blueprints.templates._common import needs_reconciler + + if needs_reconciler(spec): + # 目标能力没登记查询:写回的结果不明时平台查不了、也不自动重发,要有人去目标系统里核实。试运行时写入走测试 + # 环境或演练、待办派给试运行的人自己,不挡试运行,只挡发布(同 bp.people_unbound) + add("bp.reconciler_unbound", "/writeback/reconciler", + f"「{_value(wb.get('capability'))}」没登记查询:写回的结果不明时(请求发出去了、没拿到答复)要有人去目标系统里" + "核实,还没定是谁", level="error" if publish else "warning") for card in ("data", "people", "fields"): if spec.get(card) and card not in used: @@ -818,14 +859,14 @@ def _items(spec: dict[str, Any]) -> list[tuple[str, dict[str, Any]]]: out.append((f"/fields/{i}/spec", f["spec"])) wb = spec.get("writeback") if isinstance(wb, dict): - out += [(f"/writeback/{k}", wb[k]) for k in ("capability", "mapping", "confirm") if isinstance(wb.get(k), dict)] + out += [(f"/writeback/{k}", wb[k]) for k in WRITEBACK_KEYS if isinstance(wb.get(k), dict)] return out _PART_LABEL = {"label": "名称", "unit": "单位", "table": "对到的表", "measure": "怎么算", "date": "业务日期", "filter": "筛选条件", "grain": "粒度", "who": "处理人", "policy": "几个人同意", "due": "时限", "on_timeout": "超时怎么办", "threshold": "条件", "spec": "字段", "capability": "目标能力", - "mapping": "字段对应", "confirm": "要不要人确认"} + "mapping": "字段对应", "confirm": "要不要人确认", "reconciler": "结果不明时谁去核实"} def _where(path: str) -> str: diff --git a/backend/app/blueprints/publish_plan.py b/backend/app/blueprints/publish_plan.py index 71e470ab..3bbcecf1 100644 --- a/backend/app/blueprints/publish_plan.py +++ b/backend/app/blueprints/publish_plan.py @@ -71,6 +71,8 @@ GRAPH_CHANGED = "plan.graph_changed" UNTRIALED = "plan.untrialed" TRIAL_FAILED = "plan.trial_failed" TRIAL_DIFFERS = "plan.trial_differs" +#: 蓝图检查:写回的结果不明时没人核实(blueprints/ops.blueprint_issues) +RECONCILER_UNBOUND = "bp.reconciler_unbound" ADMIN_REQUIRED = "plan.admin_required" DOWNGRADE_ADMIN = "plan.downgrade_admin" PREFLIGHT = "plan.preflight" @@ -343,6 +345,13 @@ async def _preflight(session: AsyncSession, bp: Blueprint, target: _Target, *, l issues = await lint_app(session, spec, _draft_bindings(bp, target), previous=target.previous, dispositions=options.get("dispositions") or {}, pins=await _simulated_pins(session, spec, target, workflow_id=workflow_id, level=level)) + if any(i.get("code") == RECONCILER_UNBOUND for i in out): + # 写回的结果不明时没人核实:蓝图检查已经指到卡片上那一项,应用检查说的是同一件事(核实岗位没绑、写入没人核实), + # 不再多列两条——那两条说的岗位在卡片上找不到 + from app.apps.derive import RECONCILER + + issues = [i for i in issues if i.get("code") != "app.reconciler_unbound" + and not (i.get("code") == "app.slot_unbound" and i.get("path") == f"slots.{RECONCILER}")] out += [{**i, "source": "app"} for i in issues] # 授权:给的人和组要在名录里(同授权接口) _, problem = await grants_problem(session, grant_items(bp)) diff --git a/backend/app/blueprints/spec.py b/backend/app/blueprints/spec.py index a9e4c967..57b1e56d 100644 --- a/backend/app/blueprints/spec.py +++ b/backend/app/blueprints/spec.py @@ -78,6 +78,9 @@ class Writeback(_Strict): capability: Item = Field(default_factory=Item) mapping: Item = Field(default_factory=Item) # {参数: "form.x" | "metric.y" | 常量} confirm: Item = Field(default_factory=Item) # 要不要人确认(布尔) + # 结果不明时谁去核实([{kind: user|group, name}]):目标能力没登记查询时才要,绑到应用的核实岗位(apps/derive.RECONCILER)。 + # 只由用户定(同「谁能看」)。旧蓝图没有这一项,读进来是空的 + reconciler: Item = Field(default_factory=Item) class Access(_Strict): diff --git a/backend/app/blueprints/store.py b/backend/app/blueprints/store.py index 246bffc4..a3ef2fc4 100644 --- a/backend/app/blueprints/store.py +++ b/backend/app/blueprints/store.py @@ -377,7 +377,10 @@ async def blueprint_view(session: AsyncSession, blueprint: Any) -> dict[str, Any def blueprint_out(blueprint: Any, *, issues: bool = True, extra: list[dict[str, Any]] | None = None) -> dict[str, Any]: """一张蓝图交给前端的样子(frontend/src/typesBlueprint.ts 的 Blueprint)。关节只给状态和模板声明的说法、类型、分组 - (JointState:status、by、note、label、kind、group),不给填的值(值在图上看)。""" + (JointState:status、by、note、label、kind、group),不给填的值(值在图上看)。reconciler_needed:写回卡片上要不要 + 摆「结果不明时谁去核实」(目标能力没登记查询,templates/_common.needs_reconciler),界面不自己判。""" + from app.blueprints.templates._common import needs_reconciler + joints = {jid: {k: v for k, v in state.items() if k in ("status", "by", "note", "label", "kind", "group")} for jid, state in (blueprint.joints or {}).items() if isinstance(state, dict)} out = { @@ -386,7 +389,7 @@ def blueprint_out(blueprint: Any, *, issues: bool = True, extra: list[dict[str, "template_version": blueprint.template_version, "source": blueprint.source or {}, "conversation_id": blueprint.conversation_id, "workflow_id": blueprint.workflow_id, "app_id": blueprint.app_id, "last_trial_run_id": blueprint.last_trial_run_id, "graph_hash": blueprint.graph_hash, "joints": joints, - "copied_from": blueprint.copied_from, + "copied_from": blueprint.copied_from, "reconciler_needed": needs_reconciler(blueprint.spec or {}), "created_at": blueprint.created_at.isoformat() if blueprint.created_at else None, "updated_at": blueprint.updated_at.isoformat() if blueprint.updated_at else None, } diff --git a/backend/app/blueprints/templates/_common.py b/backend/app/blueprints/templates/_common.py index b8820fcb..589c221a 100644 --- a/backend/app/blueprints/templates/_common.py +++ b/backend/app/blueprints/templates/_common.py @@ -352,6 +352,12 @@ def bindings_of(bp: dict[str, Any]) -> dict[str, Any]: timeout = value(step.get("on_timeout")) if isinstance(timeout, dict): bind(escalate_slot(step), timeout.get("to") or []) + if needs_reconciler(bp): + # 写回的结果不明时谁去核实:只在目标能力没登记查询时绑(应用骨架这时才声明核实岗位,app_base)。补登了查询的, + # 卡片上留着的人不再绑:平台自己查,用不上 + from app.apps.derive import RECONCILER + + bind(RECONCILER, value((bp.get("writeback") or {}).get("reconciler")) or []) return {"slots": slots} @@ -422,6 +428,18 @@ def write_lookup(capability: str) -> bool: return bool(view.get("lookup")) +def needs_reconciler(bp: dict[str, Any]) -> bool: + """「写回哪里」要不要定「结果不明时谁去核实」:模板用到写回卡片、选了目标能力、这项能力没登记查询(和应用骨架加核实 + 岗位同一个判断,app_base)。结果不明时平台不自动重发,查不了就只能派人去目标系统里看。蓝图检查、岗位绑定、交给前端的 + 蓝图(reconciler_needed)共用这一份。""" + ref = bp.get("template") + template = get_template(str(ref.get("id") or "")) if isinstance(ref, dict) else None + if template is None or "writeback" not in template.cards: + return False + capability = value((bp.get("writeback") or {}).get("capability")) + return bool(capability) and not write_lookup(str(capability)) + + # ========================================================================== # 看哪些数:按取数节点分组 # ========================================================================== diff --git a/backend/app/blueprints/templates/approval.py b/backend/app/blueprints/templates/approval.py index c262aba5..a0e058ba 100644 --- a/backend/app/blueprints/templates/approval.py +++ b/backend/app/blueprints/templates/approval.py @@ -105,7 +105,7 @@ TEMPLATE = register(TemplateDef( cards={"purpose": "必填:一句话说清审批什么", "fields": "必填:申请人要填的字段(名称、类型、必填、上下限、选项)", "people": "必填:每一步谁处理、几个人同意算通过、时限、超时给谁;某一步只在金额超过某个数时才走,写条件", - "writeback": "可选:通过后写回哪个系统、哪些字段、要不要人确认", + "writeback": "可选:通过后写回哪个系统、哪些字段、要不要人确认;能力没登记查询的,还要定结果不明时谁去核实", "access": "可选:谁能发起、谁能看全部、谁能管理"}, personal=None, skeleton=skeleton, app_skeleton=app_skeleton, schedule=None, tags=["流程", "审批"], diff --git a/backend/app/blueprints/templates/reconcile_writeback.py b/backend/app/blueprints/templates/reconcile_writeback.py index 686b3831..887a1fc9 100644 --- a/backend/app/blueprints/templates/reconcile_writeback.py +++ b/backend/app/blueprints/templates/reconcile_writeback.py @@ -168,7 +168,7 @@ def example() -> dict[str, Any]: "fields": [], "writeback": {"capability": item("fake_ledger_adjust"), "mapping": item({"items": "rows", "total": "metric.diff_amount"}), - "confirm": item(True)}, + "confirm": item(True), "reconciler": item([{"kind": "group", "name": "财务组"}])}, "access": {"use": [{"kind": "group", "name": "财务组"}], "observe": [], "manage": []}, "asks": [], } @@ -193,7 +193,8 @@ TEMPLATE = register(TemplateDef( "data": "必填:差异的指标(差异笔数、差异金额),对到同一个库里的表", "cadence": "必填:多久核对一次,统计哪一期", "people": "必填:谁复核(只有一步)", - "writeback": "必填:写回哪个系统(能力登记里的写入能力)、字段对应(差异明细写 rows)、要不要人确认", + "writeback": "必填:写回哪个系统(能力登记里的写入能力)、字段对应(差异明细写 rows)、要不要人确认;" + "能力没登记查询的,还要定结果不明时谁去核实", "access": "可选:谁能用、谁能看全部、谁能管理"}, personal=None, skeleton=skeleton, app_skeleton=app_skeleton, schedule=schedule, tags=["流程", "定时", "写回"], diff --git a/backend/app/plain.py b/backend/app/plain.py index 4c724cab..617afe92 100644 --- a/backend/app/plain.py +++ b/backend/app/plain.py @@ -332,6 +332,9 @@ PLAIN.update({ "在「看哪些数」里点「改」重新选表;表刚建的话,先到「数据」页读取一次表结构。"), "bp.people_unbound": Plain( "有一步还没定谁来处理", "流程走到这一步就没人接手。", _Y, "在「谁来做」里选人、组或岗位。"), + "bp.reconciler_unbound": Plain( + "写回的结果不明时没人核实", "这项写入能力没登记查询:结果不明时平台查不了,也不会自动重发,要有人去目标系统里看写进去没有。", + _Y, "在「写回哪里」卡片的「结果不明时谁去核实」里选人或组。"), "bp.template_mismatch": Plain( "卡片上写的超出了这个场景能做的", "超出的那部分不会生效,做出来的应用和你说的对不上。", _Y, "按提示删掉或改小,或者换一个场景。"), diff --git a/backend/tests/test_blueprint_ops.py b/backend/tests/test_blueprint_ops.py index 51faa862..cb127ccc 100644 --- a/backend/tests/test_blueprint_ops.py +++ b/backend/tests/test_blueprint_ops.py @@ -187,6 +187,47 @@ def test_writeback_only_takes_write_capabilities(monkeypatch): assert apply_bp_op(spec, {"op": "bp_remove", "path": "/writeback"}, source="user")["writeback"] is None +def test_the_reconciler_is_chosen_by_people_and_only_when_the_write_cannot_be_looked_up(monkeypatch): + """写回的「结果不明时谁去核实」:目标能力没登记查询时才要(试运行只提醒,发布挡住);只收用户的、只能是人或组; + 助手换写回卡片时留着用户选好的人;岗位绑定按它绑到应用的核实岗位,能力补登了查询就不再要、也不再绑。""" + from app.blueprints.templates._common import bindings_of, needs_reconciler + from app.capabilities import registry + + monkeypatch.setitem(registry._view, "bp_test_erp_post", {"effect": "write", "endpoint_id": "e1", "missing": False}) + spec = apply_bp_op(empty_blueprint(), {"op": "bp_template", "template": {"id": "approval", "version": 1}}, + source="user") + spec = apply_bp_op(spec, {"op": "bp_set", "path": "/writeback", "value": { + "capability": "bp_test_erp_post", "mapping": {"amount": "form.reason"}, "confirm": True}}, source="assistant") + assert needs_reconciler(spec) + found = {i["code"]: i for i in blueprint_issues(spec, for_="publish")} + assert (found["bp.reconciler_unbound"]["level"], found["bp.reconciler_unbound"]["path"]) == ( + "error", "/writeback/reconciler") + trial = {i["code"]: i for i in blueprint_issues(spec, for_="trial")} + assert trial["bp.reconciler_unbound"]["level"] == "warning", "试运行的待办派给试运行的人自己,不挡试运行" + + finance = [{"kind": "group", "name": "财务组"}] + assert "要由你来定" in refused(spec, {"op": "bp_set", "path": "/writeback/reconciler", "value": finance}).message + assert "要由你来定" in refused(spec, {"op": "bp_set", "path": "/writeback", "value": { + "capability": "bp_test_erp_post", "reconciler": finance}}).message + assert "人或组" in refused(spec, {"op": "bp_set", "path": "/writeback/reconciler", + "value": [{"kind": "slot", "name": "财务"}]}, source="user").message + chosen = apply_bp_op(spec, {"op": "bp_set", "path": "/writeback/reconciler", "value": [*finance, *finance]}, + source="user") + assert chosen["writeback"]["reconciler"] == {"value": finance, "source": "user", "status": "confirmed", "why": None} + assert not [i for i in blueprint_issues(chosen, for_="publish") if i["code"] == "bp.reconciler_unbound"] + assert bindings_of(chosen)["slots"]["reconciler"] == finance + # 助手换目标能力、字段对应:用户选好的人留着 + kept = apply_bp_op(chosen, {"op": "bp_set", "path": "/writeback", "value": { + "capability": "bp_test_erp_post", "mapping": {"amount": "form.reason"}, "confirm": False}}, source="assistant") + assert kept["writeback"]["reconciler"]["value"] == finance and kept["writeback"]["confirm"]["value"] is False + # 补登了查询:平台自己查,不再要人核实,卡片上留着的人也不绑 + monkeypatch.setitem(registry._view, "bp_test_erp_post", {"effect": "write", "endpoint_id": "e1", "missing": False, + "lookup": "bp_test_erp_get"}) + assert not needs_reconciler(spec) + assert not [i for i in blueprint_issues(spec, for_="publish") if i["code"] == "bp.reconciler_unbound"] + assert "reconciler" not in bindings_of(chosen)["slots"] + + def test_access_is_for_people_and_asks_go_one_way(): spec = empty_blueprint() e = refused(spec, {"op": "bp_add", "path": "/access/use", "value": {"kind": "group", "name": "门店"}}) diff --git a/backend/tests/test_publish_plan.py b/backend/tests/test_publish_plan.py index 4ae1ef8a..e38b8d97 100644 --- a/backend/tests/test_publish_plan.py +++ b/backend/tests/test_publish_plan.py @@ -202,6 +202,65 @@ async def test_a_process_with_an_unbound_step_stops_at_preflight(client): assert "沿用" in summary["save_version"] and "流程类" in summary["first_run"] +@pytest.fixture +async def erp_write(): + """一项写入能力:绑定登记端点的 POST,没登记查询(结果未知时平台查不了,要人核实)。""" + from sqlalchemy import delete + + from app.capabilities import registry + from app.db.models import Capability, CustomTool, Endpoint + + tag = uuid.uuid4().hex[:6] + name = f"erp_claim_{tag}" + async with SessionLocal() as session: + erp = Endpoint(name=f"erp_{tag}", environments={"prod": {"base_url": "https://erp.example.com", "headers": {}}}) + session.add(erp) + await session.flush() + session.add(CustomTool(name=name, kind="http", parameters={"type": "object", "properties": {}}, + config={"endpoint": erp.id, "method": "POST", "path": "/claims"})) + await session.commit() + await registry.sync_all(session) + try: + yield name + finally: + async with SessionLocal() as session: + await session.execute(delete(CustomTool).where(CustomTool.name == name)) + await session.execute(delete(Endpoint).where(Endpoint.name == f"erp_{tag}")) + await session.execute(delete(Capability).where(Capability.name == name)) + await session.commit() + await registry.refresh_cache(session) + + +async def test_the_reconciler_comes_from_the_writeback_card(client, erp_write): + """审批通过后写回一项没登记查询的能力:结果不明时要人核实。发布单只列一条、指到「写回哪里」卡片上那一项(应用检查说的 + 核实岗位没绑、写入没人核实是同一件事,不再重复列);在卡片上选了人,绑到应用的核实岗位,这几条都不再有。""" + from app.blueprints.templates import get_template + + bp = (await client.post("/api/blueprints", json={"source": {"kind": "template", "id": "approval"}, + "scope": "shared"})).json() + example = get_template("approval").example() + await s.edit_spec(bp["id"], **{k: example[k] for k in ("purpose", "people", "fields", "access")}, + writeback={"capability": s.item(erp_write), "mapping": s.item({"amount": "form.amount"}), + "confirm": s.item(True), "reconciler": s.item(None, status="missing")}) + assert (await client.get(f"/api/blueprints/{bp['id']}")).json()["reconciler_needed"] is True + + def about_reconciler(plan: dict) -> list[str]: + return sorted(p["code"] for p in plan["problems"] if p["code"] in ("bp.reconciler_unbound", "app.reconciler_unbound") + or (p["code"] == "app.slot_unbound" and p.get("path") == "slots.reconciler")) + + plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json() + assert plan["ok"] is False and about_reconciler(plan) == ["bp.reconciler_unbound"] + (problem,) = [p for p in plan["problems"] if p["code"] == "bp.reconciler_unbound"] + assert problem["who"] == "you" and problem["path"] == "/writeback/reconciler" + assert "结果不明时谁去核实" in problem["plain"]["action"] + + r = await client.patch(f"/api/blueprints/{bp['id']}", json={"ops": [ + {"op": "bp_set", "path": "/writeback/reconciler", "value": [{"kind": "group", "name": "财务组"}]}]}) + assert r.status_code == 200, r.text + plan = (await client.post(f"/api/blueprints/{bp['id']}/publish-plan", json={"level": "published"})).json() + assert about_reconciler(plan) == [], [p["code"] for p in plan["problems"]] + + async def test_a_failed_step_stops_and_resume_does_not_redo_what_is_done(client, tmp_path, monkeypatch): from app.blueprints import publish_plan as plans diff --git a/frontend/src/lib/termsBlueprint.ts b/frontend/src/lib/termsBlueprint.ts index edac4483..6970c069 100644 --- a/frontend/src/lib/termsBlueprint.ts +++ b/frontend/src/lib/termsBlueprint.ts @@ -204,6 +204,7 @@ export const ITEM_LABEL: Record = { capability: '写到哪个系统', mapping: '字段对应', confirm: '要不要人确认', + reconciler: '结果不明时谁去核实', cadence: '多久更新、统计哪一期', use: '可以使用', observe: '可以查看全部', @@ -351,6 +352,10 @@ export const BP_EDIT_TEXT = { addField: '加一个字段', addFieldPrompt: '字段叫什么(例如「金额」)', addWriteback: '要写回系统', + /** 写回卡片上「结果不明时谁去核实」的说明:目标能力没登记查询时才摆(服务端 Blueprint.reconciler_needed) */ + reconcilerHint: '这项写入能力没登记查询:写回的结果不明时(请求发出去了、没拿到答复),平台查不了,也不会自动重发,' + + '要有人去目标系统里看写进去没有。只能由你来选。', + reconcilerUnused: '这项写入能力已经登记了查询,结果不明时平台自己去查:这一项用不上了,发布时不会派给他们。', removeTitle: (where: string) => `删掉${where}?`, removeConfirm: '删掉', template: '换一个场景', diff --git a/frontend/src/pages/blueprints/BlueprintCards.tsx b/frontend/src/pages/blueprints/BlueprintCards.tsx index a6c3ea53..be72d9ed 100644 --- a/frontend/src/pages/blueprints/BlueprintCards.tsx +++ b/frontend/src/pages/blueprints/BlueprintCards.tsx @@ -6,6 +6,8 @@ * 条件这些选填的空着也是缺,只看项的状态会把它们都标成缺;「待确认」是助手、模板、目录提的、你还没看过的项。界面不自己判 * 哪些项必填(P7)。 * + * 写回卡片上「结果不明时谁去核实」只在目标能力没登记查询时摆(服务端 Blueprint.reconciler_needed),只能由人选(同「谁能看」)。 + * * 每一项可以点「修改」:改完发一个 bp_set(值是纯值,服务端记成来源「你」、已确认);待确认的可以直接点「确认」(只改状态, * 来源不变),一张卡片上待确认的不止一项时卡头另有「确认全部」,一次修改带上这几项的状态(服务端整批落或整批不落)。 * 加一项是 bp_add(卡片字段写纯值),删一项是 bp_remove。对到数据的项写表、中文名、粒度、业务日期和 @@ -29,6 +31,9 @@ import type { import { SELF_RECORDS, ValueEditor, valueText, type EditorKind, type EditorScope } from './editors' import { isItem, missingPaths, visibleCards, type CardKey } from './shared' +/** 卡片上还没有的一项(旧蓝图的写回卡片没有「结果不明时谁去核实」):按缺摆出来,改了服务端才建 */ +const UNSET: BpItem = { value: null, source: 'template', status: 'missing' } + const STATUS_COLOR: Record = { confirmed: 'var(--st-done)', proposed: 'var(--st-waiting)', missing: 'var(--st-failed)', } @@ -207,8 +212,12 @@ function CardFor({ card, ctx, template }: { card: CardKey; ctx: CardCtx; templat } case 'writeback': { const wb = spec.writeback + // 结果不明时谁去核实:目标能力没登记查询时才摆(服务端说要不要);以前选过、后来能力补登了查询的照样摆出来,写明用不上 + const reconciler: BpItem = wb?.reconciler ?? UNSET + const chosen = Array.isArray(reconciler.value) && reconciler.value.length > 0 + const askReconciler = !!wb && (!!ctx.bp.reconciler_needed || chosen) const items: [string, BpItem][] = wb ? [['/writeback/capability', wb.capability], ['/writeback/mapping', wb.mapping], - ['/writeback/confirm', wb.confirm]] : [] + ['/writeback/confirm', wb.confirm], ...(askReconciler ? [['/writeback/reconciler', reconciler] as [string, BpItem]] : [])] : [] return ( + {askReconciler && ( + <> + +

+ {ctx.bp.reconciler_needed ? BP_EDIT_TEXT.reconcilerHint : BP_EDIT_TEXT.reconcilerUnused} +

+ + )} )}
diff --git a/frontend/src/pages/blueprints/BlueprintPage.tsx b/frontend/src/pages/blueprints/BlueprintPage.tsx index ed112c76..481e5651 100644 --- a/frontend/src/pages/blueprints/BlueprintPage.tsx +++ b/frontend/src/pages/blueprints/BlueprintPage.tsx @@ -25,8 +25,11 @@ import { TrialSection } from './TrialSection' import { blueprintPath, trialed } from './shared' const SECTIONS = ['cards', 'trial'] as const -/** 只挡发布、不挡试运行的问题:待确认的项、岗位没人(试运行时人工步骤派给试运行的人自己,服务端 blueprints/ops) */ -const PUBLISH_ONLY = new Set(['bp.unconfirmed', 'bp.people_unbound']) +/** + * 只挡发布、不挡试运行的问题:待确认的项、岗位没人、写回结果不明时没人核实(试运行时人工步骤派给试运行的人自己,写入走 + * 测试环境或演练,服务端 blueprints/ops) + */ +const PUBLISH_ONLY = new Set(['bp.unconfirmed', 'bp.people_unbound', 'bp.reconciler_unbound']) type Section = (typeof SECTIONS)[number] /** 模板说明整站一份,进一次蓝图页取一次就够 */ diff --git a/frontend/src/pages/blueprints/shared.ts b/frontend/src/pages/blueprints/shared.ts index 171ea35c..09763aa9 100644 --- a/frontend/src/pages/blueprints/shared.ts +++ b/frontend/src/pages/blueprints/shared.ts @@ -65,10 +65,12 @@ export function visibleCards(spec: BlueprintSpec | null | undefined, templateCar } /** - * 服务端蓝图检查(Blueprint.issues)里说「缺」的那几种:缺必填项、没对到表、岗位没人。卡片上的「缺」以它为准——项的状态 - * 默认就是缺(单位、筛选条件这些可以不填),只看项的状态会把选填的都标成缺 + * 服务端蓝图检查(Blueprint.issues)里说「缺」的那几种:缺必填项、没对到表、岗位没人、写回结果不明时没人核实。卡片上的 + * 「缺」以它为准——项的状态默认就是缺(单位、筛选条件这些可以不填),只看项的状态会把选填的都标成缺 */ -export const MISSING_CODES = new Set(['bp.missing', 'bp.data_unmapped', 'bp.people_unbound', 'tpl.joint_unfilled']) +export const MISSING_CODES = new Set([ + 'bp.missing', 'bp.data_unmapped', 'bp.people_unbound', 'bp.reconciler_unbound', 'tpl.joint_unfilled', +]) /** 卡片上标「缺」的位置:服务端给了检查结果时按它;老服务端没给时退回项本身的状态(null) */ export function missingPaths(bp: Blueprint | null | undefined): Set | null { diff --git a/frontend/src/typesBlueprint.ts b/frontend/src/typesBlueprint.ts index a7218b88..5e1a9fa7 100644 --- a/frontend/src/typesBlueprint.ts +++ b/frontend/src/typesBlueprint.ts @@ -49,6 +49,11 @@ export interface BpWriteback { capability: BpItem mapping: BpItem> confirm: BpItem + /** + * 结果不明时谁去核实(一组人或组):目标能力没登记查询时才要(Blueprint.reconciler_needed),发布时绑到应用的核实岗位。 + * 只由用户定,助手的改动服务端不收。旧蓝图没有这一项 + */ + reconciler?: BpItem<{ kind: 'user' | 'group'; name: string }[]> } export interface BpAccess { @@ -122,6 +127,11 @@ export interface Blueprint { graph_hash: string | null joints: Record copied_from: string | null + /** + * 写回卡片上要不要摆「结果不明时谁去核实」:模板用到写回、选了目标能力、这项能力没登记查询(服务端 + * templates/_common.needs_reconciler,界面不自己判)。老服务端不给时按不要 + */ + reconciler_needed?: boolean /** 蓝图检查(bp.* / tpl.*),带白话 */ issues?: PlainIssue[] created_at: string diff --git a/scripts/check-blueprint.mjs b/scripts/check-blueprint.mjs index 2f757ecf..eb86799e 100644 --- a/scripts/check-blueprint.mjs +++ b/scripts/check-blueprint.mjs @@ -8,6 +8,7 @@ // 应用首页的「新建应用」链到 /apps/create; // - 卡片:卡头标状态(缺按服务端检查、待确认、已确认),对到数据的项写表、中文名、粒度、业务日期、为什么;「修改」发 bp_set // (纯值),确认只改状态,「确认全部」一次带上整张卡片待确认的几项,加一项发 bp_add(纯值);缺项时「用真数试一次」置灰并写清缺什么; +// - 写回卡片:目标能力没登记查询时摆「结果不明时谁去核实」(服务端 reconciler_needed),缺时只挡发布,选人发 bp_set; // - 反问:助手的 bp_ask 画成可点的选项,「这一轮在等你回答」常显,输入框提示回答;点选项就是把它作为下一轮发出去(回答记在 // 问题上、这一轮落进蓝图对话都是服务端做的,前端不另记); // - 关节:只列生成关节,按卡片填的汇总一句;「让助手填」走 fill-joints-stream,逐组写进度,组之间并行; @@ -74,7 +75,7 @@ const TEMPLATES = [ { id: 'approval', version: 1, label: '审批流程', description: '有人发起、有人审批,可以按金额分级', applies_when: '', tags: [], personal: null, cards: [{ card: 'purpose', note: '必填' }, { card: 'fields', note: '必填:发起时填什么' }, { card: 'people', note: '必填:谁审批' }, - { card: 'access', note: '' }] }, + { card: 'writeback', note: '可选:通过后写回哪个系统' }, { card: 'access', note: '' }] }, { id: 'freeform', version: 1, label: '自由形', description: '从已有的运行或工作流导入', applies_when: '', tags: [], personal: '只读部分', cards: [{ card: 'purpose', note: '' }, { card: 'cadence', note: '' }, { card: 'access', note: '' }] }, ] @@ -491,6 +492,64 @@ await section('蓝图页:卡片的状态、对到的数据、改一项、缺 check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '') }) +// ---------------------------------------------------------------- 写回:结果不明时谁去核实 + +/** 审批流程的蓝图:通过后写回一项没登记查询的写入能力(服务端说要定核实人:reconciler_needed) */ +const CLAIM_SPEC = { + ...clone(SPEC), template: { id: 'approval', version: 1 }, data: [], cadence: item(null), + people: [{ id: 's1', label: item('主管审批', 'user'), who: item([{ kind: 'user', name: 'zhang' }], 'user'), + policy: item({ kind: 'any' }, 'user'), due: item(null), on_timeout: item(null), threshold: item(null) }], + fields: [{ name: 'amount', spec: item({ type: 'number', title: '金额' }, 'user'), sensitive: false }], + writeback: { capability: item('erp_claim', 'user'), mapping: item({ amount: 'form.amount' }, 'user'), + confirm: item(true, 'user'), reconciler: item(null) }, +} +const RECONCILER_UNBOUND = { code: 'bp.reconciler_unbound', level: 'error', path: '/writeback/reconciler', + message: '「erp_claim」没登记查询:写回的结果不明时要有人去目标系统里核实,还没定是谁', + plain: { title: '写回的结果不明时没人核实', why: '平台查不了,也不会自动重发', who: 'you', + action: '在「写回哪里」卡片的「结果不明时谁去核实」里选人或组。' } } +const CLAIM = (extra = {}) => ({ ...clone(BP), template_id: 'approval', spec: clone(CLAIM_SPEC), joints: {}, + issues: [RECONCILER_UNBOUND], reconciler_needed: true, ...extra }) + +await section('写回:结果不明时谁去核实(能力没登记查询才摆,只能由人选,只挡发布)', async () => { + const state = { bp: CLAIM() } + const { page } = await open({ state }) + await page.goto(`${WEB}/blueprints/bp_week`) + await page.locator('[data-card="writeback"]').waitFor({ timeout: 15000 }) + const row = page.locator('[data-bp-item="/writeback/reconciler"]') + check('能力没登记查询:摆出「结果不明时谁去核实」,标「缺」,写明为什么要人核实', + await row.count() === 1 && await row.getAttribute('data-item-status') === 'missing' + && await page.locator('[data-bp-reconciler-hint="needed"]').count() === 1 + && await page.locator('[data-card="writeback"]').getAttribute('data-card-status') === 'missing') + check('卡片上照白话写缺什么、怎么改', (await page.locator('[data-card="writeback"] [data-card-issues]').innerText()) + .includes('写回的结果不明时没人核实')) + check('只挡发布:「用真数试一次」照样能点', !(await page.locator('[data-blueprint-trial]').isDisabled())) + await row.locator('[data-bp-edit]').click() + const editor = row.locator('[data-bp-editor]') + await editor.locator('select').first().selectOption('group') + await editor.locator('[data-principal-picker] input').fill('财务组') + await editor.locator('[data-principal-picker] input').press('Enter') + await row.locator('[data-bp-save]').click() + check('选人:bp_set /writeback/reconciler,值是一组人或组', await until(() => (state.patches ?? []).length === 1) + && JSON.stringify(state.patches[0].ops) === JSON.stringify([ + { op: 'bp_set', path: '/writeback/reconciler', value: [{ kind: 'group', name: '财务组' }] }]), + JSON.stringify(state.patches?.[0])) + check('没有未捕获的运行时错误', page.errors.length === 0, page.errors[0] ?? '') + + // 能力登记了查询:平台自己查,不摆这一项;以前选过的照样摆出来,写明用不上 + const looked = { bp: CLAIM({ issues: [], reconciler_needed: false }) } + const second = await open({ state: looked }) + await second.page.goto(`${WEB}/blueprints/bp_week`) + await second.page.locator('[data-card="writeback"]').waitFor({ timeout: 15000 }) + check('登记了查询、没选过人:不摆这一项', await second.page.locator('[data-bp-item="/writeback/reconciler"]').count() === 0) + const chosen = CLAIM({ issues: [], reconciler_needed: false }) + chosen.spec.writeback.reconciler = item([{ kind: 'group', name: '财务组' }], 'user') + const third = await open({ state: { bp: chosen } }) + await third.page.goto(`${WEB}/blueprints/bp_week`) + await third.page.locator('[data-card="writeback"]').waitFor({ timeout: 15000 }) + check('登记了查询、以前选过人:照样摆出来,写明用不上', await third.page.locator('[data-bp-item="/writeback/reconciler"]').count() === 1 + && await third.page.locator('[data-bp-reconciler-hint="unused"]').count() === 1) +}) + // ---------------------------------------------------------------- 反问 await section('反问:选项可点、「这一轮在等你回答」常显、回答记在问题上', async () => {