Commit bfa1689b539cba73b8ddaf80ca82d41a5fb47164

Authored by zichun
1 parent edc349ba

feat: 接入 ERP 声明的 assign 联动回填(选中客户带出客户编号/名称)

ERP /ai/fieldOptions 已上线(feature/ai @5d4ff83),其返回的 assign 声明了
选中一行后还应带出哪些列。前端在选中时按 assign 收集该行对应值随提交回传,
buildCreate/buildQuote 用 applyAssign 合并:只填用户没填过的列、且必须是目标表
真实存在的非系统列——用户已写的备注不会被客户档案的备注覆盖(ERP 回执 4.1 提醒)。

修复 ERP 指出的实际缺陷:我们建的 10 张报价 sCustomerName 全空,而原生单会填。
实测:assign 提交后 sCustomerName/sCustomerNo 落入载荷;用户已填字段不被覆盖。
src/main/java/com/xly/service/FormRenderService.java
@@ -256,6 +256,28 @@ public class FormRenderService { @@ -256,6 +256,28 @@ public class FormRenderService {
256 } 256 }
257 257
258 /** 258 /**
  259 + * 联动回填:用户在选择器里选中一行后,ERP 按其控件配置的 {@code sAssignField} 声明了该行还应带出哪些列
  260 + * (客户→客户编号/客户名称等),前端把值一并回传。**只填用户没填过的列**——用户在表单里写了备注,
  261 + * 就不该被客户档案里的备注覆盖;同时只认目标表真实存在的非系统列。
  262 + */
  263 + private void applyAssign(Map<String, Object> col, Map<String, String> types, Map<String, String> assign) {
  264 + if (assign == null || assign.isEmpty()) {
  265 + return;
  266 + }
  267 + for (Map.Entry<String, String> e : assign.entrySet()) {
  268 + String target = e.getKey();
  269 + String v = e.getValue();
  270 + if (v == null || v.isBlank() || col.containsKey(target)) {
  271 + continue;
  272 + }
  273 + if (!types.containsKey(target) || resolver.isSystemColumn(target)) {
  274 + continue;
  275 + }
  276 + col.put(target, v);
  277 + }
  278 + }
  279 +
  280 + /**
259 * 把用户/模型给的字符串按目标列规范化:外键列名称→id(租户内,多条命中报错列候选、 281 * 把用户/模型给的字符串按目标列规范化:外键列名称→id(租户内,多条命中报错列候选、
260 * 唯一精确同名优先——绝不静默绑最短匹配),其余按列类型强转。目标列必须物理存在 282 * 唯一精确同名优先——绝不静默绑最短匹配),其余按列类型强转。目标列必须物理存在
261 * (字段字典由表单控件名构建,含大量幻影列;幻影列会让类型校验静默失效)。 283 * (字段字典由表单控件名构建,含大量幻影列;幻影列会让类型校验静默失效)。
@@ -377,12 +399,15 @@ public class FormRenderService { @@ -377,12 +399,15 @@ public class FormRenderService {
377 * **只构建不写入**:产物由调用方经 {@link OpService} 落 ai_op_queue。 399 * **只构建不写入**:产物由调用方经 {@link OpService} 落 ai_op_queue。
378 */ 400 */
379 public CreateBuild buildCreate(AgentIdentity identity, String entityKeyword, Map<String, String> fields) { 401 public CreateBuild buildCreate(AgentIdentity identity, String entityKeyword, Map<String, String> fields) {
380 - return buildCreate(identity, entityKeyword, fields, null); 402 + return buildCreate(identity, entityKeyword, fields, null, null);
381 } 403 }
382 404
383 - /** 同上;{@code boundIds}(技术列名→id)来自选择器里点中的行,FK 列据此直接绑定,不按名称反查。 */ 405 + /**
  406 + * 同上;{@code boundIds}(技术列名→id)来自选择器里点中的行,FK 列据此直接绑定,不按名称反查;
  407 + * {@code assign}(技术列名→值)是 ERP 声明的联动回填,只补用户没填过的列。
  408 + */
