Commit 58b65d9d2e85ac0314de162252471a5b28a4f4ba
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,使修改单据时也有初始级联上下文。
Showing
4 changed files
with
234 additions
and
15 deletions
src/main/java/com/xly/service/ErpClient.java
| @@ -57,6 +57,9 @@ public class ErpClient { | @@ -57,6 +57,9 @@ public class ErpClient { | ||
| 57 | private String password; | 57 | private String password; |
| 58 | 58 | ||
| 59 | private volatile String cachedToken; | 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 | public ErpClient(ObjectMapper mapper) { | 64 | public ErpClient(ObjectMapper mapper) { |
| 62 | this.mapper = mapper; | 65 | this.mapper = mapper; |
| @@ -130,6 +133,67 @@ public class ErpClient { | @@ -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 | * token 内省:把透传的用户 token 换成 ERP **服务端认定**的身份(/ai/whoami,@CurrentUser 解析)。 | 197 | * token 内省:把透传的用户 token 换成 ERP **服务端认定**的身份(/ai/whoami,@CurrentUser 解析)。 |
| 134 | * 返回 {sId,sUserNo,sUserName,sType,sBrandsId,sSubsidiaryId};token 无效/过期返回 null。 | 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,6 +138,9 @@ public class PreviewService { | ||
| 138 | 138 | ||
| 139 | Map<String, Object> cf = new LinkedHashMap<>(f); | 139 | Map<String, Object> cf = new LinkedHashMap<>(f); |
| 140 | cf.put("value", shown); | 140 | cf.put("value", shown); |
| 141 | + if (ffk != null && !raw.isBlank()) { | ||
| 142 | + cf.put("boundId", raw); // 该记录当前绑定的 id:ERP 级联下拉的初始上下文 | ||
| 143 | + } | ||
| 141 | if (name.equals(col)) { | 144 | if (name.equals(col)) { |
| 142 | cf.put("changed", true); | 145 | cf.put("changed", true); |
| 143 | cf.put("newValue", n.shown); | 146 | cf.put("newValue", n.shown); |
| @@ -177,6 +180,7 @@ public class PreviewService { | @@ -177,6 +180,7 @@ public class PreviewService { | ||
| 177 | card.put("previewId", previewId); | 180 | card.put("previewId", previewId); |
| 178 | card.put("action", "update"); | 181 | card.put("action", "update"); |
| 179 | card.put("entity", entity); | 182 | card.put("entity", entity); |
| 183 | + card.put("formId", l.formId); // FK 选择器按 (formId,字段) 走 ERP 下拉配置 | ||
| 180 | card.put("recordName", l.recordName); | 184 | card.put("recordName", l.recordName); |
| 181 | card.put("title", "修改「" + l.recordName + "」(" + entity + ")"); | 185 | card.put("title", "修改「" + l.recordName + "」(" + entity + ")"); |
| 182 | card.put("summary", description); | 186 | card.put("summary", description); |
src/main/java/com/xly/web/FormController.java
| 1 | package com.xly.web; | 1 | package com.xly.web; |
| 2 | 2 | ||
| 3 | +import com.fasterxml.jackson.databind.JsonNode; | ||
| 4 | +import com.fasterxml.jackson.databind.ObjectMapper; | ||
| 3 | import com.xly.agent.AgentIdentity; | 5 | import com.xly.agent.AgentIdentity; |
| 4 | import com.xly.service.AuthzService; | 6 | import com.xly.service.AuthzService; |
| 7 | +import com.xly.service.ErpClient; | ||
| 5 | import com.xly.service.FormResolverService; | 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 | import org.springframework.http.HttpStatus; | 12 | import org.springframework.http.HttpStatus; |
| 7 | import org.springframework.web.bind.annotation.GetMapping; | 13 | import org.springframework.web.bind.annotation.GetMapping; |
| 8 | import org.springframework.web.bind.annotation.RequestHeader; | 14 | import org.springframework.web.bind.annotation.RequestHeader; |
| @@ -11,28 +17,51 @@ import org.springframework.web.bind.annotation.RequestParam; | @@ -11,28 +17,51 @@ import org.springframework.web.bind.annotation.RequestParam; | ||
| 11 | import org.springframework.web.bind.annotation.RestController; | 17 | import org.springframework.web.bind.annotation.RestController; |
| 12 | import org.springframework.web.server.ResponseStatusException; | 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 | import java.util.Map; | 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 | @RestController | 45 | @RestController |
| 27 | @RequestMapping("/api/agent/form") | 46 | @RequestMapping("/api/agent/form") |
| 28 | public class FormController { | 47 | public class FormController { |
| 29 | 48 | ||
| 49 | + private static final Logger log = LoggerFactory.getLogger(FormController.class); | ||
| 50 | + | ||
| 30 | private final FormResolverService resolver; | 51 | private final FormResolverService resolver; |
| 31 | private final AuthzService authz; | 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 | this.resolver = resolver; | 61 | this.resolver = resolver; |
| 35 | this.authz = authz; | 62 | this.authz = authz; |
| 63 | + this.erp = erp; | ||
| 64 | + this.mapper = mapper; | ||
| 36 | } | 65 | } |
| 37 | 66 | ||
| 38 | @GetMapping("/options") | 67 | @GetMapping("/options") |
| @@ -40,15 +69,101 @@ public class FormController { | @@ -40,15 +69,101 @@ public class FormController { | ||
| 40 | @RequestParam(value = "q", required = false) String q, | 69 | @RequestParam(value = "q", required = false) String q, |
| 41 | @RequestParam(value = "page", defaultValue = "1") int page, | 70 | @RequestParam(value = "page", defaultValue = "1") int page, |
| 42 | @RequestParam(value = "pageSize", defaultValue = "20") int pageSize, | 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 | @RequestHeader(value = "Authorization", required = false) String auth) { | 75 | @RequestHeader(value = "Authorization", required = false) String auth) { |
| 44 | AgentIdentity id = authz.resolveIdentity(auth); | 76 | AgentIdentity id = authz.resolveIdentity(auth); |
| 45 | if (id == null) { | 77 | if (id == null) { |
| 46 | throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "登录已过期或未登录"); | 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 | // 仅登录不够:该 FK 表必须被用户有权的某张表单引用,否则无表单权限的用户也能翻完客户名录 | 84 | // 仅登录不够:该 FK 表必须被用户有权的某张表单引用,否则无表单权限的用户也能翻完客户名录 |
| 49 | if (!resolver.fkTableAccessible(table, id)) { | 85 | if (!resolver.fkTableAccessible(table, id)) { |
| 50 | throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该数据"); | 86 | throw new ResponseStatusException(HttpStatus.FORBIDDEN, "无权访问该数据"); |
| 51 | } | 87 | } |
| 52 | return resolver.fkOptionPage(table, id.brandsId(), q, page, pageSize); | 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,6 +575,9 @@ function addPreviewCard(ev) { | ||
| 575 | card.appendChild(m); | 575 | card.appendChild(m); |
| 576 | } | 576 | } |
| 577 | const controls = []; // {label, get()} —— 仅 editable 预览收集 | 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 | if (ev.editable) { | 581 | if (ev.editable) { |
| 579 | const grid = el("div", "form-grid"); | 582 | const grid = el("div", "form-grid"); |
| 580 | (ev.fields || []).forEach(f => { | 583 | (ev.fields || []).forEach(f => { |
| @@ -591,7 +594,13 @@ function addPreviewCard(ev) { | @@ -591,7 +594,13 @@ function addPreviewCard(ev) { | ||
| 591 | inp.placeholder = "点击选择…"; | 594 | inp.placeholder = "点击选择…"; |
| 592 | inp.value = initial; | 595 | inp.value = initial; |
| 593 | const pick = el("button", "btn", "选择"); | 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 | row.appendChild(inp); | 604 | row.appendChild(inp); |
| 596 | row.appendChild(pick); | 605 | row.appendChild(pick); |
| 597 | wrap.appendChild(row); | 606 | wrap.appendChild(row); |
| @@ -720,6 +729,9 @@ function addFormCard(ev) { | @@ -720,6 +729,9 @@ function addFormCard(ev) { | ||
| 720 | if (ev.message) card.appendChild(el("div", "muted", ev.message)); | 729 | if (ev.message) card.appendChild(el("div", "muted", ev.message)); |
| 721 | const grid = el("div", "form-grid"); | 730 | const grid = el("div", "form-grid"); |
| 722 | const controls = []; // {label, required, get()} | 731 | const controls = []; // {label, required, get()} |
| 732 | + // 已选中记录的 id(字段名→id):ERP 的级联下拉要靠它过滤(如产品限定在已选客户名下) | ||
| 733 | + const bound = {}; | ||
| 734 | + const ctxOf = () => Object.assign({}, bound); | ||
| 723 | 735 | ||
| 724 | (ev.fields || []).forEach(f => { | 736 | (ev.fields || []).forEach(f => { |
| 725 | const wrap = el("div", "ffield"); | 737 | const wrap = el("div", "ffield"); |
| @@ -736,7 +748,13 @@ function addFormCard(ev) { | @@ -736,7 +748,13 @@ function addFormCard(ev) { | ||
| 736 | inp.placeholder = "点击选择…"; | 748 | inp.placeholder = "点击选择…"; |
| 737 | if (f.default) inp.value = f.default; | 749 | if (f.default) inp.value = f.default; |
| 738 | const pick = el("button", "btn", "选择"); | 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 | row.appendChild(inp); | 758 | row.appendChild(inp); |
| 741 | row.appendChild(pick); | 759 | row.appendChild(pick); |
| 742 | wrap.appendChild(row); | 760 | wrap.appendChild(row); |
| @@ -823,11 +841,16 @@ function addFormCard(ev) { | @@ -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 | fk.q = ""; | 854 | fk.q = ""; |
| 832 | fk.page = 1; | 855 | fk.page = 1; |
| 833 | fk.onPick = onPick; | 856 | fk.onPick = onPick; |
| @@ -842,10 +865,22 @@ async function loadFkPage() { | @@ -842,10 +865,22 @@ async function loadFkPage() { | ||
| 842 | const body = $("#fkBody"); | 865 | const body = $("#fkBody"); |
| 843 | body.innerHTML = '<div style="padding:20px;color:#888">加载中…</div>'; | 866 | body.innerHTML = '<div style="padding:20px;color:#888">加载中…</div>'; |
| 844 | try { | 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 | + "&q=" + encodeURIComponent(fk.q) | 869 | + "&q=" + encodeURIComponent(fk.q) |
| 847 | + "&page=" + fk.page + "&pageSize=" + fk.pageSize; | 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 | const d = await (await fetch(url, { headers: authHeaders(false) })).json(); | 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 | fk.total = d.total || 0; | 884 | fk.total = d.total || 0; |
| 850 | const cols = d.columns || []; | 885 | const cols = d.columns || []; |
| 851 | const rows = d.rows || []; | 886 | const rows = d.rows || []; |
| @@ -856,13 +891,14 @@ async function loadFkPage() { | @@ -856,13 +891,14 @@ async function loadFkPage() { | ||
| 856 | thead.appendChild(trh); | 891 | thead.appendChild(trh); |
| 857 | table.appendChild(thead); | 892 | table.appendChild(thead); |
| 858 | const tbody = el("tbody"); | 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 | rows.forEach(r => { | 896 | rows.forEach(r => { |
| 861 | const tr = el("tr"); | 897 | const tr = el("tr"); |
| 862 | cols.forEach(c => tr.appendChild(el("td", null, r[c.col] == null ? "" : String(r[c.col])))); | 898 | cols.forEach(c => tr.appendChild(el("td", null, r[c.col] == null ? "" : String(r[c.col])))); |
| 863 | tr.onclick = () => { | 899 | tr.onclick = () => { |
| 864 | $("#modalMask").classList.remove("show"); | 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 | tbody.appendChild(tr); | 903 | tbody.appendChild(tr); |
| 868 | }); | 904 | }); |