Commit 190e64cd9453f5fa88babe60335286d45117a755

Authored by zichun
1 parent a4e1a1a7

feat(quote): full multi-table create (master + 印刷/部件从表 + 多数量报价)

User: chat form showed only 7 fields vs ERP's full 报价单. Root cause: 报价 is a cross-table
master-detail form; 印刷/颜色/单双面/部件/单价/系数/材料备注/多数量 live in slave tables, not the master.

- FormResolverService.curatedFields(quote): 17 fields tagged by target table (master/slave/note/manyqtys);
  印刷/颜色/单双面 have no column -> written as note into slave sMaterialsMemo.
- ProposeWriteTool.proposeQuote: routes fields to quoquotationmaster + quoquotationslave(印刷/部件, sParentId cascade) +
  quoquotationmanyqtys(多数量, one row per qty); FK name->id, type-coerce; emits __tables__ structured payload.
- ErpClient.createMulti + OpController: multi-table addUpdateDelBusinessData (master + slaves in one call).
- Pricing (核价) intentionally left to ERP (control/materials/process rows are engine-generated).

Verified via agent (real write): collectForm shows all 17 fields; created BJD202607084 = master(Little Antelope/妇科三醇乳膏/1.8mm双灰板/5000/12x8x5/3.5) + slave(只/部件30x20/系数1/材料备注含印刷=胶印;颜色=彩色;单双面=单面) + manyqtys(1000/3000/5000).
src/main/java/com/xly/service/ErpClient.java
@@ -293,6 +293,49 @@ public class ErpClient { @@ -293,6 +293,49 @@ public class ErpClient {
293 return root; 293 return root;
294 } 294 }
295 295
  296 + /**
  297 + * 多表联动新增(主-从:报价 = 主表 + 印刷/部件从表 + 多数量)。{@code tables} 每项 = {@code {sTable, name, column(map)}},
  298 + * 本方法给每行补 handleType=add 并一次性 POST addUpdateDelBusinessData,从表靠 sParentId 关联主表。
  299 + */
  300 + public JsonNode createMulti(String authToken, String moduleId, List<Map<String, Object>> tables) {
  301 + JsonNode root = doCreateMulti(moduleId, tables, resolveToken(authToken));
  302 + if (root.path("code").asInt() == -2 && canRelogin(authToken)) {
  303 + login();
  304 + root = doCreateMulti(moduleId, tables, resolveToken(authToken));
  305 + }
  306 + return root;
  307 + }
  308 +
  309 + @SuppressWarnings("unchecked")
  310 + private JsonNode doCreateMulti(String moduleId, List<Map<String, Object>> tables, String tok) {
  311 + try {
  312 + String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId;
  313 + List<Map<String, Object>> data = new ArrayList<>();
  314 + for (Map<String, Object> t : tables) {
  315 + Map<String, Object> col = new LinkedHashMap<>((Map<String, Object>) t.get("column"));
  316 + col.put("handleType", "add");
  317 + Map<String, Object> item = new LinkedHashMap<>();
  318 + item.put("sTable", t.get("sTable"));
  319 + item.put("name", t.get("name"));
  320 + item.put("column", List.of(col));
  321 + data.add(item);
  322 + }
  323 + Map<String, Object> body = new LinkedHashMap<>();
  324 + body.put("sModelsId", moduleId);
  325 + body.put("data", data);
  326 + HttpRequest req = HttpRequest.newBuilder(URI.create(url))
  327 + .header("Content-Type", "application/json;charset=UTF-8")
  328 + .header("Authorization", tok)
  329 + .timeout(Duration.ofSeconds(40))
  330 + .POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(body), StandardCharsets.UTF_8))
  331 + .build();
  332 + HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
  333 + return mapper.readTree(resp.body());
  334 + } catch (Exception e) {
  335 + throw new RuntimeException("ERP 多表新增异常: " + e.getMessage(), e);
  336 + }
  337 + }
  338 +
