Commit 58b65d9d2e85ac0314de162252471a5b28a4f4ba

Authored by zichun
1 parent 429bef5f

feat: FK 候选优先走 ERP 下拉配置,端点缺失时回落本地字典

ErpClient.fieldOptions 调 /ai/fieldOptions(契约见 docs/erp-tasks-field-options.md):
ERP 按其控件配置给候选,带行级数据权限、级联过滤(产品限定在已选客户名下)、
联动回填映射——这三样本地直查库都没有。404/异常/mode=none 一律回落到
FormResolverService.fkOptionPage,5 分钟退避避免反复撞未上线的端点。

前端 FK 选择器改传 (formId, 字段, ctx):ctx = 表单上已绑定记录的 id,供级联过滤;
ERP 报 need_context 时提示'请先选择客户'而不是给空列表。预览卡新增 formId 与
fk 字段的 boundId,使修改单据时也有初始级联上下文。
src/main/java/com/xly/service/ErpClient.java
... ... @@ -57,6 +57,9 @@ public class ErpClient {
57 57 private String password;
58 58  
59 59 private volatile String cachedToken;
  60 + /** /ai/fieldOptions 返回 404 后的静默期(ERP 侧尚未上线时不必每次点击都撞一次)。 */
  61 + private static final long ENDPOINT_MISSING_BACKOFF_MS = 5 * 60 * 1000L;
  62 + private volatile long endpointMissingUntil;
60 63  
61 64 public ErpClient(ObjectMapper mapper) {
62 65 this.mapper = mapper;
... ... @@ -130,6 +133,67 @@ public class ErpClient {
130 133 }
131 134  
132 135 /**
  136 + * 字段候选值({@code POST /ai/fieldOptions},见 {@code docs/erp-tasks-field-options.md}):按
  137 + * ERP 自己的下拉控件配置取候选——**行级数据权限、级联过滤(如产品限定在已选客户名下)、联动回填映射
  138 + * 都由 ERP 给**,这三样我们自己直查库时没有。
  139 + *
  140 + * <p>返回 ERP 的载荷对象(含 {@code mode});端点不存在、报错、超时一律返回 null,
  141 + * 由调用方回落到本地字典查询({@link FormResolverService#fkOptionPage}),保底可用。
  142 + *
  143 + * @param context 表单上已选好的其它字段值(字段名→**已绑定的 id**),供级联条件使用;可空
  144 + */
  145 + public JsonNode fieldOptions(String authToken, String formId, String field, String q,
  146 + int page, int pageSize, Map<String, String> context) {
  147 + if (endpointMissingUntil > System.currentTimeMillis()) {
  148 + return null; // 端点尚未上线:短路一段时间,别每次点击都去撞 404
  149 + }
  150 + try {
  151 + Map<String, Object> body = new LinkedHashMap<>();
  152 + body.put("sFormId", formId);
  153 + body.put("sField", field);
  154 + body.put("q", q == null ? "" : q);
  155 + body.put("pageNum", page);
  156 + body.put("pageSize", pageSize);
  157 + body.put("context", context == null ? Map.of() : context);
  158 + HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/ai/fieldOptions"))
  159 + .header("Content-Type", "application/json;charset=UTF-8")
  160 + .header("Authorization", resolveToken(authToken))
  161 + .timeout(Duration.ofSeconds(20))
  162 + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), StandardCharsets.UTF_8))
  163 + .build();
  164 + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
  165 + if (resp.statusCode() == 404 || resp.statusCode() == 405) {
  166 + endpointMissingUntil = System.currentTimeMillis() + ENDPOINT_MISSING_BACKOFF_MS;
  167 + log.info("ERP /ai/fieldOptions 尚未上线(HTTP {}),{} 分钟内回落本地字典查询",
  168 + resp.statusCode(), ENDPOINT_MISSING_BACKOFF_MS / 60000);
  169 + return null;
  170 + }
  171 + JsonNode root = mapper.readTree(resp.body());
  172 + JsonNode payload = pickFieldOptionsPayload(root);
  173 + return payload != null && payload.has("mode") ? payload : null;
  174 + } catch (Exception e) {
  175 + log.warn("fieldOptions failed (form={}, field={}): {}", formId, field, e.getMessage());
  176 + return null;
  177 + }
  178 + }
  179 +
  180 + /** 载荷可能在根、dataset、或 dataset.rows[0](ERP 的 Feedback 信封有几种装法)。 */
  181 + private static JsonNode pickFieldOptionsPayload(JsonNode root) {
  182 + if (root == null) {
  183 + return null;
  184 + }
  185 + if (root.has("mode")) {
  186 + return root;
  187 + }
  188 + JsonNode ds = root.path("dataset");
  189 + if (ds.has("mode")) {
  190 + return ds;
  191 + }
  192 + JsonNode row = ds.path("rows").path(0);
  193 + return row.isMissingNode() ? null : row;
  194 + }
  195 +
  196 + /**
133 197 * token 内省:把透传的用户 token 换成 ERP **服务端认定**的身份(/ai/whoami,@CurrentUser 解析)。
134 198 * 返回 {sId,sUserNo,sUserName,sType,sBrandsId,sSubsidiaryId};token 无效/过期返回 null。
135 199 */
... ...
src/main/java/com/xly/service/PreviewService.java
... ... @@ -138,6 +138,9 @@ public class PreviewService {
138 138  
139 139 Map<String, Object> cf = new LinkedHashMap<>(f);
140 140 cf.put("value", shown);
  141 + if (ffk != null && !raw.isBlank()) {
  142 + cf.put("boundId", raw); // 该记录当前绑定的 id:ERP 级联下拉的初始上下文
  143 + }
141 144 if (name.equals(col)) {
142 145 cf.put("changed", true);
143 146 cf.put("newValue", n.shown);
... ... @@ -177,6 +180,7 @@ public class PreviewService {
177 180 card.put("previewId", previewId);
178 181 card.put("action", "update");
179 182 card.put("entity", entity);
  183 + card.put("formId", l.formId); // FK 选择器按 (formId,字段) 走 ERP 下拉配置
180 184 card.put("recordName", l.recordName);
181 185 card.put("title", "修改「" + l.recordName + "」(" + entity + ")");
182 186 card.put("summary", description);
... ...
src/main/java/com/xly/web/FormController.java
1 1 package com.xly.web;
2 2  
  3 +import com.fasterxml.jackson.databind.JsonNode;
  4 +import com.fasterxml.jackson.databind.ObjectMapper;
3 5 import com.xly.agent.AgentIdentity;
4 6 import com.xly.service.AuthzService;
  7 +import com.xly.service.ErpClient;
5 8 import com.xly.service.FormResolverService;
  9 +import org.slf4j.Logger;
  10 +import org.slf4j.LoggerFactory;
  11 +import org.springframework.beans.factory.annotation.Value;
6 12 import org.springframework.http.HttpStatus;
7 13 import org.springframework.web.bind.annotation.GetMapping;
8 14 import org.springframework.web.bind.annotation.RequestHeader;
... ... @@ -11,28 +17,51 @@ import org.springframework.web.bind.annotation.RequestParam;
11 17 import org.springframework.web.bind.annotation.RestController;
12 18 import org.springframework.web.server.ResponseStatusException;
13 19  
  20 +import java.util.ArrayList;
  21 +import java.util.LinkedHashMap;
  22 +import java.util.List;
14 23 import java.util.Map;
15 24  
16 25 /**
17   - * FormCollect 的**FK 二级选择器**数据端点:外键字段(客户/产品/物料…)的候选从对应表实时取
  26 + * FormCollect 的**FK 二级选择器**数据端点:外键字段(客户/产品/物料…)的候选
18 27 * 多列展示 + 名称搜索 + 分页。
19 28 *
20   - * <p>前端调 {@code GET /api/agent/form/options?table=elecustomer&q=..&page=1&pageSize=20},
21   - * 返回 {@code {columns:[{col,label}…], rows:[{sId, 列:值…}], total, page, pageSize}}。
  29 + * <p>两条取数路径,优先前者:
  30 + * <ol>
  31 + * <li><b>ERP 下拉配置</b>({@code POST /ai/fieldOptions},需 formId+field)——候选口径与 ERP 网页
  32 + * 完全一致:带**行级数据权限**、**级联过滤**(如产品限定在已选客户名下)、**联动回填映射**。</li>
  33 + * <li><b>本地字段字典</b>({@link FormResolverService#fkOptionPage})——ERP 端点未上线/无该字段配置/
  34 + * 调用失败时的保底,行为同改造前。</li>
  35 + * </ol>
22 36 *
23   - * <p><b>安全</b>:需登录;**租户来自服务端内省的身份**(不接受客户端传 brandsid,否则不传即可跨租户全库翻页);
24   - * 表名限于「在字段字典里作为外键目标出现过」的白名单(见 {@link FormResolverService#fkOptionPage})。
  37 + * <p>前端调 {@code GET /api/agent/form/options?table=elecustomer&formId=..&field=sCustomerId&q=..&page=1},
  38 + * 返回 {@code {columns:[{col,label}…], rows:[{sId, 列:值…}], total, page, pageSize, valueField?, assign?}};
  39 + * 级联条件未满足时返回 {@code {needContext:true, requires:[…], message:"请先选择客户"}}。
  40 + *
  41 + * <p><b>安全</b>:需登录;本地路径的租户来自服务端内省的身份(不接受客户端传 brandsid,否则不传即可跨租户
  42 + * 全库翻页),且表名限于「在字段字典里作为外键目标出现过」并被有权表单引用(见
  43 + * {@link FormResolverService#fkTableAccessible});ERP 路径由 ERP 用用户 token 自行鉴权。
25 44 */
26 45 @RestController
27 46 @RequestMapping("/api/agent/form")
28 47 public class FormController {
29 48  
  49 + private static final Logger log = LoggerFactory.getLogger(FormController.class);
  50 +
30 51 private final FormResolverService resolver;
31 52 private final AuthzService authz;
  53 + private final ErpClient erp;
  54 + private final ObjectMapper mapper;
  55 +
  56 + /** ERP 字段候选接口开关(默认开:端点未上线时会自动回落,不影响可用)。 */
  57 + @Value("${erp.field-options.enabled:true}")
  58 + private boolean erpFieldOptionsEnabled;
32 59  
33   - public FormController(FormResolverService resolver, AuthzService authz) {
  60 + public FormController(FormResolverService resolver, AuthzService authz, ErpClient erp, ObjectMapper mapper) {
34 61 this.resolver = resolver;
35 62 this.authz = authz;
  63 + this.erp = erp;
  64 + this.mapper = mapper;
36 65 }
37 66  
38 67 @GetMapping("/options")
... ... @@ -40,15 +69,101 @@ public class FormController {
40 69 @RequestParam(value = "q", required = false) String q,
41 70 @RequestParam(value = "page", defaultValue = "1") int page,
42 71 @RequestParam(value = "pageSize", defaultValue = "20") int pageSize,
  72 + @RequestParam(value = "formId", required = false) String formId,
  73 + @RequestParam(value = "field", required = false) String field,
  74 + @RequestParam(value = "ctx", required = false) String ctxJson,
43 75 @RequestHeader(value = "Authorization", required = false) String auth) {
44 76 AgentIdentity id = authz.resolveIdentity(auth);
45 77 if (id == null) {
46 78 throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录");
47 79 }
  80 + Map<String, Object> viaErp = tryErp(id, formId, field, q, page, pageSize, ctxJson);
  81 + if (viaErp != null) {
  82 + return viaErp;
  83 + }
48 84 // 仅登录不够:该 FK 表必须被用户有权的某张表单引用,否则无表单权限的用户也能翻完客户名录
49 85 if (!resolver.fkTableAccessible(table, id)) {
50 86 throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该数据");
51 87 }
52 88 return resolver.fkOptionPage(table, id.brandsId(), q, page, pageSize);
53 89 }
  90 +
  91 + /** 走 ERP 下拉配置;不可用(未上线/该字段无配置/异常)返回 null 让调用方回落。 */
  92 + private Map<String, Object> tryErp(AgentIdentity id, String formId, String field, String q,
  93 + int page, int pageSize, String ctxJson) {
  94 + if (!erpFieldOptionsEnabled || isBlank(formId) || isBlank(field)) {
  95 + return null;
  96 + }
  97 + JsonNode r = erp.fieldOptions(id.token(), formId.trim(), field.trim(), q, page, pageSize, parseCtx(ctxJson));
  98 + if (r == null) {
  99 + return null;
  100 + }
  101 + String mode = r.path("mode").asText("");
  102 + if ("need_context".equals(mode)) {
  103 + Map<String, Object> out = new LinkedHashMap<>();
  104 + out.put("needContext", true);
  105 + out.put("requires", textList(r.path("requires")));
  106 + String msg = r.path("msg").asText("");
  107 + out.put("message", msg.isBlank() ? "请先选择上级字段,再选这一项。" : msg);
  108 + return out;
  109 + }
  110 + if (!"list".equals(mode)) {
  111 + return null; // mode=none 等:该字段在 ERP 侧没有可列的候选,回落本地字典
  112 + }
  113 + return normalize(r);
  114 + }
  115 +
  116 + /** ERP 载荷 → 前端既有的 {columns, rows, total, …} 形状,额外带上 valueField/assign。 */
  117 + private Map<String, Object> normalize(JsonNode r) {
  118 + List<Map<String, Object>> columns = new ArrayList<>();
  119 + for (JsonNode c : r.path("columns")) {
  120 + String col = c.path("col").asText("");
  121 + if (!col.isBlank()) {
  122 + columns.add(Map.of("col", col, "label", c.path("label").asText(col)));
  123 + }
  124 + }
  125 + List<Map<String, Object>> rows = new ArrayList<>();
  126 + for (JsonNode row : r.path("rows")) {
  127 + Map<String, Object> m = mapper.convertValue(row, Map.class);
  128 + rows.add(m);
  129 + }
  130 + Map<String, Object> out = new LinkedHashMap<>();
  131 + out.put("columns", columns);
  132 + out.put("rows", rows);
  133 + out.put("total", r.path("total").asInt(rows.size()));
  134 + out.put("page", r.path("pageNum").asInt(1));
  135 + out.put("pageSize", r.path("pageSize").asInt(rows.size()));
  136 + out.put("nameField", r.path("nameField").asText(columns.isEmpty() ? "" : String.valueOf(columns.get(0).get("col"))));
  137 + out.put("valueField", r.path("valueField").asText("sId"));
  138 + out.put("assign", r.has("assign") ? mapper.convertValue(r.path("assign"), Map.class) : Map.of());
  139 + out.put("source", "erp");
  140 + return out;
  141 + }
  142 +
  143 + /** ctx = 表单上已绑定的其它字段值(字段名→id),供 ERP 的级联条件用。解析失败按无上下文处理。 */
  144 + private Map<String, String> parseCtx(String ctxJson) {
  145 + if (isBlank(ctxJson)) {
  146 + return Map.of();
  147 + }
  148 + try {
  149 + @SuppressWarnings("unchecked")
  150 + Map<String, String> m = mapper.readValue(ctxJson, Map.class);
  151 + return m;
  152 + } catch (Exception e) {
  153 + log.debug("ctx 解析失败,按无上下文处理: {}", e.getMessage());
  154 + return Map.of();
  155 + }
  156 + }
  157 +
  158 + private static List<String> textList(JsonNode arr) {
  159 + List<String> out = new ArrayList<>();
  160 + for (JsonNode n : arr) {
  161 + out.add(n.asText());
  162 + }
  163 + return out;
  164 + }
  165 +
  166 + private static boolean isBlank(String s) {
  167 + return s == null || s.isBlank();
  168 + }
54 169 }
... ...
src/main/resources/templates/chat.html
... ... @@ -575,6 +575,9 @@ function addPreviewCard(ev) {
575 575 card.appendChild(m);
576 576 }
577 577 const controls = []; // {label, get()} —— 仅 editable 预览收集
  578 + const bound = {}; // 已选中记录的 id(字段名→id),供 ERP 级联下拉过滤
  579 + (ev.fields || []).forEach(f => { if (f.boundId) bound[f.name] = f.boundId; }); // 记录原有绑定作初始上下文
  580 + const ctxOf = () => Object.assign({}, bound);
578 581 if (ev.editable) {
579 582 const grid = el("div", "form-grid");
580 583 (ev.fields || []).forEach(f => {
... ... @@ -591,7 +594,13 @@ function addPreviewCard(ev) {
591 594 inp.placeholder = "点击选择…";
592 595 inp.value = initial;
593 596 const pick = el("button", "btn", "选择");
594   - pick.onclick = () => openFkPicker(f.fkTable, f.label || f.name, (name) => { inp.value = name; });
  597 + pick.onclick = () => openFkPicker(
  598 + { fkTable: f.fkTable, formId: ev.formId, name: f.name, ctx: ctxOf },
  599 + f.label || f.name,
  600 + (name, id) => {
  601 + inp.value = name;
  602 + if (id) bound[f.name] = id; else delete bound[f.name];
  603 + });
595 604 row.appendChild(inp);
596 605 row.appendChild(pick);
597 606 wrap.appendChild(row);
... ... @@ -720,6 +729,9 @@ function addFormCard(ev) {
720 729 if (ev.message) card.appendChild(el("div", "muted", ev.message));
721 730 const grid = el("div", "form-grid");
722 731 const controls = []; // {label, required, get()}
  732 + // 已选中记录的 id(字段名→id):ERP 的级联下拉要靠它过滤(如产品限定在已选客户名下)
  733 + const bound = {};
  734 + const ctxOf = () => Object.assign({}, bound);
723 735  
724 736 (ev.fields || []).forEach(f => {
725 737 const wrap = el("div", "ffield");
... ... @@ -736,7 +748,13 @@ function addFormCard(ev) {
736 748 inp.placeholder = "点击选择…";
737 749 if (f.default) inp.value = f.default;
738 750 const pick = el("button", "btn", "选择");
739   - pick.onclick = () => openFkPicker(f.fkTable, f.label || f.name, (name) => { inp.value = name; });
  751 + pick.onclick = () => openFkPicker(
  752 + { fkTable: f.fkTable, formId: ev.formId, name: f.name, ctx: ctxOf },
  753 + f.label || f.name,
  754 + (name, id) => {
  755 + inp.value = name;
  756 + if (id) bound[f.name] = id; else delete bound[f.name];
  757 + });
740 758 row.appendChild(inp);
741 759 row.appendChild(pick);
742 760 wrap.appendChild(row);
... ... @@ -823,11 +841,16 @@ function addFormCard(ev) {
823 841 };
824 842 }
825 843  
826   -/* ================= FK 二级选择器(列表/搜索/分页/选择) ================= */
827   -const fk = { table: null, q: "", page: 1, pageSize: 20, total: 0, onPick: null };
  844 +/* ================= FK 二级选择器(列表/搜索/分页/选择) =================
  845 + 候选优先由 ERP 按其下拉配置给(带行级权限/级联过滤/联动回填),故要把 formId、字段名、
  846 + 以及本表单上**已绑定的其它字段 id**(ctx)一并带上;ERP 说缺上级字段时提示用户先选那一个。 */
  847 +const fk = { table: null, formId: null, field: null, ctx: null, q: "", page: 1, pageSize: 20, total: 0, onPick: null };
828 848  
829   -function openFkPicker(table, title, onPick) {
830   - fk.table = table;
  849 +function openFkPicker(opts, title, onPick) {
  850 + fk.table = opts.fkTable;
  851 + fk.formId = opts.formId || null;
  852 + fk.field = opts.name || null;
  853 + fk.ctx = opts.ctx || null;
831 854 fk.q = "";
832 855 fk.page = 1;
833 856 fk.onPick = onPick;
... ... @@ -842,10 +865,22 @@ async function loadFkPage() {
842 865 const body = $("#fkBody");
843 866 body.innerHTML = '<div style="padding:20px;color:#888">加载中…</div>';
844 867 try {
845   - const url = BASE + "/api/agent/form/options?table=" + encodeURIComponent(fk.table)
  868 + let url = BASE + "/api/agent/form/options?table=" + encodeURIComponent(fk.table)
846 869 + "&q=" + encodeURIComponent(fk.q)
847 870 + "&page=" + fk.page + "&pageSize=" + fk.pageSize;
  871 + if (fk.formId && fk.field) {
  872 + url += "&formId=" + encodeURIComponent(fk.formId) + "&field=" + encodeURIComponent(fk.field);
  873 + const ctx = typeof fk.ctx === "function" ? fk.ctx() : fk.ctx;
  874 + if (ctx && Object.keys(ctx).length) url += "&ctx=" + encodeURIComponent(JSON.stringify(ctx));
  875 + }
848 876 const d = await (await fetch(url, { headers: authHeaders(false) })).json();
  877 + if (d.needContext) {
  878 + body.innerHTML = '<div style="padding:20px;color:#8a6d3b">' + (d.message || "请先选择上级字段。") + "</div>";
  879 + $("#fkPageInfo").textContent = "";
  880 + $("#fkPrev").disabled = true;
  881 + $("#fkNext").disabled = true;
  882 + return;
  883 + }
849 884 fk.total = d.total || 0;
850 885 const cols = d.columns || [];
851 886 const rows = d.rows || [];
... ... @@ -856,13 +891,14 @@ async function loadFkPage() {
856 891 thead.appendChild(trh);
857 892 table.appendChild(thead);
858 893 const tbody = el("tbody");
859   - const nameCol = cols.length ? cols[0].col : null;
  894 + const nameCol = d.nameField || (cols.length ? cols[0].col : null);
  895 + const valueCol = d.valueField || "sId";
860 896 rows.forEach(r => {
861 897 const tr = el("tr");
862 898 cols.forEach(c => tr.appendChild(el("td", null, r[c.col] == null ? "" : String(r[c.col]))));
863 899 tr.onclick = () => {
864 900 $("#modalMask").classList.remove("show");
865   - if (fk.onPick && nameCol) fk.onPick(String(r[nameCol] || ""));
  901 + if (fk.onPick && nameCol) fk.onPick(String(r[nameCol] || ""), r[valueCol] == null ? null : String(r[valueCol]));
866 902 };
867 903 tbody.appendChild(tr);
868 904 });
... ...