diff --git a/src/main/java/com/xly/service/ErpClient.java b/src/main/java/com/xly/service/ErpClient.java index ba19b8a..dcc985c 100644 --- a/src/main/java/com/xly/service/ErpClient.java +++ b/src/main/java/com/xly/service/ErpClient.java @@ -57,6 +57,9 @@ public class ErpClient { private String password; private volatile String cachedToken; + /** /ai/fieldOptions 返回 404 后的静默期(ERP 侧尚未上线时不必每次点击都撞一次)。 */ + private static final long ENDPOINT_MISSING_BACKOFF_MS = 5 * 60 * 1000L; + private volatile long endpointMissingUntil; public ErpClient(ObjectMapper mapper) { this.mapper = mapper; @@ -130,6 +133,67 @@ public class ErpClient { } /** + * 字段候选值({@code POST /ai/fieldOptions},见 {@code docs/erp-tasks-field-options.md}):按 + * ERP 自己的下拉控件配置取候选——**行级数据权限、级联过滤(如产品限定在已选客户名下)、联动回填映射 + * 都由 ERP 给**,这三样我们自己直查库时没有。 + * + *

返回 ERP 的载荷对象(含 {@code mode});端点不存在、报错、超时一律返回 null, + * 由调用方回落到本地字典查询({@link FormResolverService#fkOptionPage}),保底可用。 + * + * @param context 表单上已选好的其它字段值(字段名→**已绑定的 id**),供级联条件使用;可空 + */ + public JsonNode fieldOptions(String authToken, String formId, String field, String q, + int page, int pageSize, Map context) { + if (endpointMissingUntil > System.currentTimeMillis()) { + return null; // 端点尚未上线:短路一段时间,别每次点击都去撞 404 + } + try { + Map body = new LinkedHashMap<>(); + body.put("sFormId", formId); + body.put("sField", field); + body.put("q", q == null ? "" : q); + body.put("pageNum", page); + body.put("pageSize", pageSize); + body.put("context", context == null ? Map.of() : context); + HttpRequest req = HttpRequest.newBuilder(URI.create(baseUrl + "/ai/fieldOptions")) + .header("Content-Type", "application/json;charset=UTF-8") + .header("Authorization", resolveToken(authToken)) + .timeout(Duration.ofSeconds(20)) + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), StandardCharsets.UTF_8)) + .build(); + HttpResponse resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (resp.statusCode() == 404 || resp.statusCode() == 405) { + endpointMissingUntil = System.currentTimeMillis() + ENDPOINT_MISSING_BACKOFF_MS; + log.info("ERP /ai/fieldOptions 尚未上线(HTTP {}),{} 分钟内回落本地字典查询", + resp.statusCode(), ENDPOINT_MISSING_BACKOFF_MS / 60000); + return null; + } + JsonNode root = mapper.readTree(resp.body()); + JsonNode payload = pickFieldOptionsPayload(root); + return payload != null && payload.has("mode") ? payload : null; + } catch (Exception e) { + log.warn("fieldOptions failed (form={}, field={}): {}", formId, field, e.getMessage()); + return null; + } + } + + /** 载荷可能在根、dataset、或 dataset.rows[0](ERP 的 Feedback 信封有几种装法)。 */ + private static JsonNode pickFieldOptionsPayload(JsonNode root) { + if (root == null) { + return null; + } + if (root.has("mode")) { + return root; + } + JsonNode ds = root.path("dataset"); + if (ds.has("mode")) { + return ds; + } + JsonNode row = ds.path("rows").path(0); + return row.isMissingNode() ? null : row; + } + + /** * token 内省:把透传的用户 token 换成 ERP **服务端认定**的身份(/ai/whoami,@CurrentUser 解析)。 * 返回 {sId,sUserNo,sUserName,sType,sBrandsId,sSubsidiaryId};token 无效/过期返回 null。 */ diff --git a/src/main/java/com/xly/service/PreviewService.java b/src/main/java/com/xly/service/PreviewService.java index 1dafab5..2e0de50 100644 --- a/src/main/java/com/xly/service/PreviewService.java +++ b/src/main/java/com/xly/service/PreviewService.java @@ -138,6 +138,9 @@ public class PreviewService { Map cf = new LinkedHashMap<>(f); cf.put("value", shown); + if (ffk != null && !raw.isBlank()) { + cf.put("boundId", raw); // 该记录当前绑定的 id:ERP 级联下拉的初始上下文 + } if (name.equals(col)) { cf.put("changed", true); cf.put("newValue", n.shown); @@ -177,6 +180,7 @@ public class PreviewService { card.put("previewId", previewId); card.put("action", "update"); card.put("entity", entity); + card.put("formId", l.formId); // FK 选择器按 (formId,字段) 走 ERP 下拉配置 card.put("recordName", l.recordName); card.put("title", "修改「" + l.recordName + "」(" + entity + ")"); card.put("summary", description); diff --git a/src/main/java/com/xly/web/FormController.java b/src/main/java/com/xly/web/FormController.java index a375f97..00b3896 100644 --- a/src/main/java/com/xly/web/FormController.java +++ b/src/main/java/com/xly/web/FormController.java @@ -1,8 +1,14 @@ package com.xly.web; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import com.xly.agent.AgentIdentity; import com.xly.service.AuthzService; +import com.xly.service.ErpClient; import com.xly.service.FormResolverService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestHeader; @@ -11,28 +17,51 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.server.ResponseStatusException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; /** - * FormCollect 的**FK 二级选择器**数据端点:外键字段(客户/产品/物料…)的候选从对应表实时取, + * FormCollect 的**FK 二级选择器**数据端点:外键字段(客户/产品/物料…)的候选, * 多列展示 + 名称搜索 + 分页。 * - *

前端调 {@code GET /api/agent/form/options?table=elecustomer&q=..&page=1&pageSize=20}, - * 返回 {@code {columns:[{col,label}…], rows:[{sId, 列:值…}], total, page, pageSize}}。 + *

两条取数路径,优先前者: + *

    + *
  1. ERP 下拉配置({@code POST /ai/fieldOptions},需 formId+field)——候选口径与 ERP 网页 + * 完全一致:带**行级数据权限**、**级联过滤**(如产品限定在已选客户名下)、**联动回填映射**。
  2. + *
  3. 本地字段字典({@link FormResolverService#fkOptionPage})——ERP 端点未上线/无该字段配置/ + * 调用失败时的保底,行为同改造前。
  4. + *
* - *

安全:需登录;**租户来自服务端内省的身份**(不接受客户端传 brandsid,否则不传即可跨租户全库翻页); - * 表名限于「在字段字典里作为外键目标出现过」的白名单(见 {@link FormResolverService#fkOptionPage})。 + *

前端调 {@code GET /api/agent/form/options?table=elecustomer&formId=..&field=sCustomerId&q=..&page=1}, + * 返回 {@code {columns:[{col,label}…], rows:[{sId, 列:值…}], total, page, pageSize, valueField?, assign?}}; + * 级联条件未满足时返回 {@code {needContext:true, requires:[…], message:"请先选择客户"}}。 + * + *

安全:需登录;本地路径的租户来自服务端内省的身份(不接受客户端传 brandsid,否则不传即可跨租户 + * 全库翻页),且表名限于「在字段字典里作为外键目标出现过」并被有权表单引用(见 + * {@link FormResolverService#fkTableAccessible});ERP 路径由 ERP 用用户 token 自行鉴权。 */ @RestController @RequestMapping("/api/agent/form") public class FormController { + private static final Logger log = LoggerFactory.getLogger(FormController.class); + private final FormResolverService resolver; private final AuthzService authz; + private final ErpClient erp; + private final ObjectMapper mapper; + + /** ERP 字段候选接口开关(默认开:端点未上线时会自动回落,不影响可用)。 */ + @Value("${erp.field-options.enabled:true}") + private boolean erpFieldOptionsEnabled; - public FormController(FormResolverService resolver, AuthzService authz) { + public FormController(FormResolverService resolver, AuthzService authz, ErpClient erp, ObjectMapper mapper) { this.resolver = resolver; this.authz = authz; + this.erp = erp; + this.mapper = mapper; } @GetMapping("/options") @@ -40,15 +69,101 @@ public class FormController { @RequestParam(value = "q", required = false) String q, @RequestParam(value = "page", defaultValue = "1") int page, @RequestParam(value = "pageSize", defaultValue = "20") int pageSize, + @RequestParam(value = "formId", required = false) String formId, + @RequestParam(value = "field", required = false) String field, + @RequestParam(value = "ctx", required = false) String ctxJson, @RequestHeader(value = "Authorization", required = false) String auth) { AgentIdentity id = authz.resolveIdentity(auth); if (id == null) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录"); } + Map viaErp = tryErp(id, formId, field, q, page, pageSize, ctxJson); + if (viaErp != null) { + return viaErp; + } // 仅登录不够:该 FK 表必须被用户有权的某张表单引用,否则无表单权限的用户也能翻完客户名录 if (!resolver.fkTableAccessible(table, id)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该数据"); } return resolver.fkOptionPage(table, id.brandsId(), q, page, pageSize); } + + /** 走 ERP 下拉配置;不可用(未上线/该字段无配置/异常)返回 null 让调用方回落。 */ + private Map tryErp(AgentIdentity id, String formId, String field, String q, + int page, int pageSize, String ctxJson) { + if (!erpFieldOptionsEnabled || isBlank(formId) || isBlank(field)) { + return null; + } + JsonNode r = erp.fieldOptions(id.token(), formId.trim(), field.trim(), q, page, pageSize, parseCtx(ctxJson)); + if (r == null) { + return null; + } + String mode = r.path("mode").asText(""); + if ("need_context".equals(mode)) { + Map out = new LinkedHashMap<>(); + out.put("needContext", true); + out.put("requires", textList(r.path("requires"))); + String msg = r.path("msg").asText(""); + out.put("message", msg.isBlank() ? "请先选择上级字段,再选这一项。" : msg); + return out; + } + if (!"list".equals(mode)) { + return null; // mode=none 等:该字段在 ERP 侧没有可列的候选,回落本地字典 + } + return normalize(r); + } + + /** ERP 载荷 → 前端既有的 {columns, rows, total, …} 形状,额外带上 valueField/assign。 */ + private Map normalize(JsonNode r) { + List> columns = new ArrayList<>(); + for (JsonNode c : r.path("columns")) { + String col = c.path("col").asText(""); + if (!col.isBlank()) { + columns.add(Map.of("col", col, "label", c.path("label").asText(col))); + } + } + List> rows = new ArrayList<>(); + for (JsonNode row : r.path("rows")) { + Map m = mapper.convertValue(row, Map.class); + rows.add(m); + } + Map out = new LinkedHashMap<>(); + out.put("columns", columns); + out.put("rows", rows); + out.put("total", r.path("total").asInt(rows.size())); + out.put("page", r.path("pageNum").asInt(1)); + out.put("pageSize", r.path("pageSize").asInt(rows.size())); + out.put("nameField", r.path("nameField").asText(columns.isEmpty() ? "" : String.valueOf(columns.get(0).get("col")))); + out.put("valueField", r.path("valueField").asText("sId")); + out.put("assign", r.has("assign") ? mapper.convertValue(r.path("assign"), Map.class) : Map.of()); + out.put("source", "erp"); + return out; + } + + /** ctx = 表单上已绑定的其它字段值(字段名→id),供 ERP 的级联条件用。解析失败按无上下文处理。 */ + private Map parseCtx(String ctxJson) { + if (isBlank(ctxJson)) { + return Map.of(); + } + try { + @SuppressWarnings("unchecked") + Map m = mapper.readValue(ctxJson, Map.class); + return m; + } catch (Exception e) { + log.debug("ctx 解析失败,按无上下文处理: {}", e.getMessage()); + return Map.of(); + } + } + + private static List textList(JsonNode arr) { + List out = new ArrayList<>(); + for (JsonNode n : arr) { + out.add(n.asText()); + } + return out; + } + + private static boolean isBlank(String s) { + return s == null || s.isBlank(); + } } diff --git a/src/main/resources/templates/chat.html b/src/main/resources/templates/chat.html index 2666e1a..e12e91b 100644 --- a/src/main/resources/templates/chat.html +++ b/src/main/resources/templates/chat.html @@ -575,6 +575,9 @@ function addPreviewCard(ev) { card.appendChild(m); } const controls = []; // {label, get()} —— 仅 editable 预览收集 + const bound = {}; // 已选中记录的 id(字段名→id),供 ERP 级联下拉过滤 + (ev.fields || []).forEach(f => { if (f.boundId) bound[f.name] = f.boundId; }); // 记录原有绑定作初始上下文 + const ctxOf = () => Object.assign({}, bound); if (ev.editable) { const grid = el("div", "form-grid"); (ev.fields || []).forEach(f => { @@ -591,7 +594,13 @@ function addPreviewCard(ev) { inp.placeholder = "点击选择…"; inp.value = initial; const pick = el("button", "btn", "选择"); - pick.onclick = () => openFkPicker(f.fkTable, f.label || f.name, (name) => { inp.value = name; }); + pick.onclick = () => openFkPicker( + { fkTable: f.fkTable, formId: ev.formId, name: f.name, ctx: ctxOf }, + f.label || f.name, + (name, id) => { + inp.value = name; + if (id) bound[f.name] = id; else delete bound[f.name]; + }); row.appendChild(inp); row.appendChild(pick); wrap.appendChild(row); @@ -720,6 +729,9 @@ function addFormCard(ev) { if (ev.message) card.appendChild(el("div", "muted", ev.message)); const grid = el("div", "form-grid"); const controls = []; // {label, required, get()} + // 已选中记录的 id(字段名→id):ERP 的级联下拉要靠它过滤(如产品限定在已选客户名下) + const bound = {}; + const ctxOf = () => Object.assign({}, bound); (ev.fields || []).forEach(f => { const wrap = el("div", "ffield"); @@ -736,7 +748,13 @@ function addFormCard(ev) { inp.placeholder = "点击选择…"; if (f.default) inp.value = f.default; const pick = el("button", "btn", "选择"); - pick.onclick = () => openFkPicker(f.fkTable, f.label || f.name, (name) => { inp.value = name; }); + pick.onclick = () => openFkPicker( + { fkTable: f.fkTable, formId: ev.formId, name: f.name, ctx: ctxOf }, + f.label || f.name, + (name, id) => { + inp.value = name; + if (id) bound[f.name] = id; else delete bound[f.name]; + }); row.appendChild(inp); row.appendChild(pick); wrap.appendChild(row); @@ -823,11 +841,16 @@ function addFormCard(ev) { }; } -/* ================= FK 二级选择器(列表/搜索/分页/选择) ================= */ -const fk = { table: null, q: "", page: 1, pageSize: 20, total: 0, onPick: null }; +/* ================= FK 二级选择器(列表/搜索/分页/选择) ================= + 候选优先由 ERP 按其下拉配置给(带行级权限/级联过滤/联动回填),故要把 formId、字段名、 + 以及本表单上**已绑定的其它字段 id**(ctx)一并带上;ERP 说缺上级字段时提示用户先选那一个。 */ +const fk = { table: null, formId: null, field: null, ctx: null, q: "", page: 1, pageSize: 20, total: 0, onPick: null }; -function openFkPicker(table, title, onPick) { - fk.table = table; +function openFkPicker(opts, title, onPick) { + fk.table = opts.fkTable; + fk.formId = opts.formId || null; + fk.field = opts.name || null; + fk.ctx = opts.ctx || null; fk.q = ""; fk.page = 1; fk.onPick = onPick; @@ -842,10 +865,22 @@ async function loadFkPage() { const body = $("#fkBody"); body.innerHTML = '

加载中…
'; try { - const url = BASE + "/api/agent/form/options?table=" + encodeURIComponent(fk.table) + let url = BASE + "/api/agent/form/options?table=" + encodeURIComponent(fk.table) + "&q=" + encodeURIComponent(fk.q) + "&page=" + fk.page + "&pageSize=" + fk.pageSize; + if (fk.formId && fk.field) { + url += "&formId=" + encodeURIComponent(fk.formId) + "&field=" + encodeURIComponent(fk.field); + const ctx = typeof fk.ctx === "function" ? fk.ctx() : fk.ctx; + if (ctx && Object.keys(ctx).length) url += "&ctx=" + encodeURIComponent(JSON.stringify(ctx)); + } const d = await (await fetch(url, { headers: authHeaders(false) })).json(); + if (d.needContext) { + body.innerHTML = '
' + (d.message || "请先选择上级字段。") + "
"; + $("#fkPageInfo").textContent = ""; + $("#fkPrev").disabled = true; + $("#fkNext").disabled = true; + return; + } fk.total = d.total || 0; const cols = d.columns || []; const rows = d.rows || []; @@ -856,13 +891,14 @@ async function loadFkPage() { thead.appendChild(trh); table.appendChild(thead); const tbody = el("tbody"); - const nameCol = cols.length ? cols[0].col : null; + const nameCol = d.nameField || (cols.length ? cols[0].col : null); + const valueCol = d.valueField || "sId"; rows.forEach(r => { const tr = el("tr"); cols.forEach(c => tr.appendChild(el("td", null, r[c.col] == null ? "" : String(r[c.col])))); tr.onclick = () => { $("#modalMask").classList.remove("show"); - if (fk.onPick && nameCol) fk.onPick(String(r[nameCol] || "")); + if (fk.onPick && nameCol) fk.onPick(String(r[nameCol] || ""), r[valueCol] == null ? null : String(r[valueCol])); }; tbody.appendChild(tr); });