PreviewService.java 34.1 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734
package com.xly.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.xly.agent.AgentIdentity;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

/**
 * 写操作预览/保存闭环(rearch3 §1「所见即所写」):
 *
 * <ol>
 *   <li>{@link #buildPreview}(previewChange 工具的后端,**只读**):定位记录 → 校验改动
 *       (字段字典/FK/类型/系统列/状态合法性)→ 渲染整表预览卡(改动高亮/状态变化高亮)→
 *       解析产物(表/记录/列/规范化值/当前值快照)以 previewId 存 Redis({@value #TTL_MINUTES} 分钟 TTL,
 *       服务端绑定,客户端只见 previewId);</li>
 *   <li>{@link #save}(用户点卡上按钮后的确定性端点):按 previewId 取回 → 归属/权限重查 →
 *       **重读记录重校验**(记录被他人改过/状态已变 → 拒绝保存,请重新预览)→ 写 ai_op_queue
 *       ({@link OpService},AI 侧唯一写入口)→ 落 queued 事件。**xlyAi 到此为止**,执行由 ERP 侧负责。</li>
 * </ol>
 *
 * 状态合法性硬检查(预览与保存两次,防 TOCTOU):审核要求未审核、销审要求已审核、
 * 作废要求未作废、复原要求已作废(依据已核实的存储过程行为:重复审核/重复作废 ERP 不拦但有副作用)。
 * 表没有对应状态列时跳过该项检查。
 */
@Service
public class PreviewService {

    private static final Logger log = LoggerFactory.getLogger(PreviewService.class);
    private static final String KEY_PREFIX = "chat:preview:";
    private static final int TTL_MINUTES = 30;
    private static final int STATE_SUMMARY_FIELDS = 6;

    private final FormRenderService render;
    private final FormResolverService resolver;
    private final OpService ops;
    private final LedgerService ledger;
    private final ErpClient erp;
    private final StringRedisTemplate redis;
    private final ObjectMapper mapper;

    /** Phase D 接口位:true 时保存前调 ERP dry-run 校验(/business/checkBusinessData,ERP 侧完工后开启)。 */
    @Value("${erp.dry-run.enabled:false}")
    private boolean dryRunEnabled;

    public PreviewService(FormRenderService render, FormResolverService resolver, OpService ops,
                          LedgerService ledger, ErpClient erp, StringRedisTemplate redis, ObjectMapper mapper) {
        this.render = render;
        this.resolver = resolver;
        this.ops = ops;
        this.ledger = ledger;
        this.erp = erp;
        this.redis = redis;
        this.mapper = mapper;
    }

    // ---------------------------------------------------------------- 预览

    /** previewChange 的入口。返回卡片 JSON(type=change_preview)或 {"error":...}。 */
    public String buildPreview(AgentIdentity who, String convId, String action, String entityKeyword,
                               String recordKeyword, String fieldChinese, String newValue) {
        if (isBlank(action)) {
            return err("缺少 action。");
        }
        String act = normalizeAction(action);
        if (act == null) {
            return err("未知 action:" + action + "(支持 update/invalid/cancelInvalid/examine/cancelExamine/delete;新增走 collectForm)");
        }
        if (isBlank(entityKeyword) || isBlank(recordKeyword)) {
            return err("缺少实体类型或记录名称/单号。");
        }
        String verb = verbOf(act);
        FormRenderService.Located l = render.locateRecord(who, entityKeyword, recordKeyword, verb);
        if (l.error != null) {
            return err(l.error);
        }
        return "update".equals(act)
                ? buildUpdatePreview(who, convId, entityKeyword, fieldChinese, newValue, l)
                : buildStatePreview(who, convId, act, entityKeyword, l);
    }

