feat: 发起时指派——assignPolicy 配置/assign_json 快照/引擎收窄/preview+apply 契约
build / build-push (push) Successful in 2m8s

This commit is contained in:
chenxing
2026-10-13 12:05:11 +08:00
parent c02bdc5eca
commit fd4e207ce0
13 changed files with 607 additions and 6 deletions
@@ -584,6 +584,131 @@ class OapiFlowContractIT extends FlowTestSupport {
assertEquals(1, def.getVersion().intValue(), "引用自注册行的 SERVICE 定义应发布成功");
}
// ===== 发起时指派契约(2026-10-13):apply/preview + apply assigns 严格校验 =====
/** 指派契约用例定义:START → n1(可配 assignPolicy)→ n2(不开指派)→ END,两节点同科室 */
private String assignDef(String deptTag, String n1Policy) {
return "{"
+ "\"code\":\"t40\",\"name\":\"指派契约流程\",\"start\":\"start\","
+ "\"nodes\":["
+ "{\"id\":\"start\",\"type\":\"START\",\"name\":\"开始\"},"
+ "{\"id\":\"n1\",\"type\":\"APPROVAL\",\"name\":\"首审\","
+ "\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + dk(deptTag) + "\"},"
+ "\"countersign\":\"ANY\"" + n1Policy + "},"
+ "{\"id\":\"n2\",\"type\":\"APPROVAL\",\"name\":\"复审\","
+ "\"assignee\":{\"type\":\"DEPT_KEY\",\"deptKey\":\"" + dk(deptTag) + "\"}},"
+ "{\"id\":\"end\",\"type\":\"END\"}"
+ "],"
+ "\"edges\":[{\"from\":\"start\",\"to\":\"n1\"},{\"from\":\"n1\",\"to\":\"n2\"},"
+ "{\"from\":\"n2\",\"to\":\"end\"}],"
+ "\"settings\":{\"rejectPolicy\":\"END_REJECTED\"}}";
}
/** 预览契约:只暴露开启指派节点;candidates 带姓名且剔除提交人;selection 缺省补齐;countersign 透传 */
@Test
void applyPreview_exposesOnlyDesignatableNodes() {
mapDept("ybb", 101, "甲");
mapDept("ybb", 201, "张三");
FlwDef def = saveAndPublishDef("t41", "指派预览",
assignDef("ybb", ",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1}"));
ResponseEntity<JsonNode> resp = client.get("/oapi/flow/apply/preview?schId=1&flowGuid="
+ def.getFlowGuid() + "&submitterId=201", JsonNode.class);
assertEquals(200, resp.getStatusCodeValue());
assertEquals(0, resp.getBody().get("code").asInt());
JsonNode data = unwrap(resp.getBody());
assertEquals(def.getFlowGuid(), data.get("flowGuid").asText());
JsonNode nodes = data.get("nodes");
assertEquals(1, nodes.size(), "只暴露开启指派的节点(n2 未开不透出)");
JsonNode n1 = nodes.get(0);
assertEquals("n1", n1.get("nodeId").asText());
assertEquals("首审", n1.get("nodeName").asText());
assertEquals("ANY", n1.get("countersign").asText(), "countersign 透传");
JsonNode selection = n1.get("selection");
assertEquals("MULTI", selection.get("mode").asText());
assertEquals(1, selection.get("min").asInt());
assertEquals(1, selection.get("max").asInt(), "max 缺省=剔除提交人后的候选数");
assertFalse(selection.get("required").asBoolean(), "required 缺省补 false");
JsonNode candidates = n1.get("candidates");
assertEquals(1, candidates.size(), "候选剔除提交人本人");
assertEquals(101, candidates.get(0).get("userId").asInt());
assertEquals("甲", candidates.get(0).get("userName").asText());
}
/** 预览降级(I6):开启节点挂无映射科室 → 该节点 candidates=[],接口照常 200 */
@Test
void applyPreview_deadDept_degradesToEmptyCandidates() {
mapDept("ybb", 101, "甲");
FlwDef def = saveAndPublishDef("t41b", "指派预览降级",
assignDef("nobody", ",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":2}"));
ResponseEntity<JsonNode> resp = client.get("/oapi/flow/apply/preview?schId=1&flowGuid="
+ def.getFlowGuid() + "&submitterId=201", JsonNode.class);
assertEquals(200, resp.getStatusCodeValue());
assertEquals(0, resp.getBody().get("code").asInt());
JsonNode n1 = unwrap(resp.getBody()).get("nodes").get(0);
assertEquals("n1", n1.get("nodeId").asText());
assertEquals(0, n1.get("candidates").size(), "解析异常节点降级空候选");
}
/** 合法 assigns:200 + 快照落库(姓名走 requireUserName 严格口径)+ 只为被指派人建待办 */
@Test
void apply_withAssigns_ok() throws Exception {
mapDept("ybb", 101, "甲");
mapDept("ybb", 102, "乙");
FlwDef def = saveAndPublishDef("t42", "指派发起",
assignDef("ybb", ",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":2}"));
String body = "{"
+ "\"flowGuid\":\"" + def.getFlowGuid() + "\","
+ "\"bizType\":\"svc_item\",\"bizId\":\"apply-as1\",\"bizTitle\":\"指派件\","
+ "\"submitterId\":201,\"submitterName\":\"张三\","
+ "\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[101]}]}";
ResponseEntity<JsonNode> resp = client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
assertEquals(0, resp.getBody().get("code").asInt());
Long instanceId = unwrap(resp.getBody()).get("instanceId").asLong();
JsonNode snap = objectMapper.readTree(
instanceRepository.findById(instanceId).get().getAssignJson());
assertEquals(101, snap.get("n1").get(0).get("userId").asInt());
assertEquals("用户101", snap.get("n1").get(0).get("userName").asText(), "姓名=UserBase 严格口径");
assertEquals(1, taskRepository.findByInstanceIdAndStatus(instanceId, 0).size());
assertEquals(101, taskRepository.findByInstanceIdAndStatus(instanceId, 0).get(0).getAssigneeId());
}
/** 拒绝矩阵(I7/I3):六种非法 assigns 均业务码拒绝且办件未创建 */
@Test
void apply_assigns_rejected_matrix() {
mapDept("ybb", 101, "甲");
mapDept("ybb", 102, "乙");
FlwDef def = saveAndPublishDef("t43", "指派拒绝矩阵",
assignDef("ybb", ",\"assignPolicy\":{\"mode\":\"MULTI\",\"min\":1,\"max\":1}"));
FlwDef singleDef = saveAndPublishDef("t44", "单选拒绝",
assignDef("ybb", ",\"assignPolicy\":{\"mode\":\"SINGLE\"}"));
assertAssignRejected(def.getFlowGuid(), "as-x1", ",\"assigns\":[{\"nodeId\":\"n2\",\"userIds\":[101]}]}",
"未开启指派节点应拒绝");
assertAssignRejected(def.getFlowGuid(), "as-x2", ",\"assigns\":[{\"nodeId\":\"n9\",\"userIds\":[101]}]}",
"nodeId 不存在应拒绝");
assertAssignRejected(def.getFlowGuid(), "as-x3", ",\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[999]}]}",
"越候选集应拒绝");
assertAssignRejected(def.getFlowGuid(), "as-x4", ",\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[201]}]}",
"指派发起人本人应拒绝");
assertAssignRejected(singleDef.getFlowGuid(), "as-x5",
",\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[101,102]}]}", "SINGLE 传 2 人应拒绝");
assertAssignRejected(def.getFlowGuid(), "as-x6",
",\"assigns\":[{\"nodeId\":\"n1\",\"userIds\":[101,102]}]}", "MULTI 超 max 应拒绝");
}
/** 断言一次带 assigns 的 apply 被业务码拒绝且办件未创建 */
private void assertAssignRejected(String flowGuid, String bizId, String assignsSegment, String message) {
String body = "{"
+ "\"flowGuid\":\"" + flowGuid + "\","
+ "\"bizType\":\"svc_item\",\"bizId\":\"" + bizId + "\",\"bizTitle\":\"矩阵\","
+ "\"submitterId\":201,\"submitterName\":\"张三\"" + assignsSegment;
ResponseEntity<JsonNode> resp = client.post("/oapi/flow/apply?schId=1", body, JsonNode.class);
assertEquals(200, resp.getStatusCodeValue());
assertNotEquals(0, resp.getBody().get("code").asInt(), message);
assertFalse(instanceRepository.findBySchIdAndBizTypeAndBizId(1L, "svc_item", bizId).isPresent(),
message + "(办件未创建)");
}
/** 轮询等异步回调推送(AFTER_COMMIT + 推送池),最长约 5s */
private boolean awaitPayloadContains(List<String> payloads, String needle) throws InterruptedException {
for (int i = 0; i < 25; i++) {