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「所见即所写」):
*
*
* - {@link #buildPreview}(previewChange 工具的后端,**只读**):定位记录 → 校验改动
* (字段字典/FK/类型/系统列/状态合法性)→ 渲染整表预览卡(改动高亮/状态变化高亮)→
* 解析产物(表/记录/列/规范化值/当前值快照)以 previewId 存 Redis({@value #TTL_MINUTES} 分钟 TTL,
* 服务端绑定,客户端只见 previewId);
* - {@link #save}(用户点卡上按钮后的确定性端点):按 previewId 取回 → 归属/权限重查 →
* **重读记录重校验**(记录被他人改过/状态已变 → 拒绝保存,请重新预览)→ 写 ai_op_queue
* ({@link OpService},AI 侧唯一写入口)→ 落 queued 事件。**xlyAi 到此为止**,执行由 ERP 侧负责。
*
*
* 状态合法性硬检查(预览与保存两次,防 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 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