    private String buildUpdatePreview(AgentIdentity who, String convId, String entity,
                                      String fieldChinese, String newValue, FormRenderService.Located l) {
        if (isBlank(fieldChinese) || newValue == null) {
            return err("update 需要 字段中文名 + 新值。");
        }
        Map<String, Object> fm = render.resolveColumn(l.table, fieldChinese.trim());
        if (fm == null) {
            return err("在该表单里找不到叫「" + fieldChinese + "」的字段,请换个字段名或先查看该表单有哪些字段。");
        }
        String col = String.valueOf(fm.get("col"));
        if (resolver.isSystemColumn(col)) {
            return err("「" + fieldChinese + "」是系统字段,不能通过对话修改。");
        }
        String fk = fm.get("fk") == null ? null : String.valueOf(fm.get("fk"));
        FormRenderService.Normalized n = render.normalize(l.table, col, fk, fieldChinese, newValue, who);
        if (n.error != null) {
            return err(n.error);
        }

        String warning = null;
        if (readState(l.rec, "bInvalid") == 1) {
            warning = "注意:该记录当前是**已作废**状态。";
        }

        // 整表渲染:master 字段的当前真实值 + 被改字段高亮
        List<Map<String, Object>> skeleton = masterFields(l.table, l.formId);
        ensureFieldPresent(skeleton, col, fieldChinese, fk, l.table);
        List<Map<String, Object>> cardFields = new ArrayList<>();
        Map<String, String> snapshotRaw = new LinkedHashMap<>();
        Map<String, String> snapshotShown = new LinkedHashMap<>();
        List<Map<String, Object>> fieldsMeta = new ArrayList<>();
        for (Map<String, Object> f : skeleton) {
            String name = String.valueOf(f.get("name"));
            String label = String.valueOf(f.get("label"));
            String ffk = f.get("fkTable") == null ? null : String.valueOf(f.get("fkTable"));
            String raw = l.rec.path(name).asText("");
            String shown = ffk != null ? render.fkDisplayName(ffk, raw) : raw;
            snapshotRaw.put(name, raw);
            snapshotShown.put(name, shown);
            Map<String, Object> meta = new LinkedHashMap<>();
            meta.put("name", name);
            meta.put("label", label);
            if (ffk != null) {
                meta.put("fk", ffk);
            }
            fieldsMeta.add(meta);

            Map<String, Object> 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);
            }
            cardFields.add(cf);
        }

        String oldShown = snapshotShown.getOrDefault(col, "");
        String description = "将【" + l.recordName + "】的【" + fieldChinese + "】"
                + (oldShown.isBlank() ? "" : ("由「" + oldShown + "」")) + "改为「" + n.shown + "」"
                + (n.shown.equals(newValue) ? "" : "(原话:" + newValue + ")");

        Map<String, Object> draft = new LinkedHashMap<>();
        draft.put("action", "update");
        draft.put("userId", who.userId());
        draft.put("convId", convId);
        draft.put("entity", entity);
        draft.put("formId", l.formId);
        draft.put("moduleId", l.moduleId);
        draft.put("table", l.table);
        draft.put("billId", l.billId);
        draft.put("recordName", l.recordName);
        draft.put("changedCol", col);
        draft.put("changedLabel", fieldChinese);
        draft.put("changedNewShown", n.shown);
        draft.put("snapshotRaw", snapshotRaw);
        draft.put("snapshotShown", snapshotShown);
        draft.put("fieldsMeta", fieldsMeta);
        draft.put("description", description);
        String previewId = stash(draft);
        if (previewId == null) {
            return err("预览暂存失败,请稍后重试。");
        }

        Map<String, Object> card = new LinkedHashMap<>();
        card.put("type", "change_preview");
        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);
        card.put("buttonLabel", "保存");
        card.put("editable", true);
        card.put("fields", cardFields);
        card.put("message", (warning == null ? "" : warning + "\n")
                + "请核对整张表单(改动已高亮,可继续修正其他字段),确认后点【保存】。保存前不会写入任何数据。");
        return toJson(card);
    }

    private String buildStatePreview(AgentIdentity who, String convId, String act, String entity,
                                     FormRenderService.Located l) {
        Map<String, String> types = resolver.columnTypes(l.table);
        int bCheck = readState(l.rec, "bCheck");
        int bInvalid = readState(l.rec, "bInvalid");

        // 状态合法性硬检查(依据已核实的存储过程行为;无对应列的表跳过)
        String illegal = stateIllegalReason(act, types, bCheck, bInvalid, l.recordName);
        if (illegal != null) {
            return err(illegal);
        }
        String warning = null;
        if ("delete".equals(act) && types.containsKey("bCheck") && bCheck == 1) {
            warning = "注意:该单据**已审核**,物理删除有风险且不可恢复,建议改用作废。";
        }

        // 单据摘要(前几个有值的业务字段)+ 状态变化高亮,无字段编辑
        List<Map<String, Object>> summaryFields = new ArrayList<>();
        for (Map<String, Object> f : masterFields(l.table, l.formId)) {
            if (summaryFields.size() >= STATE_SUMMARY_FIELDS) {
                break;
            }
            String name = String.valueOf(f.get("name"));
            String ffk = f.get("fkTable") == null ? null : String.valueOf(f.get("fkTable"));
            String raw = l.rec.path(name).asText("");
            if (raw.isBlank()) {
                continue;
            }
            Map<String, Object> cf = new LinkedHashMap<>();
            cf.put("name", name);
            cf.put("label", f.get("label"));
            cf.put("value", ffk != null ? render.fkDisplayName(ffk, raw) : raw);
            summaryFields.add(cf);
        }
        String billNo = l.rec.path("sBillNo").asText("");
        if (!billNo.isBlank()) {
            Map<String, Object> bn = new LinkedHashMap<>();
            bn.put("name", "sBillNo");
            bn.put("label", "单号");
            bn.put("value", billNo);
            summaryFields.add(0, bn);
        }

        Map<String, Object> statusChange = statusChangeOf(act);
        String verb = verbOf(act);
        String description = verb + "【" + l.recordName + "】(" + entity + ")";

        Map<String, Object> expected = new LinkedHashMap<>();
        if (types.containsKey("bCheck")) {
            expected.put("bCheck", bCheck);
        }
        if (types.containsKey("bInvalid")) {
            expected.put("bInvalid", bInvalid);
        }

        Map<String, Object> draft = new LinkedHashMap<>();
        draft.put("action", act);
        draft.put("userId", who.userId());
        draft.put("convId", convId);
        draft.put("entity", entity);
        draft.put("formId", l.formId);
        draft.put("moduleId", l.moduleId);
        draft.put("table", l.table);
        draft.put("billId", l.billId);
        draft.put("recordName", l.recordName);
        draft.put("expected", expected);
        draft.put("description", description);
        String previewId = stash(draft);
        if (previewId == null) {
            return err("预览暂存失败,请稍后重试。");
        }

        Map<String, Object> card = new LinkedHashMap<>();
        card.put("type", "change_preview");
        card.put("previewId", previewId);
        card.put("action", act);
        card.put("entity", entity);
        card.put("recordName", l.recordName);
        card.put("title", verb + "「" + l.recordName + "」(" + entity + ")");
        card.put("summary", description);
        card.put("buttonLabel", verb);
        card.put("editable", false);
        card.put("fields", summaryFields);
        card.put("statusChange", statusChange);
        card.put("message", (warning == null ? "" : warning + "\n")
                + "请核对单据信息与状态变化,确认后点【" + verb + "】提交待办。点按钮前不会写入任何数据。");
        return toJson(card);
    }

    // ---------------------------------------------------------------- 保存

    /**
     * 用户点卡上按钮 → 确定性保存:previewId 取回 → 归属/权限 → 重读重校验 → **claim 抢占**(删 previewId
     * 的原子 DEL,防并发/重复提交入队两次)→ 写 ai_op_queue。
     * editedFields = 卡上(可编辑预览)用户最终确认的 字段技术名→值;状态类传 null。
     * 返回 {queued,opIds,description,message} 或 {error}(校验失败时预览保留,可修正后重试)。
     */
    public Map<String, Object> save(AgentIdentity who, String previewId, Map<String, String> editedFields) {
        return save(who, previewId, editedFields, null);
    }

    /** 同上;boundIds = 外键字段在选择器里点中的记录 id(字段技术名→id),有则直接绑定不按名称反查。 */
    public Map<String, Object> save(AgentIdentity who, String previewId, Map<String, String> editedFields,
                                    Map<String, String> boundIds) {
        Map<String, Object> out = new LinkedHashMap<>();
        JsonNode draft = unstash(previewId);
        if (draft == null) {
            out.put("error", "预览已过期或不存在,请让助手重新生成预览。");
            return out;
        }
        if (!draft.path("userId").asText("").equals(who.userId())) {
            out.put("error", "无权处理该预览。");
            return out;
        }
        String moduleId = draft.path("moduleId").asText("");
        if (!who.canAccessModule(moduleId)) {
            out.put("error", "你没有该单据的操作权限。");
            return out;
        }
        String action = draft.path("action").asText("");
        String convId = draft.path("convId").asText("");
        FormRenderService.Located cur = render.locateById(who, draft.path("formId").asText(""),
                moduleId, draft.path("table").asText(""), draft.path("billId").asText(""));
        if (cur.error != null) {
            out.put("error", cur.error + " 请重新预览。");
            return out;
        }
        return "update".equals(action)
                ? saveUpdate(who, draft, editedFields, boundIds, cur, convId, previewId)
                : saveStateOp(who, draft, cur, convId, action, previewId);
    }

    /** 入队多字段更新的一次改动。 */
    private record Change(String col, String label, String wasShown, Object stored, String shown) { }

    private Map<String, Object> saveUpdate(AgentIdentity who, JsonNode draft, Map<String, String> editedFields,
                                           Map<String, String> boundIds, FormRenderService.Located cur,
                                           String convId, String previewId) {
        Map<String, Object> out = new LinkedHashMap<>();
        String table = draft.path("table").asText("");
        String recordName = draft.path("recordName").asText("");
        JsonNode snapshotRaw = draft.path("snapshotRaw");
        JsonNode snapshotShown = draft.path("snapshotShown");
        String changedCol = draft.path("changedCol").asText("");
        String changedNewShown = draft.path("changedNewShown").asText("");

        // 期望的最终值:预览高亮的改动 + 用户在卡上继续修正的字段。
        // 键 = 字段**技术名**(同表两列同中文名时 label 键会串写出"幻影更新")。
        // 值为空 = 放弃该字段的改动(含模型提议的那个改动)——卡上无法把字段改成空值,属已知取舍。
        Map<String, String> desired = new LinkedHashMap<>();
        desired.put(changedCol, changedNewShown);
        if (editedFields != null && !editedFields.isEmpty()) {
            for (JsonNode meta : draft.path("fieldsMeta")) {
                String name = meta.path("name").asText("");
                if (!editedFields.containsKey(name)) {
                    continue;
                }
                String v = editedFields.get(name);
                if (v == null || v.isBlank()) {
                    desired.remove(name);
                } else {
                    desired.put(name, v);
                }
            }
        }

        // 第一遍:全部改动先过校验与冲突检查(全通过才入队,避免写一半)
        List<Change> changes = new ArrayList<>();
        for (JsonNode meta : draft.path("fieldsMeta")) {
            String col = meta.path("name").asText("");
            String label = meta.path("label").asText("");
            String fk = meta.path("fk").isMissingNode() ? null : meta.path("fk").asText(null);
            String want = desired.get(col);
            if (want == null) {
                continue;
            }
            String wasShown = snapshotShown.path(col).asText("");
            if (want.equals(wasShown)) {
                continue; // 没改
            }
            if (resolver.isSystemColumn(col)) {
                out.put("error", "「" + label + "」是系统字段,不能修改。");
                return out;
            }
            // 所见即所写:预览时的当前值必须仍是现在的当前值(他人已改 → 拒绝)
            String rawAtPreview = snapshotRaw.path(col).asText("");
            String rawNow = cur.rec.path(col).asText("");
            if (!rawNow.equals(rawAtPreview)) {
                out.put("error", "「" + label + "」在预览之后被其他人修改过(现在是「"
                        + (fk != null ? render.fkDisplayName(fk, rawNow) : rawNow) + "」),已拒绝保存,请重新预览。");
                return out;
            }
            FormRenderService.Normalized n = render.normalize(table, col, fk, label, want,
                    boundIds == null ? null : boundIds.get(col), who);
            if (n.error != null) {
                out.put("error", n.error);
                return out;
            }
            changes.add(new Change(col, label, wasShown, n.stored, n.shown));
        }
        if (changes.isEmpty()) {
            out.put("error", "没有需要保存的改动。");
            return out;
        }

        String dryErr = dryRunUpdate(who, draft, changes);
        if (dryErr != null) {
            out.put("error", dryErr);
            return out;
        }

        // claim(原子 DEL):并发/重复点保存只有一个能入队
        if (!claim(previewId)) {
            out.put("error", "该预览已提交过或已失效,请勿重复提交。");
            return out;
        }
        // 第二遍:批量入队(单事务;ERP 执行器为单字段 update,一次保存多字段 = 多行待办)
        List<OpService.FieldChange> fcs = new ArrayList<>();
        List<String> descs = new ArrayList<>();
        for (Change c : changes) {
            String desc = "将【" + recordName + "】的【" + c.label() + "】"
                    + (c.wasShown().isBlank() ? "" : ("由「" + c.wasShown() + "」")) + "改为「" + c.shown() + "」";
            descs.add(desc);
            fcs.add(new OpService.FieldChange(c.col(), c.label(), c.wasShown(), String.valueOf(c.stored()), desc));
        }
        List<String> opIds;
        try {
            opIds = ops.queueUpdates(who, convId, draft.path("formId").asText(""),
                    draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), fcs);
        } catch (Exception e) {
            log.warn("queue updates failed (conv={}): {}", convId, e.getMessage());
            out.put("error", "提交待办失败:" + e.getMessage() + ",请重新预览。");
            return out;
        }
        String description = String.join(";", descs);
        recordQueued(convId, who, opIds, description);
        out.put("queued", true);
        out.put("opIds", opIds);
        out.put("description", description);
        out.put("message", "已提交待办:" + description + "。是否/何时执行由 ERP 处理。");
        return out;
    }

    private Map<String, Object> saveStateOp(AgentIdentity who, JsonNode draft, FormRenderService.Located cur,
                                            String convId, String action, String previewId) {
        Map<String, Object> out = new LinkedHashMap<>();
        String table = draft.path("table").asText("");
        Map<String, String> types = resolver.columnTypes(table);
        int bCheck = readState(cur.rec, "bCheck");
        int bInvalid = readState(cur.rec, "bInvalid");
        // 所见即所写:预览时看到的状态必须没变
        JsonNode expected = draft.path("expected");
        if (expected.has("bCheck") && expected.path("bCheck").asInt() != bCheck) {
            out.put("error", "该单据的审核状态在预览之后发生了变化,已拒绝提交,请重新预览。");
            return out;
        }
        if (expected.has("bInvalid") && expected.path("bInvalid").asInt() != bInvalid) {
            out.put("error", "该单据的作废状态在预览之后发生了变化,已拒绝提交,请重新预览。");
            return out;
        }
        String illegal = stateIllegalReason(action, types, bCheck, bInvalid, draft.path("recordName").asText(""));
        if (illegal != null) {
            out.put("error", illegal);
            return out;
        }
        String opType;
        String sNewValue;
        switch (action) {
            case "invalid" -> { opType = "invalid"; sNewValue = "toVoid"; }
            case "cancelinvalid", "cancelInvalid" -> { opType = "invalid"; sNewValue = "cancel"; }
            case "examine" -> { opType = "examine"; sNewValue = "1"; }
            case "cancelexamine", "cancelExamine" -> { opType = "examine"; sNewValue = "0"; }
            case "delete" -> { opType = "delete"; sNewValue = null; }
            default -> {
                out.put("error", "未知动作:" + action);
                return out;
            }
        }
        // claim(原子 DEL):并发/重复点按钮只有一个能入队——重复审核/作废的副作用正是状态检查要防的
        if (!claim(previewId)) {
            out.put("error", "该预览已提交过或已失效,请勿重复提交。");
            return out;
        }
        String description = draft.path("description").asText("");
        String opId = ops.queueStateOp(who, convId, opType, draft.path("formId").asText(""),
                draft.path("moduleId").asText(""), table, draft.path("billId").asText(""), sNewValue, description);
        recordQueued(convId, who, List.of(opId), description);
        out.put("queued", true);
        out.put("opIds", List.of(opId));
        out.put("description", description);
        out.put("message", "已提交待办:" + description + "。是否/何时执行由 ERP 处理。");
        return out;
    }

    // ---------------------------------------------------------------- create(表单保存按钮的后端)

    /** collectForm 表单【保存】→ 构建校验 create 载荷 → 入队。返回 {queued,opIds,...} 或 {error}。 */
    public Map<String, Object> saveCreate(AgentIdentity who, String convId, String entity, Map<String, String> fields) {
        return saveCreate(who, convId, entity, fields, null);
    }

    /** 同上;boundIds = 外键字段在选择器里点中的记录 id(字段技术名→id)。 */
    public Map<String, Object> saveCreate(AgentIdentity who, String convId, String entity,
                                          Map<String, String> fields, Map<String, String> boundIds) {
        Map<String, Object> out = new LinkedHashMap<>();
        FormRenderService.CreateBuild b = render.buildCreate(who, entity, fields, boundIds);
        if (b.error != null) {
            out.put("error", b.error);
            return out;
        }
        String dryErr = dryRunCreate(who, b);
        if (dryErr != null) {
            out.put("error", dryErr);
            return out;
        }
        String opId = ops.queueCreate(who, convId, b.formId, b.moduleId, b.table, b.payload, b.description);
        recordQueued(convId, who, List.of(opId), b.description);
        out.put("queued", true);
        out.put("opIds", List.of(opId));
        out.put("opId", opId);
        out.put("summary", b.description);
        out.put("description", b.description);
        out.put("message", "已提交待办:" + b.description + "。是否/何时执行由 ERP 处理。");
        return out;
    }

    // ---------------------------------------------------------------- Phase D:ERP dry-run 接口位

    /** ERP 校验 API 完工前恒关(erp.dry-run.enabled=false)。开启后:校验失败返回用户可读错误,网络异常放行(ERP 执行时仍会真校验)。 */
    private String dryRunCreate(AgentIdentity who, FormRenderService.CreateBuild b) {
        if (!dryRunEnabled) {
            return null;
        }
        try {
            JsonNode r = erp.checkBusinessData(who.token(), b.moduleId, b.table, b.payload, "add");
            if (r != null && r.path("code").asInt(1) != 1) {
                return "ERP 校验未通过:" + r.path("msg").asText("数据不合法");
            }
        } catch (Exception e) {
            log.warn("dry-run create failed (放行,执行时 ERP 仍会校验): {}", e.getMessage());
        }
        return null;
    }

    private String dryRunUpdate(AgentIdentity who, JsonNode draft, List<Change> changes) {
        if (!dryRunEnabled) {
            return null;
        }
        try {
            // 与真实入队完全同口径:只发改动列,值用规范化后的入库值(FK=id、数字已 coerce)
            Map<String, Object> col = new LinkedHashMap<>();
            col.put("sId", draft.path("billId").asText(""));
            for (Change c : changes) {
                col.put(c.col(), c.stored());
            }
            JsonNode r = erp.checkBusinessData(who.token(), draft.path("moduleId").asText(""),
                    draft.path("table").asText(""), mapper.writeValueAsString(col), "update");
            if (r != null && r.path("code").asInt(1) != 1) {
                return "ERP 校验未通过:" + r.path("msg").asText("数据不合法");
            }
        } catch (Exception e) {
            log.warn("dry-run update failed (放行,执行时 ERP 仍会校验): {}", e.getMessage());
        }
        return null;
    }

    // ---------------------------------------------------------------- 内部

    private void recordQueued(String convId, AgentIdentity who, List<String> opIds, String description) {
        Map<String, Object> data = new LinkedHashMap<>();
        data.put("opIds", opIds);
        data.put("description", description);
        ledger.append(convId, "queued", data, who);
    }

    /** 状态-动作合法性:非法返回用户可读原因,合法返回 null。无对应状态列的表跳过检查。 */
    private static String stateIllegalReason(String act, Map<String, String> types, int bCheck, int bInvalid,
                                             String recordName) {
        boolean hasCheck = types.containsKey("bCheck");
        boolean hasInvalid = types.containsKey("bInvalid");
        switch (act) {
            case "examine":
                if (hasCheck && bCheck == 1) {
                    return "【" + recordName + "】已经是**已审核**状态,无需重复审核(重复审核会覆盖审核人并重跑回写)。";
                }
                if (hasInvalid && bInvalid == 1) {
                    return "【" + recordName + "】是已作废单据,不能审核。";
                }
                break;
            case "cancelexamine", "cancelExamine":
                if (hasCheck && bCheck == 0) {
                    return "【" + recordName + "】还未审核,无法销审。";
                }
                break;
            case "invalid":
                if (hasInvalid && bInvalid == 1) {
                    return "【" + recordName + "】已经是**已作废**状态,无需重复作废(重复作废会重跑上下游回写)。";
                }
                break;
            case "cancelinvalid", "cancelInvalid":
                if (hasInvalid && bInvalid == 0) {
                    return "【" + recordName + "】不是作废状态,无需复原。";
                }
                break;
            default:
        }
        return null;
    }

    /** 记录的状态位(bCheck/bInvalid):1/true → 1,其余 → 0;列不存在也返回 0(调用方按 types 判断是否采信)。 */
    private static int readState(JsonNode rec, String col) {
        JsonNode v = rec == null ? null : rec.path(col);
        if (v == null || v.isMissingNode() || v.isNull()) {
            return 0;
        }
        String s = v.asText("");
        return "1".equals(s) || "true".equalsIgnoreCase(s) ? 1 : 0;
    }

    private static Map<String, Object> statusChangeOf(String act) {
        Map<String, Object> m = new LinkedHashMap<>();
        switch (act) {
            case "examine" -> { m.put("label", "审核状态"); m.put("from", "未审核"); m.put("to", "已审核"); }
            case "cancelexamine", "cancelExamine" -> { m.put("label", "审核状态"); m.put("from", "已审核"); m.put("to", "未审核"); }
            case "invalid" -> { m.put("label", "单据状态"); m.put("from", "正常"); m.put("to", "已作废(可复原)"); }
            case "cancelinvalid", "cancelInvalid" -> { m.put("label", "单据状态"); m.put("from", "已作废"); m.put("to", "正常"); }
            case "delete" -> { m.put("label", "单据状态"); m.put("from", "存在"); m.put("to", "物理删除(不可恢复)"); }
            default -> { }
        }
        return m;
    }

    private static String verbOf(String act) {
        return switch (act) {
            case "update" -> "保存";
            case "invalid" -> "作废";
            case "cancelinvalid", "cancelInvalid" -> "取消作废";
            case "examine" -> "审核";
            case "cancelexamine", "cancelExamine" -> "反审核";
            case "delete" -> "删除";
            default -> "操作";
        };
    }

    private static String normalizeAction(String action) {
        return switch (action.trim().toLowerCase()) {
            case "update" -> "update";
            case "invalid" -> "invalid";
            case "cancelinvalid" -> "cancelInvalid";
            case "examine" -> "examine";
            case "cancelexamine" -> "cancelExamine";
            case "delete" -> "delete";
            default -> null;
        };
    }

    /** update 预览渲染的字段集合:master 目标的骨架字段(报价策展含从表字段,改字段只支持主表列)。 */
    private List<Map<String, Object>> masterFields(String table, String formId) {
        List<Map<String, Object>> out = new ArrayList<>();
        for (Map<String, Object> f : render.formSkeleton(table, formId)) {
            Object target = f.get("target");
            if (target == null || "master".equals(String.valueOf(target))) {
                out.add(f);
            }
        }
        return out;
    }

    /** 被改字段不在骨架里(低使用度列)时补一行,保证卡上一定能看到改动。 */
    private void ensureFieldPresent(List<Map<String, Object>> skeleton, String col, String label,
                                    String fk, String table) {
        for (Map<String, Object> f : skeleton) {
            if (col.equals(String.valueOf(f.get("name")))) {
                return;
            }
        }
        Map<String, Object> f = new LinkedHashMap<>();
        f.put("name", col);
        f.put("label", label);
        f.put("required", false);
        if (fk != null && !fk.isBlank()) {
            f.put("type", "fkselect");
            f.put("fkTable", fk);
        } else {
            f.put("type", "text");
        }
        skeleton.add(0, f);
    }

    private String stash(Map<String, Object> draft) {
        try {
            String id = UUID.randomUUID().toString().replace("-", "");
            redis.opsForValue().set(KEY_PREFIX + id, mapper.writeValueAsString(draft),
                    Duration.ofMinutes(TTL_MINUTES));
            return id;
        } catch (Exception e) {
            log.warn("preview stash failed: {}", e.getMessage());
            return null;
        }
    }

    private JsonNode unstash(String previewId) {
        if (previewId == null || previewId.isBlank() || !previewId.matches("[A-Za-z0-9]{16,64}")) {
            return null;
        }
        try {
            String json = redis.opsForValue().get(KEY_PREFIX + previewId);
            return json == null ? null : mapper.readTree(json);
        } catch (Exception e) {
            return null;
        }
    }

    /** 原子 DEL 抢占:只有真正删掉 key 的那个请求返回 true——并发/重复保存只有一个能入队。 */
    private boolean claim(String previewId) {
        try {
            return Boolean.TRUE.equals(redis.delete(KEY_PREFIX + previewId));
        } catch (Exception e) {
            return false;
        }
    }

    private String err(String msg) {
        Map<String, Object> m = new LinkedHashMap<>();
        m.put("error", msg);
        return toJson(m);
    }

    private String toJson(Map<String, Object> m) {
        try {
            return mapper.writeValueAsString(m);
        } catch (Exception e) {
            return "{\"error\":\"内部错误\"}";
        }
    }

    private static boolean isBlank(String s) {
        return s == null || s.isBlank();
    }
}