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}}。
+ *
两条取数路径,优先前者:
+ *
+ * - ERP 下拉配置({@code POST /ai/fieldOptions},需 formId+field)——候选口径与 ERP 网页
+ * 完全一致:带**行级数据权限**、**级联过滤**(如产品限定在已选客户名下)、**联动回填映射**。
+ * - 本地字段字典({@link FormResolverService#fkOptionPage})——ERP 端点未上线/无该字段配置/
+ * 调用失败时的保底,行为同改造前。
+ *
*
- * 安全:需登录;**租户来自服务端内省的身份**(不接受客户端传 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