Commit 190e64cd9453f5fa88babe60335286d45117a755
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).
Showing
4 changed files
with
231 additions
and
11 deletions
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)) { |