384 public CreateBuild buildCreate(AgentIdentity identity, String entityKeyword, Map<String, String> fields, 409 public CreateBuild buildCreate(AgentIdentity identity, String entityKeyword, Map<String, String> fields,
385 - Map<String, String> boundIds) { 410 + Map<String, String> boundIds, Map<String, String> assign) {
386 CreateBuild out = new CreateBuild(); 411 CreateBuild out = new CreateBuild();
387 if (isBlank(entityKeyword)) { 412 if (isBlank(entityKeyword)) {
388 out.error = "缺少单据类型。"; 413 out.error = "缺少单据类型。";
@@ -401,7 +426,7 @@ public class FormRenderService { @@ -401,7 +426,7 @@ public class FormRenderService {
401 return out; 426 return out;
402 } 427 }
403 if ("quoquotationmaster".equalsIgnoreCase(out.table)) { 428 if ("quoquotationmaster".equalsIgnoreCase(out.table)) {
404 - return buildQuote(identity, entityKeyword, fields, boundIds, out); 429 + return buildQuote(identity, entityKeyword, fields, boundIds, assign, out);
405 } 430 }
406 431
407 Map<String, String> types = resolver.columnTypes(out.table); 432 Map<String, String> types = resolver.columnTypes(out.table);
@@ -441,6 +466,7 @@ public class FormRenderService { @@ -441,6 +466,7 @@ public class FormRenderService {
441 out.error = "请至少提供一个有效字段(如客户名称)。"; 466 out.error = "请至少提供一个有效字段(如客户名称)。";
442 return out; 467 return out;
443 } 468 }
  469 + applyAssign(col, types, assign);
444 for (String rc : requiredCols(out.table)) { 470 for (String rc : requiredCols(out.table)) {
445 if (col.containsKey(rc) || resolver.isSystemColumn(rc)) { 471 if (col.containsKey(rc) || resolver.isSystemColumn(rc)) {
446 continue; 472 continue;
@@ -469,8 +495,8 @@ public class FormRenderService { @@ -469,8 +495,8 @@ public class FormRenderService {
469 * 字段按策展的 target 表分流;印刷/颜色/单双面无独立列 → 合进从表 sMaterialsMemo;多数量按逗号拆多行。 495 * 字段按策展的 target 表分流;印刷/颜色/单双面无独立列 → 合进从表 sMaterialsMemo;多数量按逗号拆多行。
470 * 价格由 ERP【核价】计算,只落主-从明细。 496 * 价格由 ERP【核价】计算,只落主-从明细。
471 */ 497 */
472 - private CreateBuild buildQuote(AgentIdentity identity, String entityKeyword,  
473 - Map<String, String> fields, Map<String, String> boundIds, CreateBuild out) { 498 + private CreateBuild buildQuote(AgentIdentity identity, String entityKeyword, Map<String, String> fields,
  499 + Map<String, String> boundIds, Map<String, String> assign, CreateBuild out) {
474 Map<String, String> masterTypes = resolver.columnTypes(out.table); 500 Map<String, String> masterTypes = resolver.columnTypes(out.table);
475 Map<String, String> slaveTypes = resolver.columnTypes("quoquotationslave"); 501 Map<String, String> slaveTypes = resolver.columnTypes("quoquotationslave");
476 Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>(); 502 Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>();
@@ -560,6 +586,7 @@ public class FormRenderService { @@ -560,6 +586,7 @@ public class FormRenderService {
560 return out; 586 return out;
561 } 587 }
562 588
  589 + applyAssign(masterCol, masterTypes, assign);
563 for (String rc : requiredCols(out.table)) { 590 for (String rc : requiredCols(out.table)) {
564 if (masterCol.containsKey(rc) || resolver.isSystemColumn(rc)) { 591 if (masterCol.containsKey(rc) || resolver.isSystemColumn(rc)) {
565 continue; 592 continue;
src/main/java/com/xly/service/PreviewService.java
@@ -490,14 +490,18 @@ public class PreviewService { @@ -490,14 +490,18 @@ public class PreviewService {
490 490
491 /** collectForm 表单【保存】→ 构建校验 create 载荷 → 入队。返回 {queued,opIds,...} 或 {error}。 */ 491 /** collectForm 表单【保存】→ 构建校验 create 载荷 → 入队。返回 {queued,opIds,...} 或 {error}。 */
492 public Map<String, Object> saveCreate(AgentIdentity who, String convId, String entity, Map<String, String> fields) { 492 public Map<String, Object> saveCreate(AgentIdentity who, String convId, String entity, Map<String, String> fields) {
493 - return saveCreate(who, convId, entity, fields, null); 493 + return saveCreate(who, convId, entity, fields, null, null);
494 } 494 }
495 495
496 - /** 同上;boundIds = 外键字段在选择器里点中的记录 id(字段技术名→id)。 */ 496 + /**
  497 + * 同上;boundIds = 外键字段在选择器里点中的记录 id(字段技术名→id);
  498 + * assign = ERP 声明的联动回填(选中客户带出客户编号等),只补用户没填过的列。
  499 + */
497 public Map<String, Object> saveCreate(AgentIdentity who, String convId, String entity, 500 public Map<String, Object> saveCreate(AgentIdentity who, String convId, String entity,
498 - Map<String, String> fields, Map<String, String> boundIds) { 501 + Map<String, String> fields, Map<String, String> boundIds,
  502 + Map<String, String> assign) {
499 Map<String, Object> out = new LinkedHashMap<>(); 503 Map<String, Object> out = new LinkedHashMap<>();
500 - FormRenderService.CreateBuild b = render.buildCreate(who, entity, fields, boundIds); 504 + FormRenderService.CreateBuild b = render.buildCreate(who, entity, fields, boundIds, assign);
501 if (b.error != null) { 505 if (b.error != null) {
502 out.put("error", b.error); 506 out.put("error", b.error);
503 return out; 507 return out;
src/main/java/com/xly/web/AgentChatController.java
@@ -116,6 +116,7 @@ public class AgentChatController { @@ -116,6 +116,7 @@ public class AgentChatController {
116 public String entity; 116 public String entity;
117 public Map<String, String> fields; // 字段中文名 -> 值 117 public Map<String, String> fields; // 字段中文名 -> 值
118 public Map<String, String> boundIds; // 外键字段技术名 -> 选择器里点中的记录 id 118 public Map<String, String> boundIds; // 外键字段技术名 -> 选择器里点中的记录 id
  119 + public Map<String, String> assign; // ERP 声明的联动回填:目标列技术名 -> 值
119 public String conversationId; 120 public String conversationId;
120 public String authorization; 121 public String authorization;
121 } 122 }
@@ -153,7 +154,7 @@ public class AgentChatController { @@ -153,7 +154,7 @@ public class AgentChatController {
153 ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString()), identity); 154 ledger.append(convId, "form_submit", Map.of("entity", entity, "fields", parts.toString()), identity);
154 155
155 try { 156 try {
156 - Map<String, Object> r = previews.saveCreate(identity, convId, entity, fields, req.boundIds); 157 + Map<String, Object> r = previews.saveCreate(identity, convId, entity, fields, req.boundIds, req.assign);
157 if (r.containsKey("error")) { 158 if (r.containsKey("error")) {
158 ledger.append(convId, "assistant", Map.of("text", String.valueOf(r.get("error"))), identity); 159 ledger.append(convId, "assistant", Map.of("text", String.valueOf(r.get("error"))), identity);
159 } 160 }
src/main/resources/templates/chat.html
@@ -731,7 +731,17 @@ function addFormCard(ev) { @@ -731,7 +731,17 @@ function addFormCard(ev) {
731 const controls = []; // {label, required, get()} 731 const controls = []; // {label, required, get()}
732 // 已选中记录的 id(字段名→id):ERP 的级联下拉要靠它过滤(如产品限定在已选客户名下) 732 // 已选中记录的 id(字段名→id):ERP 的级联下拉要靠它过滤(如产品限定在已选客户名下)
733 const bound = {}; 733 const bound = {};
  734 + const assigned = {}; // 选中行带出的联动回填(字段名→{目标列:值}),由 ERP 的 assign 声明
734 const ctxOf = () => Object.assign({}, bound); 735 const ctxOf = () => Object.assign({}, bound);
  736 + // 用户自己填了的列不参与回填——已写的备注不该被客户档案里的备注覆盖
  737 + const assignOf = () => {
  738 + const typed = new Set(controls.filter(c => String(c.get() || "").trim()).map(c => c.name));
  739 + const out = {};
  740 + Object.values(assigned).forEach(m => Object.entries(m).forEach(([k, v]) => {
  741 + if (!typed.has(k)) out[k] = v;
  742 + }));
  743 + return out;
  744 + };
735 745
736 (ev.fields || []).forEach(f => { 746 (ev.fields || []).forEach(f => {
737 const wrap = el("div", "ffield"); 747 const wrap = el("div", "ffield");
@@ -751,9 +761,10 @@ function addFormCard(ev) { @@ -751,9 +761,10 @@ function addFormCard(ev) {
751 pick.onclick = () => openFkPicker( 761 pick.onclick = () => openFkPicker(
752 { fkTable: f.fkTable, formId: ev.formId, name: f.name, ctx: ctxOf }, 762 { fkTable: f.fkTable, formId: ev.formId, name: f.name, ctx: ctxOf },
753 f.label || f.name, 763 f.label || f.name,
754 - (name, id) => { 764 + (name, id, assign) => {
755 inp.value = name; 765 inp.value = name;
756 if (id) bound[f.name] = id; else delete bound[f.name]; 766 if (id) bound[f.name] = id; else delete bound[f.name];
  767 + assigned[f.name] = assign || {};
757 }); 768 });
758 row.appendChild(inp); 769 row.appendChild(inp);
759 row.appendChild(pick); 770 row.appendChild(pick);
@@ -780,7 +791,7 @@ function addFormCard(ev) { @@ -780,7 +791,7 @@ function addFormCard(ev) {
780 } 791 }
781 if (f.hint) wrap.appendChild(el("div", "hint", f.hint)); 792 if (f.hint) wrap.appendChild(el("div", "hint", f.hint));
782 grid.appendChild(wrap); 793 grid.appendChild(wrap);
783 - controls.push({ label: f.label || f.name, required: !!f.required, get: getter }); 794 + controls.push({ name: f.name, label: f.label || f.name, required: !!f.required, get: getter });
784 }); 795 });
785 card.appendChild(grid); 796 card.appendChild(grid);
786 797
@@ -821,6 +832,7 @@ function addFormCard(ev) { @@ -821,6 +832,7 @@ function addFormCard(ev) {
821 entity: ev.entity, 832 entity: ev.entity,
822 fields: fields, 833 fields: fields,
823 boundIds: bound, 834 boundIds: bound,
  835 + assign: assignOf(),
824 conversationId: conversationId 836 conversationId: conversationId
825 }) 837 })
826 }); 838 });
@@ -899,7 +911,15 @@ async function loadFkPage() { @@ -899,7 +911,15 @@ async function loadFkPage() {
899 cols.forEach(c => tr.appendChild(el("td", null, r[c.col] == null ? "" : String(r[c.col])))); 911 cols.forEach(c => tr.appendChild(el("td", null, r[c.col] == null ? "" : String(r[c.col]))));
900 tr.onclick = () => { 912 tr.onclick = () => {
901 $("#modalMask").classList.remove("show"); 913 $("#modalMask").classList.remove("show");
902 - if (fk.onPick && nameCol) fk.onPick(String(r[nameCol] || ""), r[valueCol] == null ? null : String(r[valueCol])); 914 + // ERP 声明的联动回填(目标列→结果列):把选中行对应的值一并带出,和网页选中客户的行为一致
  915 + const assign = {};
  916 + Object.entries(d.assign || {}).forEach(([target, src]) => {
  917 + const v = r[src];
  918 + if (v != null && String(v) !== "") assign[target] = String(v);
  919 + });
  920 + if (fk.onPick && nameCol) {
  921 + fk.onPick(String(r[nameCol] || ""), r[valueCol] == null ? null : String(r[valueCol]), assign);
  922 + }
903 }; 923 };
904 tbody.appendChild(tr); 924 tbody.appendChild(tr);
905 }); 925 });