296 private JsonNode doCreate(String moduleId, String table, Map<String, Object> columns, String tok) { 339 private JsonNode doCreate(String moduleId, String table, Map<String, Object> columns, String tok) {
297 try { 340 try {
298 String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId; 341 String url = baseUrl + "/business/addUpdateDelBusinessData?sModelsId=" + moduleId;
src/main/java/com/xly/service/FormResolverService.java
@@ -131,26 +131,41 @@ public class FormResolverService { @@ -131,26 +131,41 @@ public class FormResolverService {
131 return null; 131 return null;
132 } 132 }
133 if (table.equalsIgnoreCase("quoquotationmaster")) { 133 if (table.equalsIgnoreCase("quoquotationmaster")) {
  134 + // 报价是跨表主-从表单:主表(quoquotationmaster) + 印刷/部件明细(quoquotationslave) + 多数量(quoquotationmanyqtys)。
  135 + // 每个字段标 target 表;印刷/颜色/单双面无独立列,作为备注写进从表 sMaterialsMemo(note)。价格由 ERP【核价】算。
134 List<Map<String, Object>> l = new ArrayList<>(); 136 List<Map<String, Object>> l = new ArrayList<>();
135 - l.add(bf("sCustomerId", "客户名称", "elecustomer"));  
136 - l.add(bf("sProductId", "产品名称", "eleproduct"));  
137 - l.add(bf("dQty", "数量", null));  
138 - l.add(bf("dLength", "长(L)", null));  
139 - l.add(bf("dWidth", "宽(W)", null));  
140 - l.add(bf("dHeight", "高(D)", null));  
141 - l.add(bf("dPrice", "单价", null)); 137 + l.add(bf("sCustomerId", "客户名称", "elecustomer", "master"));
  138 + l.add(bf("sProductId", "产品名称", "eleproduct", "master"));
  139 + l.add(bf("sMaterialsId", "物料名称", "elematerials", "master"));
  140 + l.add(bf("sProductUnit", "单位", null, "slave"));
  141 + l.add(bf("dLength", "长(L)", null, "master"));
  142 + l.add(bf("dWidth", "宽(W)", null, "master"));
  143 + l.add(bf("dHeight", "高(D)", null, "master"));
  144 + l.add(bf("dQty", "数量", null, "master"));
  145 + l.add(bf("sPrint", "印刷", null, "note"));
  146 + l.add(bf("sColor", "颜色", null, "note"));
  147 + l.add(bf("sSingleDouble", "单双面", null, "note"));
  148 + l.add(bf("dProductLength", "部件长", null, "slave"));
  149 + l.add(bf("dProductWidth", "部件宽", null, "slave"));
  150 + l.add(bf("dPrice", "单价", null, "master"));
  151 + l.add(bf("dCoefficient", "系数", null, "slave"));
  152 + l.add(bf("sMaterialsMemo", "材料备注", null, "slave"));
  153 + l.add(bf("dManyQty", "多数量(逗号分隔)", null, "manyqtys"));
142 return l; 154 return l;
143 } 155 }
144 return null; 156 return null;
145 } 157 }
146 158
147 - private Map<String, Object> bf(String col, String label, String fk) { 159 + private Map<String, Object> bf(String col, String label, String fk, String targetTable) {
148 Map<String, Object> m = new LinkedHashMap<>(); 160 Map<String, Object> m = new LinkedHashMap<>();
149 m.put("col", col); 161 m.put("col", col);
150 m.put("label", label); 162 m.put("label", label);
151 if (fk != null) { 163 if (fk != null) {
152 m.put("fk", fk); 164 m.put("fk", fk);
153 } 165 }
  166 + if (targetTable != null) {
  167 + m.put("table", targetTable);
  168 + }
154 return m; 169 return m;
155 } 170 }
156 171
src/main/java/com/xly/tool/ProposeWriteTool.java
@@ -212,6 +212,11 @@ public class ProposeWriteTool { @@ -212,6 +212,11 @@ public class ProposeWriteTool {
212 return err("你没有新增「" + entityKeyword + "」的权限。"); 212 return err("你没有新增「" + entityKeyword + "」的权限。");
213 } 213 }
214 214
  215 + // 报价是跨表主-从单据 → 走多表创建(主表 + 印刷/部件从表 + 多数量)
  216 + if ("quoquotationmaster".equalsIgnoreCase(table)) {
  217 + return proposeQuote(fieldsJson, formId, moduleId, table);
  218 + }
  219 +
215 Map<String, String> types = resolver.columnTypes(table); 220 Map<String, String> types = resolver.columnTypes(table);
216 // 权威 label -> {col, fk} 映射(与 collectForm 同源:策展/字段字典),保证前端标签能被正确回映射 221 // 权威 label -> {col, fk} 映射(与 collectForm 同源:策展/字段字典),保证前端标签能被正确回映射
217 Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>(); 222 Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>();
@@ -360,6 +365,155 @@ public class ProposeWriteTool { @@ -360,6 +365,155 @@ public class ProposeWriteTool {
360 return toJson(out); 365 return toJson(out);
361 } 366 }
362 367
  368 + /**
  369 + * 报价多表创建:主表(quoquotationmaster) + 印刷/部件从表(quoquotationslave) + 多数量(quoquotationmanyqtys)。
  370 + * 字段按策展的 target 表分流;印刷/颜色/单双面无独立列 → 合进从表 sMaterialsMemo;多数量按逗号拆多行。
  371 + * 价格由 ERP【核价】计算,本工具只落主-从明细。生成 __tables__ 结构化 payload,确认端点做多表写入。
  372 + */
  373 + private String proposeQuote(String fieldsJson, String formId, String moduleId, String table) {
  374 + Map<String, String> masterTypes = resolver.columnTypes(table);
  375 + Map<String, String> slaveTypes = resolver.columnTypes("quoquotationslave");
  376 + Map<String, Map<String, Object>> labelMap = new LinkedHashMap<>();
  377 + for (Map<String, Object> bfm : resolver.businessFields(table, 40)) {
  378 + labelMap.put(String.valueOf(bfm.get("label")), bfm);
  379 + }
  380 +
  381 + Map<String, Object> masterCol = new LinkedHashMap<>();
  382 + Map<String, Object> slaveCol = new LinkedHashMap<>();
  383 + List<String> notes = new ArrayList<>();
  384 + List<String> manyQtys = new ArrayList<>();
  385 + List<String> descParts = new ArrayList<>();
  386 + String custId = null;
  387 + try {
  388 + if (!isBlank(fieldsJson)) {
  389 + JsonNode fj = mapper.readTree(fieldsJson.trim());
  390 + Iterator<Map.Entry<String, JsonNode>> it = fj.fields();
  391 + while (it.hasNext()) {
  392 + Map.Entry<String, JsonNode> e = it.next();
  393 + String zh = e.getKey();
  394 + String v = e.getValue().asText("");
  395 + if (isBlank(v)) {
  396 + continue;
  397 + }
  398 + Map<String, Object> fm = labelMap.get(zh);
  399 + if (fm == null) {
  400 + continue;
  401 + }
  402 + String colName = str(fm.get("col"));
  403 + String tgt = fm.get("table") == null ? "master" : str(fm.get("table"));
  404 + String fk = str(fm.get("fk"));
  405 + if ("note".equals(tgt)) {
  406 + notes.add(zh + "=" + v);
  407 + descParts.add(zh + "=" + v);
  408 + continue;
  409 + }
  410 + if ("manyqtys".equals(tgt)) {
  411 + for (String q : v.split("[,,、\\s]+")) {
  412 + String n = q.replaceAll("[^0-9.]", "");
  413 + if (!n.isEmpty()) {
  414 + manyQtys.add(n);
  415 + }
  416 + }
  417 + descParts.add(zh + "=" + v);
  418 + continue;
  419 + }
  420 + Object value;
  421 + if (fk != null && !fk.isBlank()) {
  422 + String id = resolver.resolveFk(fk, v);
  423 + if (id == null) {
  424 + return err("找不到名为「" + v + "」的" + zh + ",请确认是否已存在。");
  425 + }
  426 + value = id;
  427 + if ("sCustomerId".equals(colName)) {
  428 + custId = id;
  429 + }
  430 + } else {
  431 + String dt = "slave".equals(tgt) ? slaveTypes.get(colName) : masterTypes.get(colName);
  432 + value = resolver.coerce(dt, v);
  433 + }
  434 + if (value != null) {
  435 + if ("slave".equals(tgt)) {
  436 + slaveCol.put(colName, value);
  437 + } else {
  438 + masterCol.put(colName, value);
  439 + }
  440 + }
  441 + descParts.add(zh + "=" + v);
  442 + }
  443 + }
  444 + } catch (Exception ex) {
  445 + return err("字段解析失败:" + ex.getMessage());
  446 + }
  447 + if (descParts.isEmpty()) {
  448 + return err("请至少填写一个字段(如客户名称/产品名称/数量)。");
  449 + }
  450 +
  451 + // 主表:补必填(非系统) + 主键 + 表单id + 单号
  452 + for (String rc : requiredCols(table)) {
  453 + if (masterCol.containsKey(rc) || resolver.isSystemColumn(rc)) {
  454 + continue;
  455 + }
  456 + masterCol.put(rc, resolver.typeDefault(rc, masterTypes.get(rc)));
  457 + }
  458 + String masterId = erp.newUuid(identity.token());
  459 + masterCol.put("sId", masterId);
  460 + masterCol.put("sFormId", formId);
  461 + String billNo = resolver.nextBillNo(table, identity.brandsId());
  462 + if (billNo != null) {
  463 + masterCol.put("sBillNo", billNo);
  464 + }
  465 +
  466 + List<Map<String, Object>> tables = new ArrayList<>();
  467 + tables.add(tableItem("quoquotationmaster", "master", masterCol));
  468 +
  469 + // 印刷/部件从表:有从表字段或印刷备注就建一行(sCustomerId 从表 NOT-NULL)
  470 + if (!slaveCol.isEmpty() || !notes.isEmpty()) {
  471 + if (!notes.isEmpty()) {
  472 + String memo = String.join(";", notes);
  473 + Object exist = slaveCol.get("sMaterialsMemo");
  474 + slaveCol.put("sMaterialsMemo", exist == null ? memo : (exist + ";" + memo));
  475 + }
  476 + slaveCol.put("sId", erp.newUuid(identity.token()));
  477 + slaveCol.put("sParentId", masterId);
  478 + slaveCol.put("sCustomerId", custId == null ? "" : custId);
  479 + tables.add(tableItem("quoquotationslave", "slave", slaveCol));
  480 + }
  481 + // 多数量报价:每个数量一行
  482 + for (String q : manyQtys) {
  483 + Map<String, Object> mq = new LinkedHashMap<>();
  484 + mq.put("sId", erp.newUuid(identity.token()));
  485 + mq.put("sParentId", masterId);
  486 + mq.put("dManyQty", q);
  487 + tables.add(tableItem("quoquotationmanyqtys", "slave", mq));
  488 + }
  489 +
  490 + Map<String, Object> wrap = new LinkedHashMap<>();
  491 + wrap.put("__tables__", tables);
  492 + String payload;
  493 + try {
  494 + payload = mapper.writeValueAsString(wrap);
  495 + } catch (Exception e) {
  496 + return err("内部错误:" + e.getMessage());
  497 + }
  498 + String description = "新增【报价】:" + String.join(",", descParts)
  499 + + "(含印刷/多数量明细;价格请在 ERP 点【核价】计算)";
  500 + String opId = ops.createDraftPayload(identity.userId(), "create", formId, moduleId, table, payload, description);
  501 +
  502 + Map<String, Object> out = new LinkedHashMap<>();
  503 + out.put("opId", opId);
  504 + out.put("summary", description);
  505 + out.put("message", "已为你生成一条待确认的报价新增(主表+印刷明细+多数量),请点【确认】执行、或【取消】。价格在 ERP 里点【核价】计算。");
  506 + return toJson(out);
  507 + }
  508 +
  509 + private Map<String, Object> tableItem(String sTable, String name, Map<String, Object> column) {
  510 + Map<String, Object> m = new LinkedHashMap<>();
  511 + m.put("sTable", sTable);
  512 + m.put("name", name);
  513 + m.put("column", column);
  514 + return m;
  515 + }
  516 +
363 /** 中文名 -> {col, fk}(字段字典,先精确合并模糊,取使用度最高的一列)。 */ 517 /** 中文名 -> {col, fk}(字段字典,先精确合并模糊,取使用度最高的一列)。 */
364 private Map<String, Object> resolveColumn(String table, String zh) { 518 private Map<String, Object> resolveColumn(String table, String zh) {
365 try { 519 try {
src/main/java/com/xly/web/OpController.java
@@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestParam; @@ -16,6 +16,7 @@ import org.springframework.web.bind.annotation.RequestParam;
16 import org.springframework.web.bind.annotation.RestController; 16 import org.springframework.web.bind.annotation.RestController;
17 17
18 import java.util.LinkedHashMap; 18 import java.util.LinkedHashMap;
  19 +import java.util.List;
19 import java.util.Map; 20 import java.util.Map;
20 21
21 /** 22 /**
@@ -81,9 +82,16 @@ public class OpController { @@ -81,9 +82,16 @@ public class OpController {
81 String opType = str(op.get("sOpType")); 82 String opType = str(op.get("sOpType"));
82 JsonNode r; 83 JsonNode r;
83 if ("create".equals(opType)) { 84 if ("create".equals(opType)) {
84 - @SuppressWarnings("unchecked")  
85 - Map<String, Object> columns = mapper.readValue(str(op.get("sPayload")), Map.class);  
86 - r = erp.createForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), columns); 85 + JsonNode payload = mapper.readTree(str(op.get("sPayload")));
  86 + if (payload.has("__tables__")) { // 报价等多表主-从创建
  87 + @SuppressWarnings("unchecked")
  88 + List<Map<String, Object>> tables = mapper.convertValue(payload.get("__tables__"), List.class);
  89 + r = erp.createMulti(authToken, str(op.get("sTargetModuleId")), tables);
  90 + } else {
  91 + @SuppressWarnings("unchecked")
  92 + Map<String, Object> columns = mapper.convertValue(payload, Map.class);
  93 + r = erp.createForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), columns);
  94 + }
87 } else if ("delete".equals(opType)) { 95 } else if ("delete".equals(opType)) {
88 r = erp.deleteForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId"))); 96 r = erp.deleteForm(authToken, str(op.get("sTargetModuleId")), str(op.get("sTargetTable")), str(op.get("sTargetBillId")));
89 } else if ("examine".equals(opType)) { 97 } else if ("examine".equals(opType)) {