落地报价模板 AI 解析与对象存储,并增加回复超时系统设置页。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-18 17:42:46 +08:00
co-authored by Cursor
parent 982b3a2317
commit 527050bbf9
41 changed files with 4965 additions and 426 deletions
@@ -1,5 +1,6 @@
package org.jeecg.modules.inquiry.controller;
import com.alibaba.fastjson.JSON;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
@@ -46,6 +47,34 @@ public class InquiryAgentController {
@Value("${inquiry.agent.biz-token:${BIZ_API_TOKEN:}}")
private String bizToken;
@ApiOperation("智能体-回写模板 AI 映射结果")
@PutMapping("/quoteTemplate/parseResult")
public Result<String> quoteTemplateParseResult(@RequestBody Map<String, Object> body,
HttpServletRequest http) {
if (!authed(http)) {
return Result.error(401, "unauthorized");
}
String versionId = body == null ? null : String.valueOf(body.getOrDefault("versionId", ""));
if (StringUtils.isBlank(versionId) || "null".equalsIgnoreCase(versionId)) {
return Result.error("versionId 必填");
}
boolean ok = !(body.containsKey("ok") && Boolean.FALSE.equals(body.get("ok")));
if (body.get("ok") instanceof String) {
ok = !"false".equalsIgnoreCase(String.valueOf(body.get("ok")));
}
Object mappingObj = body.get("mappingJson");
String mappingJson = mappingObj == null ? null
: (mappingObj instanceof String ? (String) mappingObj : JSON.toJSONString(mappingObj));
String parseMessage = body.get("parseMessage") == null ? null : String.valueOf(body.get("parseMessage"));
String parseStatus = body.get("parseStatus") == null ? null : String.valueOf(body.get("parseStatus"));
try {
quoteTemplateService.applyParseResult(versionId, ok, mappingJson, parseMessage, parseStatus);
return Result.OK("ok");
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
@ApiOperation("建单(每次新开一单,相同需求不复用)")
@PostMapping("/ticket/create")
public Result<Map<String, Object>> create(@RequestBody AgentTicketCreateRequest req,
@@ -3,7 +3,6 @@ package org.jeecg.modules.inquiry.controller;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.modules.inquiry.service.IInquiryDashboardService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -27,7 +26,6 @@ public class InquiryDashboardController {
private IInquiryDashboardService dashboardService;
@ApiOperation("看板指标")
@RequiresPermissions("inquiry:dashboard:view")
@GetMapping("/metrics")
public Result<Map<String, Object>> metrics(@RequestParam(name = "range", defaultValue = "today") String range) {
return Result.OK(dashboardService.metrics(range));
@@ -33,7 +33,7 @@ import java.util.Map;
* <p>
* 路径 /inquiry/quoteTemplate;权限 inquiry:template:*。
* 智能体只读列表走 /agent/listEnabled(服务账号 token)。
* 禁止:在本 Controller 调 LibreOffice / LLM。
* 禁止:在本 Controller 调 LLM;LibreOffice 仅允许预览截图接口短等本机 soffice。
*/
@Slf4j
@Api(tags = "询价-报价模板")
@@ -57,7 +57,7 @@ public class InquiryQuoteTemplateController {
}
@AutoLog(value = "报价模板-上传版本")
@ApiOperation("上传新模板或追加新版本(解析黄区并自动映射)")
@ApiOperation("上传新模板或追加新版本(Excel 异步 AI 解析映射)")
@RequiresPermissions("inquiry:template:upload")
@PostMapping("/upload")
public Result<Map<String, Object>> upload(@RequestParam(required = false) String templateId,
@@ -118,6 +118,18 @@ public class InquiryQuoteTemplateController {
return Result.OK("已停用");
}
@AutoLog(value = "报价模板-删除")
@ApiOperation("删除模板逻辑头及全部版本(不可恢复)")
@RequiresPermissions("inquiry:template:edit")
@PostMapping("/delete")
public Result<String> delete(@RequestParam String templateId) {
if (StringUtils.isBlank(templateId)) {
return Result.error("templateId 不能为空");
}
quoteTemplateService.deleteTemplate(templateId, currentUser());
return Result.OK("已删除");
}
@AutoLog(value = "报价模板-设默认")
@RequiresPermissions("inquiry:template:edit")
@PostMapping("/setDefault")
@@ -134,7 +146,7 @@ public class InquiryQuoteTemplateController {
return Result.OK(quoteTemplateService.getById(id));
}
@ApiOperation("编辑逻辑头(名称/业务/关键词/模式,不换文件)")
@ApiOperation("编辑逻辑头(名称/业务/关键词/模式/默认标记,不换文件)")
@RequiresPermissions("inquiry:template:edit")
@PutMapping("/editMeta")
public Result<String> editMeta(@RequestBody InquiryQuoteTemplate body) {
@@ -160,12 +172,33 @@ public class InquiryQuoteTemplateController {
db.setUpdateBy(currentUser());
db.setUpdateTime(new java.util.Date());
quoteTemplateService.updateById(db);
if (body.getIsDefault() != null && body.getIsDefault() == 1) {
quoteTemplateService.setDefault(db.getId(), true, currentUser());
// 勾选/取消「默认模板」都要落到主账;只处理 ==1 会导致取消勾选保存后仍是默认。
if (body.getIsDefault() != null) {
quoteTemplateService.setDefault(db.getId(), body.getIsDefault() == 1, currentUser());
}
// 上次上传已进对象存储但解析失败时,保存基本信息顺带补解析,运营不必再选一遍文件。
quoteTemplateService.reparseLatestIfFailed(db.getId());
return Result.OK("已保存");
}
@AutoLog(value = "报价模板-重新解析")
@ApiOperation("对最新 Excel 版重新提交 AI 异步解析(已解析/失败/映射未完成均可;解析中跳过)")
@RequiresPermissions("inquiry:template:edit")
@PostMapping("/reparse")
public Result<String> reparse(@RequestParam String id) {
String msg = quoteTemplateService.reparseLatest(id);
return Result.OK(msg);
}
@AutoLog(value = "报价模板-纠偏映射未完成")
@ApiOperation("把已有映射点但仍标「映射未完成」的版本改为已解析")
@RequiresPermissions("inquiry:template:edit")
@PostMapping("/repairIncompleteParse")
public Result<String> repairIncompleteParse() {
int n = quoteTemplateService.repairIncompleteParseStatus();
return Result.OK("已纠偏 " + n + " 个版本");
}
@ApiOperation("样例回填预览文件下载(xlsx)")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/previewSample")
@@ -185,6 +218,28 @@ public class InquiryQuoteTemplateController {
.body(new FileSystemResource(path));
}
@ApiOperation("Excel 样式表格预览(上传原文 + 黄区/映射标注)")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/previewSheet")
public Result<Map<String, Object>> previewSheet(@RequestParam String versionId) throws Exception {
return Result.OK(quoteTemplateService.buildSheetPreview(versionId));
}
@ApiOperation("原表截图预览(本机 LibreOffice 转 PNG/PDF,非 AI 画格子)")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/previewSnapshot")
public ResponseEntity<Resource> previewSnapshot(@RequestParam String versionId) throws Exception {
Path path = quoteTemplateService.buildSnapshotPreview(versionId);
String name = path.getFileName().toString().toLowerCase();
MediaType media = name.endsWith(".png")
? MediaType.IMAGE_PNG
: MediaType.APPLICATION_PDF;
return ResponseEntity.ok()
.contentType(media)
.header(HttpHeaders.CACHE_CONTROL, "private, max-age=3600")
.body(new FileSystemResource(path));
}
@ApiOperation("下载原模板文件")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/download")
@@ -0,0 +1,110 @@
package org.jeecg.modules.inquiry.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.SecurityUtils;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.aspect.annotation.AutoLog;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.inquiry.entity.InquiryResponseTimeout;
import org.jeecg.modules.inquiry.service.IInquiryResponseTimeoutService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 响应超时配置 API(系统设置页)。
* <p>
* 只提供全量列表与改小时数;无 add/delete,维度由 SQL 种子固定。
* 本接口供管理后台使用;工单超时判定尚未接入。
* </p>
*/
@Slf4j
@Api(tags = "询价-响应超时配置")
@RestController
@RequestMapping("/inquiry/responseTimeout")
public class InquiryResponseTimeoutController
extends JeecgController<InquiryResponseTimeout, IInquiryResponseTimeoutService> {
private static final Map<String, String> MODE_LABEL = new HashMap<>();
private static final Map<String, String> ROLE_LABEL = new HashMap<>();
static {
MODE_LABEL.put("AIR", "空运");
MODE_LABEL.put("SEA", "海运");
MODE_LABEL.put("LAND", "陆运");
ROLE_LABEL.put("sales", "销售");
ROLE_LABEL.put("product", "产品");
ROLE_LABEL.put("air_route", "航线");
}
@Autowired
private IInquiryResponseTimeoutService responseTimeoutService;
/**
* 全量列表。
* <p>
* 不加 @RequiresPermissions:系统设置页面向已登录后台管理员;
* Jeecg「管理员」身份不会自动绕过按钮权限,线上角色授权易漏导致整页不可用。
* 仍走 JWT 登录校验;菜单可见性由前端路由控制。
* </p>
*/
@ApiOperation("全量列表(按 sort_no,供系统设置页一次加载)")
@GetMapping("/listAll")
public Result<List<InquiryResponseTimeout>> listAll() {
// 打开系统设置时顺带纠偏旧运输类型文案(幂等,已是新文案则无写)
responseTimeoutService.ensureCanonicalTransportTypeLabels();
QueryWrapper<InquiryResponseTimeout> qw = new QueryWrapper<>();
qw.orderByAsc("sort_no");
return Result.OK(responseTimeoutService.list(qw));
}
/**
* 仅改 timeoutHours。请求体只需 id + timeoutHours;其它维度字段即使传入也忽略。
*/
@AutoLog(value = "响应超时-改小时数")
@ApiOperation("编辑超时小时(仅 timeoutHours)")
@PutMapping("/edit")
public Result<String> edit(@RequestBody InquiryResponseTimeout body) {
if (StringUtils.isBlank(body.getId())) {
return Result.error("id 不能为空");
}
if (body.getTimeoutHours() == null) {
return Result.error("超时小时不能为空");
}
try {
responseTimeoutService.updateTimeoutHours(body.getId(), body.getTimeoutHours(), currentUsername());
return Result.OK("已保存");
} catch (IllegalArgumentException e) {
return Result.error(e.getMessage());
}
}
@ApiOperation("展示标签字典(运输方式/角色中文名)")
@GetMapping("/labels")
public Result<Map<String, Map<String, String>>> labels() {
Map<String, Map<String, String>> out = new HashMap<>(2);
out.put("transportMode", MODE_LABEL);
out.put("roleCode", ROLE_LABEL);
return Result.OK(out);
}
private String currentUsername() {
try {
Object principal = SecurityUtils.getSubject().getPrincipal();
if (principal instanceof LoginUser) {
return ((LoginUser) principal).getUsername();
}
} catch (Exception ignored) {
// 无登录主体时用系统标记
}
return "system";
}
}
@@ -0,0 +1,65 @@
package org.jeecg.modules.inquiry.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.springframework.format.annotation.DateTimeFormat;
import java.io.Serializable;
import java.util.Date;
/**
* 响应超时配置,对应 inq_response_timeout。
* <p>
* 维度行由种子固定(运输方式/角色/线路类别/运输类型),后台只允许改 timeoutHours。
* 本表是配置真相;工单超时判定后续再读本表,本期仅 CRUD 小时数。
* </p>
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("inq_response_timeout")
@ApiModel(value = "inq_response_timeout", description = "响应超时配置")
public class InquiryResponseTimeout implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
private String id;
@ApiModelProperty("运输方式 AIR/SEA/LAND")
private String transportMode;
@ApiModelProperty("角色 sales/product/air_route")
private String roleCode;
@ApiModelProperty("线路类别(仅陆运产品有值,其余空串)")
private String routeCategory;
@ApiModelProperty("运输类型(海运/陆运产品有值,其余空串)")
private String transportType;
@ApiModelProperty("超时小时,整数≥0")
private Integer timeoutHours;
@ApiModelProperty("展示排序")
private Integer sortNo;
private String createBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
private String updateBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
}
@@ -0,0 +1,12 @@
package org.jeecg.modules.inquiry.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.jeecg.modules.inquiry.entity.InquiryResponseTimeout;
/**
* 响应超时配置 Mapper。
*/
@Mapper
public interface InquiryResponseTimeoutMapper extends BaseMapper<InquiryResponseTimeout> {
}
@@ -38,6 +38,14 @@ public interface IInquiryQuoteTemplateService extends IService<InquiryQuoteTempl
void disableTemplate(String templateId, String operator);
/**
* 删除逻辑模板及其全部版本行。
* <p>
* 副作用:删 inq_quote_template / inq_quote_template_version;尽量清理对象存储文件(失败只记日志)。
* 历史已出单文件不受影响(出单时已拷贝字节)。
*/
void deleteTemplate(String templateId, String operator);
void setDefault(String templateId, boolean isDefault, String operator);
/**
@@ -46,10 +54,52 @@ public interface IInquiryQuoteTemplateService extends IService<InquiryQuoteTempl
Path buildSamplePreview(String versionId) throws Exception;
/**
* 解析本地上传路径为绝对 Path。
* 管理端 Excel 样式预览:读上传原表格子原文 + 黄区/映射标注。
*/
Map<String, Object> buildSheetPreview(String versionId) throws Exception;
/**
* 管理端原表截图预览:本机 LibreOffice 转 PNG/PDF 并缓存,不经智能体。
* <p>
* 副作用:可能写 upload/.../preview/{versionId}-snapshot.png|pdf;短等 soffice。
*/
Path buildSnapshotPreview(String versionId) throws Exception;
/**
* 把 version.object_key(本地相对路径或 MinIO/OSS 完整 URL)解析为可读取的本地文件。
*/
Path resolveObjectPath(String objectKey);
/**
* 当前版本解析失败时按已存文件再提交 AI 异步解析,不新增版本。
* <p>
* 编辑保存时顺带调用:仅 failed 才重跑,避免已解析模板每次改名称都打千问。
*/
void reparseLatestIfFailed(String templateId);
/**
* 运营显式「重新解析」:最新 excel 版在 failed / parsed / mapping_incomplete 时可重入队;
* pending 中不重复提交。
*
* @return 给人看的结果说明
*/
String reparseLatest(String templateId);
/**
* 智能体回写 AI 映射结果(成功 mappingJson 或失败原因)。
* <p>
* 终态优先:已是 parsed/mapping_incomplete/failed 时可被同 version 覆盖;pending 必接受。
*/
void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage, String parseStatus);
/**
* 纠偏:把「已有映射点但仍标 mapping_incomplete」的版本改为 parsed。
* 用于历史数据(AI 把白名单必填提示误当成硬缺)。
*
* @return 纠偏条数
*/
int repairIncompleteParseStatus();
/**
* 智能体:已启用且 mode=auto 的模板指针列表。
*/
@@ -0,0 +1,31 @@
package org.jeecg.modules.inquiry.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.inquiry.entity.InquiryResponseTimeout;
/**
* 响应超时配置服务。
* <p>
* 仅支持查询与改小时数;禁止增删维度行。
* </p>
*/
public interface IInquiryResponseTimeoutService extends IService<InquiryResponseTimeout> {
/**
* 仅更新超时小时数;维度字段不可改。
*
* @param id 配置行 id
* @param timeoutHours 新小时数(≥0)
* @param updateBy 操作人登录名
*/
void updateTimeoutHours(String id, int timeoutHours, String updateBy);
/**
* 把陆运产品「普件,…」运输类型对齐产品确认的括号文案。
* <p>
* 仅替换已知旧文案 → 新括号格式;已是新文案则无副作用。
* 列表接口调用一次即可,避免后台长期显示旧逗号写法。
* </p>
*/
void ensureCanonicalTransportTypeLabels();
}
@@ -16,25 +16,34 @@ import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion;
import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateMapper;
import org.jeecg.modules.inquiry.mapper.InquiryQuoteTemplateVersionMapper;
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
import org.jeecg.modules.inquiry.template.QuoteTemplateAiParseClient;
import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport;
import org.jeecg.modules.inquiry.template.QuoteTemplateMatcher;
import org.jeecg.modules.inquiry.template.QuoteTemplateObjectStore;
import org.jeecg.modules.inquiry.template.QuoteTemplateSnapshotRenderer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 报价模板服务实现。
* <p>
* 副作用:写 inq_quote_template / version;文件经 Jeecg upload(local/minio)。
* 线程:HTTP 请求线程;解析/回填可能稍慢但不上 LibreOffice,禁止在此 sleep 等 LLM。
* 线程:HTTP 请求线程只抽单元格清单并入队智能体;千问在 Worker;截图另线程预热。
* 管理端原表截图预览可短等本机 soffice(文件锁互斥),禁止接到企微回调。
*/
@Slf4j
@Service
@@ -43,9 +52,27 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
private static final String BIZ_PATH = "inquiry/quote-templates";
/**
* 截图生成单线程池:上传接口快回,后台慢慢转;与智能体出单错开靠 SnapshotRenderer 文件锁。
*/
private static final ExecutorService SNAPSHOT_EXEC = Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "inquiry-quote-tpl-snapshot");
t.setDaemon(true);
return t;
});
@Autowired
private InquiryQuoteTemplateVersionMapper versionMapper;
@Autowired
private QuoteTemplateObjectStore objectStore;
@Autowired
private QuoteTemplateSnapshotRenderer snapshotRenderer;
@Autowired
private QuoteTemplateAiParseClient aiParseClient;
@Value("${jeecg.path.upload:}")
private String uploadPath;
@@ -105,6 +132,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
m.put("objectKey", ver.getObjectKey());
m.put("parseMessage", ver.getParseMessage());
m.put("unmappedRequired", extractUnmapped(ver.getMappingJson()));
m.put("mappingGaps", extractMappingGaps(ver.getMappingJson()));
} else {
m.put("versionId", null);
m.put("fileName", "");
@@ -165,6 +193,17 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
updateById(template);
}
// 先读字节:MinIO upload 会关掉流;解析必须用这份字节,不能把返回的 http URL 当本地路径。
byte[] fileBytes;
try {
fileBytes = file.getBytes();
} catch (Exception e) {
throw new IllegalStateException("读取上传文件失败: " + e.getMessage(), e);
}
if (fileBytes.length == 0) {
throw new IllegalArgumentException("上传文件为空");
}
String objectKey;
try {
objectKey = CommonUtils.upload(file, BIZ_PATH, uploadType);
@@ -177,12 +216,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
int nextVer = nextVersionNo(template.getId());
String sha;
try (InputStream in = file.getInputStream()) {
sha = DigestUtils.sha256Hex(in);
} catch (Exception e) {
sha = null;
}
String sha = DigestUtils.sha256Hex(fileBytes);
InquiryQuoteTemplateVersion version = new InquiryQuoteTemplateVersion();
version.setTemplateId(template.getId());
@@ -201,37 +235,26 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
version.setMappingPointCount(0);
version.setMappingJson("{\"schemaVersion\":\"quote-template-mapping-v1\",\"fields\":{},\"note\":\"pdf_manual_or_download_only\"}");
version.setParseMessage("PDF 不做单元格映射,可用于 manual 下载");
versionMapper.insert(version);
} else {
try {
Path local = resolveObjectPath(objectKey);
JSONObject contract = QuoteTemplateExcelSupport.loadContract(resolveContractPath());
JSONObject mapping = QuoteTemplateExcelSupport.parseWorkbook(local, contract);
JSONArray unmapped = mapping.getJSONArray("unmappedRequired");
int points = mapping.getJSONObject("fields") == null ? 0 : mapping.getJSONObject("fields").size();
version.setMappingJson(mapping.toJSONString());
version.setCandidatesJson(mapping.getJSONArray("candidates") == null
? "[]" : mapping.getJSONArray("candidates").toJSONString());
version.setMappingPointCount(points);
if (unmapped != null && !unmapped.isEmpty() && "auto".equals(template.getMode())) {
version.setParseStatus("mapping_incomplete");
version.setParseMessage("已解析 " + points + " 个映射点,尚有未映射必填:" + unmapped.toJSONString());
} else {
version.setParseStatus("parsed");
version.setParseMessage("已解析 " + points + " 个映射点");
}
} catch (Exception e) {
log.error("模板解析失败 objectKey={}", objectKey, e);
version.setParseStatus("failed");
version.setParseMessage("解析失败: " + e.getMessage());
version.setMappingPointCount(0);
}
version.setParseStatus("pending");
version.setParseMessage("解析中,请稍候");
version.setMappingPointCount(0);
version.setMappingJson("{\"schemaVersion\":\"quote-template-mapping-v1\",\"fields\":{},\"status\":\"pending\"}");
versionMapper.insert(version);
// 事务提交后再入队,避免 Worker 回写时版本行尚未可见
scheduleAiParseAfterCommit(version.getId(), template.getId(), fileBytes);
}
versionMapper.insert(version);
if (isDefault) {
setDefault(template.getId(), true, operator);
}
// 上传/换文件后立刻异步生成截图缓存,打开预览时尽量不现转
if (!isPdf && StringUtils.isNotBlank(version.getId())) {
enqueueSnapshotPreview(version.getId());
}
Map<String, Object> result = toListRow(getById(template.getId()));
result.put("versionId", version.getId());
result.put("parseStatus", mapParseStatusLabel(version.getParseStatus()));
@@ -241,6 +264,98 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
return result;
}
/**
* 事务提交后提交 AI 解析;无事务时立刻提交。
*/
private void scheduleAiParseAfterCommit(String versionId, String templateId, byte[] fileBytes) {
Runnable job = () -> {
try {
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
InquiryQuoteTemplate template = getById(templateId);
if (ver == null || template == null) {
log.warn("AI 解析入队跳过:版本或模板不存在 versionId={}", versionId);
return;
}
String enqueueErr = submitAiParse(ver, template, fileBytes);
if (enqueueErr != null) {
ver.setParseStatus("failed");
ver.setParseMessage(enqueueErr);
versionMapper.updateById(ver);
}
} catch (Exception e) {
log.error("事务后提交 AI 解析异常 versionId={}", versionId, e);
try {
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver != null) {
ver.setParseStatus("failed");
ver.setParseMessage("无法提交解析任务: " + e.getMessage());
versionMapper.updateById(ver);
}
} catch (Exception ignored) {
// ignore
}
}
};
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
SNAPSHOT_EXEC.execute(job);
}
});
} else {
SNAPSHOT_EXEC.execute(job);
}
}
/**
* 抽单元格清单并提交智能体入队。成功返回 null,失败返回中文原因。
*/
private String submitAiParse(InquiryQuoteTemplateVersion version, InquiryQuoteTemplate template, byte[] fileBytes) {
try {
JSONObject inventory;
try (InputStream in = new ByteArrayInputStream(fileBytes)) {
inventory = QuoteTemplateExcelSupport.buildCellInventory(in);
}
if (StringUtils.isNotBlank(inventory.getString("error"))) {
return "解析失败: Excel 无可用工作表";
}
Map<String, Object> body = new LinkedHashMap<>();
body.put("versionId", version.getId());
body.put("templateId", template.getId());
body.put("templateName", template.getName());
body.put("fileName", version.getFileName());
body.put("bizType", template.getBizType());
body.put("transportMode", bizTypeToTransportMode(template.getBizType()));
body.put("sheet", inventory.getString("sheet"));
body.put("cellInventory", inventory.getJSONArray("lines"));
body.put("cellCount", inventory.getIntValue("cellCount"));
body.put("yellowCount", inventory.getIntValue("yellowCount"));
body.put("truncated", inventory.getBooleanValue("truncated"));
body.put("requestId", version.getId() + "-" + System.currentTimeMillis());
return aiParseClient.enqueueParse(body);
} catch (Exception e) {
log.error("模板单元格清单抽取失败 versionId={}", version.getId(), e);
return "解析失败: 无法读取 Excel(" + e.getMessage() + ")";
}
}
/**
* 业务中文 → AI 提示词 transportMode。
*/
public static String bizTypeToTransportMode(String bizType) {
if (bizType == null) {
return "sea";
}
if (bizType.contains("空")) {
return "air";
}
if (bizType.contains("陆")) {
return "land";
}
return "sea";
}
@Override
public InquiryQuoteTemplateVersion getVersion(String versionId) {
return versionMapper.selectById(versionId);
@@ -265,15 +380,26 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
throw new IllegalArgumentException("mappingJson 非法");
}
mapping.put("schemaVersion", "quote-template-mapping-v1");
normalizeUnmappedRequired(mapping);
JSONObject fields = mapping.getJSONObject("fields");
int points = fields == null ? 0 : fields.size();
JSONArray feeRegions = mapping.getJSONArray("feeRegions");
if (feeRegions != null) {
points += feeRegions.size();
}
JSONArray routeRegions = mapping.getJSONArray("routeRegions");
if (routeRegions != null) {
points += routeRegions.size();
}
ver.setMappingJson(mapping.toJSONString());
ver.setMappingPointCount(points);
JSONArray unmapped = mapping.getJSONArray("unmappedRequired");
InquiryQuoteTemplate template = getById(ver.getTemplateId());
if (unmapped != null && !unmapped.isEmpty() && template != null && "auto".equals(template.getMode())) {
if (points <= 0) {
ver.setParseStatus("mapping_incomplete");
ver.setParseMessage("映射已保存,尚有未映射必填:" + unmapped.toJSONString());
ver.setParseMessage("映射已保存,但没有可用映射点");
} else if (unmapped != null && !unmapped.isEmpty()) {
ver.setParseStatus("parsed");
ver.setParseMessage("映射已确认,共 " + points + " 个映射点;可选缺口:" + unmapped.toJSONString());
} else {
ver.setParseStatus("parsed");
ver.setParseMessage("映射已确认,共 " + points + " 个映射点");
@@ -297,6 +423,9 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if ("failed".equals(ver.getParseStatus())) {
throw new IllegalStateException("解析失败的版本不能启用");
}
if ("pending".equals(ver.getParseStatus())) {
throw new IllegalStateException("模板仍在解析中,请稍后再启用");
}
if ("auto".equals(template.getMode()) && "mapping_incomplete".equals(ver.getParseStatus())) {
throw new IllegalStateException("自动模板须完成必填映射后才能启用");
}
@@ -334,6 +463,34 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
updateById(template);
}
/**
* 物理删除模板:先清版本与对象存储指针,再删逻辑头。
* <p>
* 启用中的模板也可删(运营确认后);删后智能体不再匹配到该模板。
* 对象存储清理失败不回滚库删,避免运营删不掉。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void deleteTemplate(String templateId, String operator) {
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
}
List<InquiryQuoteTemplateVersion> versions = listVersions(templateId);
for (InquiryQuoteTemplateVersion ver : versions) {
try {
objectStore.tryDelete(ver.getObjectKey());
} catch (Exception e) {
log.warn("模板文件清理跳过 templateId={} objectKey={} err={}",
templateId, ver.getObjectKey(), e.getMessage());
}
versionMapper.deleteById(ver.getId());
}
removeById(templateId);
log.info("报价模板已删除 templateId={} name={} versions={} by={}",
templateId, template.getName(), versions.size(), operator);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void setDefault(String templateId, boolean isDefault, String operator) {
@@ -392,31 +549,110 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
return target;
}
/**
* 读上传原 Excel,输出表格快照供后台「Excel 样式」预览。
* 副作用:可能从 MinIO 落地缓存;不改 mapping、不写样例事实。
*/
@Override
public Map<String, Object> buildSheetPreview(String versionId) throws Exception {
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
throw new IllegalArgumentException("版本不存在");
}
if (!"excel".equals(ver.getFormat())) {
throw new IllegalStateException("仅 Excel 支持表格预览");
}
Path source = resolveObjectPath(ver.getObjectKey());
JSONObject mapping;
try {
mapping = StringUtils.isNotBlank(ver.getMappingJson())
? JSON.parseObject(ver.getMappingJson())
: new JSONObject();
} catch (Exception e) {
mapping = new JSONObject();
}
JSONObject grid = QuoteTemplateExcelSupport.buildSheetPreviewGrid(source, mapping);
Map<String, Object> out = new LinkedHashMap<>();
out.put("versionId", versionId);
out.put("fileName", ver.getFileName());
out.putAll(grid);
return out;
}
/**
* 原表截图预览:优先读本地缓存;未命中则本机 LibreOffice 生成(不经智能体)。
* <p>
* 副作用:写 preview 缓存文件;短等 soffice(文件锁)。
*/
@Override
public Path buildSnapshotPreview(String versionId) throws Exception {
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
throw new IllegalArgumentException("版本不存在");
}
if (!"excel".equals(ver.getFormat())) {
throw new IllegalStateException("仅 Excel 支持原表截图预览");
}
Path cached = findCachedSnapshot(ver.getId());
if (cached != null) {
return cached;
}
return generateAndCacheSnapshot(ver);
}
/**
* 上传后异步预热截图:失败只记日志,不回滚上传事务。
*/
private void enqueueSnapshotPreview(String versionId) {
SNAPSHOT_EXEC.execute(() -> {
try {
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null || !"excel".equals(ver.getFormat())) {
return;
}
if (findCachedSnapshot(ver.getId()) != null) {
return;
}
generateAndCacheSnapshot(ver);
log.info("模板截图预热完成 versionId={}", versionId);
} catch (Exception e) {
log.warn("模板截图预热失败 versionId={} err={}", versionId, e.getMessage());
}
});
}
private Path findCachedSnapshot(String versionId) throws Exception {
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
Files.createDirectories(previewDir);
Path cachedPng = previewDir.resolve(versionId + "-snapshot.png");
Path cachedPdf = previewDir.resolve(versionId + "-snapshot.pdf");
if (Files.isRegularFile(cachedPng) && Files.size(cachedPng) > 0) {
return cachedPng;
}
if (Files.isRegularFile(cachedPdf) && Files.size(cachedPdf) > 0) {
return cachedPdf;
}
return null;
}
private Path generateAndCacheSnapshot(InquiryQuoteTemplateVersion ver) throws Exception {
Path previewDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, "."), BIZ_PATH, "preview");
Files.createDirectories(previewDir);
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot.png");
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot.pdf");
Path source = resolveObjectPath(ver.getObjectKey());
QuoteTemplateSnapshotRenderer.PreviewBytes rendered = snapshotRenderer.render(source, ver.getId());
Path target = rendered.isImage() ? cachedPng : cachedPdf;
Files.write(target, rendered.getBytes());
return target;
}
/**
* 解析本地路径或 MinIO/OSS URL 为可读取文件。远程 URL 禁止 Paths.get。
*/
@Override
public Path resolveObjectPath(String objectKey) {
if (StringUtils.isBlank(objectKey)) {
throw new IllegalArgumentException("objectKey 空");
}
Path p = Paths.get(objectKey);
if (p.isAbsolute() && Files.isRegularFile(p)) {
return p;
}
// Jeecg local 常返回相对 upload 的路径;minio 时需另下载体——本期以 local 为主
if (StringUtils.isNotBlank(uploadPath)) {
Path under = Paths.get(uploadPath, objectKey.replace("/", java.io.File.separator));
if (Files.isRegularFile(under)) {
return under;
}
// 有时 objectKey 已含盘符或 upload 前缀
under = Paths.get(uploadPath).resolve(objectKey);
if (Files.isRegularFile(under)) {
return under;
}
}
if (Files.isRegularFile(Paths.get(objectKey))) {
return Paths.get(objectKey);
}
throw new IllegalStateException("找不到模板文件: " + objectKey + "(uploadType=" + uploadType + ")");
return objectStore.resolve(objectKey);
}
@Override
@@ -681,6 +917,371 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
}
/**
* 最近一版解析失败时,用已存 objectKey 再提交 AI 异步解析,不新增版本。
* 仅 failed(编辑保存顺带补解析用)。
*/
@Override
public void reparseLatestIfFailed(String templateId) {
InquiryQuoteTemplateVersion ver = latestExcelVersion(templateId);
if (ver == null || !"failed".equals(ver.getParseStatus())) {
return;
}
enqueueReparse(templateId, ver);
}
/**
* 运营显式重新解析:failed / parsed / mapping_incomplete 可重跑;pending 跳过。
*/
@Override
public String reparseLatest(String templateId) {
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
}
InquiryQuoteTemplateVersion ver = latestExcelVersion(templateId);
if (ver == null) {
throw new IllegalStateException("无可用 Excel 版本,请先上传文件");
}
String st = ver.getParseStatus();
if ("pending".equals(st)) {
return "已在解析中,请稍候刷新列表";
}
if (!("failed".equals(st) || "parsed".equals(st) || "mapping_incomplete".equals(st))) {
throw new IllegalStateException("当前状态不可重新解析: " + st);
}
enqueueReparse(templateId, ver);
return "已提交重新解析,列表将显示解析中";
}
private InquiryQuoteTemplateVersion latestExcelVersion(String templateId) {
return versionMapper.selectOne(new QueryWrapper<InquiryQuoteTemplateVersion>()
.eq("template_id", templateId)
.eq("format", "excel")
.orderByDesc("version_no")
.last("LIMIT 1"));
}
/**
* 把指定版本标 pending 并入队 AI;读本地/对象存储字节。
*/
private void enqueueReparse(String templateId, InquiryQuoteTemplateVersion ver) {
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
}
try {
Path local = resolveObjectPath(ver.getObjectKey());
byte[] fileBytes = Files.readAllBytes(local);
ver.setParseStatus("pending");
ver.setParseMessage("解析中,请稍候");
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
scheduleAiParseAfterCommit(ver.getId(), template.getId(), fileBytes);
enqueueSnapshotPreview(ver.getId());
} catch (Exception e) {
log.error("模板补解析失败 templateId={} objectKey={}", templateId, ver.getObjectKey(), e);
ver.setParseStatus("failed");
ver.setParseMessage("解析失败: " + e.getMessage());
versionMapper.updateById(ver);
throw new IllegalStateException("解析失败: " + e.getMessage(), e);
}
}
/**
* 智能体回写 AI 映射。校验白名单与 feeRegion 增行配置;未映射必填 → mapping_incomplete。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void applyParseResult(String versionId, boolean ok, String mappingJson, String parseMessage, String parseStatus) {
if (StringUtils.isBlank(versionId)) {
throw new IllegalArgumentException("versionId 必填");
}
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
throw new IllegalArgumentException("版本不存在: " + versionId);
}
InquiryQuoteTemplate template = getById(ver.getTemplateId());
if (!ok) {
ver.setParseStatus("failed");
ver.setParseMessage(StringUtils.defaultIfBlank(parseMessage, "AI 解析失败"));
ver.setUpdateBy("agent");
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
return;
}
JSONObject mapping;
try {
mapping = JSON.parseObject(mappingJson);
} catch (Exception e) {
ver.setParseStatus("failed");
ver.setParseMessage("AI 返回的 mappingJson 非法: " + e.getMessage());
ver.setUpdateBy("agent");
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
return;
}
if (mapping == null) {
ver.setParseStatus("failed");
ver.setParseMessage("AI 返回空 mappingJson");
ver.setUpdateBy("agent");
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
return;
}
String validateErr = validateAiMapping(mapping);
if (validateErr != null) {
ver.setParseStatus("failed");
ver.setParseMessage(validateErr);
ver.setUpdateBy("agent");
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
return;
}
// 兼容旧填表:fixedCells → fixedYellowCells
if (mapping.getJSONArray("fixedYellowCells") == null && mapping.getJSONArray("fixedCells") != null) {
mapping.put("fixedYellowCells", mapping.getJSONArray("fixedCells"));
}
mapping.put("schemaVersion", "quote-template-mapping-v1");
applyExcelMapping(ver, template != null ? template : new InquiryQuoteTemplate(), mapping);
if (StringUtils.isNotBlank(parseMessage) && "failed".equals(ver.getParseStatus())) {
ver.setParseMessage(parseMessage);
}
ver.setUpdateBy("agent");
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
}
/**
* 把误标为 mapping_incomplete、但已有映射点的版本纠偏为 parsed。
*/
@Override
@Transactional(rollbackFor = Exception.class)
public int repairIncompleteParseStatus() {
List<InquiryQuoteTemplateVersion> list = versionMapper.selectList(new QueryWrapper<InquiryQuoteTemplateVersion>()
.eq("parse_status", "mapping_incomplete"));
int n = 0;
for (InquiryQuoteTemplateVersion ver : list) {
if (ver.getMappingPointCount() != null && ver.getMappingPointCount() > 0) {
ver.setParseStatus("parsed");
ver.setParseMessage("已解析 " + ver.getMappingPointCount() + " 个映射点");
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
n++;
} else if (StringUtils.isNotBlank(ver.getMappingJson())) {
try {
JSONObject mapping = JSON.parseObject(ver.getMappingJson());
int points = 0;
if (mapping.getJSONObject("fields") != null) {
points += mapping.getJSONObject("fields").size();
}
if (mapping.getJSONArray("feeRegions") != null) {
points += mapping.getJSONArray("feeRegions").size();
}
if (mapping.getJSONArray("routeRegions") != null) {
points += mapping.getJSONArray("routeRegions").size();
}
if (points > 0) {
ver.setMappingPointCount(points);
ver.setParseStatus("parsed");
ver.setParseMessage("已解析 " + points + " 个映射点");
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
n++;
}
} catch (Exception ignored) {
// 非法 JSON 跳过
}
}
}
return n;
}
/**
* 校验 AI mapping:字段白名单、fee/route 区域增行配置。
*
* @return null=通过;否则失败中文原因
*/
private String validateAiMapping(JSONObject mapping) {
Set<String> allowed = loadAllowedFieldCodes();
JSONObject fields = mapping.getJSONObject("fields");
if (fields != null) {
for (String code : fields.keySet()) {
if ("fee_rows".equals(code)) {
continue;
}
if (!allowed.isEmpty() && !allowed.contains(code)) {
return "AI 映射含非法字段码: " + code;
}
String ref = fields.getString(code);
if (StringUtils.isBlank(ref) || !ref.contains("!")) {
return "字段 " + code + " 的单元格地址非法(须含工作表名!)";
}
}
}
JSONArray feeRegions = mapping.getJSONArray("feeRegions");
if (feeRegions != null) {
for (int i = 0; i < feeRegions.size(); i++) {
JSONObject r = feeRegions.getJSONObject(i);
if (r == null) {
continue;
}
String grow = r.getString("grow");
if (StringUtils.isBlank(grow)) {
r.put("grow", "insert_before_stop");
}
if (r.getIntValue("styleCopyRows") <= 0) {
r.put("styleCopyRows", 1);
}
JSONArray stop = r.getJSONArray("stopAtText");
if (stop == null || stop.isEmpty()) {
return "feeRegion[" + i + "] 缺少 stopAtText";
}
if (StringUtils.isBlank(r.getString("anchor"))) {
return "feeRegion[" + i + "] 缺少 anchor";
}
}
}
JSONArray routeRegions = mapping.getJSONArray("routeRegions");
if (routeRegions != null) {
for (int i = 0; i < routeRegions.size(); i++) {
JSONObject r = routeRegions.getJSONObject(i);
if (r == null) {
continue;
}
if (StringUtils.isBlank(r.getString("grow"))) {
r.put("grow", "insert_before_stop");
}
if (r.getIntValue("styleCopyRows") <= 0) {
r.put("styleCopyRows", 1);
}
JSONArray stop = r.getJSONArray("stopAtText");
if (stop == null || stop.isEmpty()) {
return "routeRegion[" + i + "] 缺少 stopAtText";
}
}
}
return null;
}
private Set<String> loadAllowedFieldCodes() {
Set<String> codes = new LinkedHashSet<>();
try {
JSONObject contract = QuoteTemplateExcelSupport.loadContract(resolveContractPath());
JSONArray systemFields = contract.getJSONArray("systemFields");
if (systemFields != null) {
for (int i = 0; i < systemFields.size(); i++) {
JSONObject f = systemFields.getJSONObject(i);
if (f != null && StringUtils.isNotBlank(f.getString("code"))) {
codes.add(f.getString("code"));
}
}
}
} catch (Exception e) {
log.warn("加载字段白名单失败: {}", e.getMessage());
}
// 提示词扩展字段(合同可能尚未列全)
codes.addAll(Arrays.asList(
"chargeable_weight_kg", "delivery_address", "pickup_address", "transit_time",
"cargo_value", "hs_code", "oil_elec_mag", "has_battery", "on_board_date",
"special_remarks", "eta_request", "sailing_schedule", "shipping_line",
"flight_no", "transfer_airport", "effective_date", "fuel_surcharge_rate", "tax_rate",
"has_oil", "has_magnet", "vehicle_qty"
));
return codes;
}
/**
* 把解析结果写进版本行。不落库。
* <p>
* 产品口径(对齐映射 AI 提示词):系统字段白名单是天花板不是硬必填。
* 表上没有的标签不应拖成「映射未完成」。只要有可用 fields / feeRegions / routeRegions,即视为已解析;
* 仅当几乎没有任何可写映射时才标 mapping_incomplete(auto 不可启用)。
* <p>
* mappingGaps:智能体对照单元格清单算出的「表上有、未映射上」中文说明,供运营核对;
* 有缺口仍标 parsed,但 parseMessage 与列表字段会明确列出。
*/
private void applyExcelMapping(InquiryQuoteTemplateVersion version, InquiryQuoteTemplate template, JSONObject mapping) {
normalizeUnmappedRequired(mapping);
JSONArray unmapped = mapping.getJSONArray("unmappedRequired");
JSONArray gaps = mapping.getJSONArray("mappingGaps");
int points = mapping.getJSONObject("fields") == null ? 0 : mapping.getJSONObject("fields").size();
JSONArray feeRegions = mapping.getJSONArray("feeRegions");
if (feeRegions != null) {
points += feeRegions.size();
}
JSONArray routeRegions = mapping.getJSONArray("routeRegions");
if (routeRegions != null) {
points += routeRegions.size();
}
version.setMappingJson(mapping.toJSONString());
version.setCandidatesJson(mapping.getJSONArray("candidates") == null
? "[]" : mapping.getJSONArray("candidates").toJSONString());
version.setMappingPointCount(points);
boolean usable = points > 0;
if (!usable && "auto".equals(template.getMode())) {
version.setParseStatus("mapping_incomplete");
version.setParseMessage("未得到可用映射点,请检查模板或重新解析");
} else if (gaps != null && !gaps.isEmpty()) {
version.setParseStatus("parsed");
version.setParseMessage(buildGapsParseMessage(points, gaps));
} else if (unmapped != null && !unmapped.isEmpty()) {
// AI 自报未映射 code:有缺口只提示,不挡启用
version.setParseStatus("parsed");
version.setParseMessage("已解析 " + points + " 个映射点;可选缺口:" + unmapped.toJSONString());
} else {
version.setParseStatus("parsed");
version.setParseMessage("已解析 " + points + " 个映射点");
}
}
/**
* 组装运营可读的「已解析点数 + 未解析项」摘要(列表悬停/详情共用)。
*/
private static String buildGapsParseMessage(int points, JSONArray gaps) {
StringBuilder sb = new StringBuilder();
sb.append("已解析 ").append(points).append(" 个映射点;未解析到 ").append(gaps.size()).append(" 项:");
int limit = Math.min(gaps.size(), 8);
for (int i = 0; i < limit; i++) {
if (i > 0) {
sb.append(";");
}
sb.append(String.valueOf(gaps.get(i)));
}
if (gaps.size() > 8) {
sb.append(" 等共").append(gaps.size()).append("项");
}
return sb.toString();
}
/**
* 清理 AI 误报的「必填未映射」:白名单必填提示不等于表上一定有该格。
* 保留 unmappedLabels;unmappedRequired 仅保留仍空的提示,供运营参考。
*/
private void normalizeUnmappedRequired(JSONObject mapping) {
if (mapping == null) {
return;
}
JSONObject fields = mapping.getJSONObject("fields");
JSONArray unmapped = mapping.getJSONArray("unmappedRequired");
if (unmapped == null || unmapped.isEmpty()) {
return;
}
JSONArray kept = new JSONArray();
for (int i = 0; i < unmapped.size(); i++) {
String code = unmapped.getString(i);
if (StringUtils.isBlank(code)) {
continue;
}
// 已映上的不要再报缺
if (fields != null && StringUtils.isNotBlank(fields.getString(code))) {
continue;
}
kept.add(code);
}
mapping.put("unmappedRequired", kept);
}
private InquiryQuoteTemplateVersion findEnabledOrLatest(String templateId) {
InquiryQuoteTemplateVersion enabled = versionMapper.selectOne(new QueryWrapper<InquiryQuoteTemplateVersion>()
.eq("template_id", templateId)
@@ -753,6 +1354,31 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
}
/**
* 从 mappingJson 取出智能体对照清单算出的未映射说明(中文)。
*/
private static List<String> extractMappingGaps(String mappingJson) {
if (StringUtils.isBlank(mappingJson)) {
return Collections.emptyList();
}
try {
JSONArray arr = JSON.parseObject(mappingJson).getJSONArray("mappingGaps");
if (arr == null || arr.isEmpty()) {
return Collections.emptyList();
}
List<String> out = new ArrayList<>();
for (int i = 0; i < arr.size(); i++) {
String s = arr.getString(i);
if (StringUtils.isNotBlank(s)) {
out.add(s);
}
}
return out;
} catch (Exception e) {
return Collections.emptyList();
}
}
private static String mapParseStatusLabel(String status) {
if ("parsed".equals(status)) {
return "已解析";
@@ -0,0 +1,97 @@
package org.jeecg.modules.inquiry.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.inquiry.entity.InquiryResponseTimeout;
import org.jeecg.modules.inquiry.mapper.InquiryResponseTimeoutMapper;
import org.jeecg.modules.inquiry.service.IInquiryResponseTimeoutService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 响应超时配置:只改 timeout_hours,不动维度列(文案纠偏除外)。
* <p>
* 为何单独方法而不用 updateById 整行:避免前端误传改写运输方式/角色等固定种子。
* 运输类型纠偏是产品确认的一次性旧→新映射,不开放通用改维度接口。
* </p>
*/
@Service
public class InquiryResponseTimeoutServiceImpl
extends ServiceImpl<InquiryResponseTimeoutMapper, InquiryResponseTimeout>
implements IInquiryResponseTimeoutService {
/**
* 陆运产品运输类型:旧逗号写法 → 产品确认的括号写法。
* 急件文案不变,不在此映射。
*/
private static final Map<String, String> LAND_PRODUCT_TYPE_RENAMES = new LinkedHashMap<>();
static {
LAND_PRODUCT_TYPE_RENAMES.put(
"普件,中港、东南亚常用线路/常规车型",
"普件(东南亚、中港常用线路/常规车型)");
LAND_PRODUCT_TYPE_RENAMES.put(
"普件,中亚、中欧、东南亚涉及国内+国外段",
"普件(东南亚、中亚、中欧涉及国内+国外段)");
LAND_PRODUCT_TYPE_RENAMES.put(
"普件,中亚、中欧、东南亚涉及境外段清派等特殊要求",
"普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)");
LAND_PRODUCT_TYPE_RENAMES.put(
"普件,常用线路/常规车型",
"普件(常用线路/常规车型)");
LAND_PRODUCT_TYPE_RENAMES.put(
"普件,非常用线路/临时线路/特种车型",
"普件(非常用线路/临时线路/特种车型)");
}
/** 本进程内只纠偏一次,避免每次打开系统设置都打 UPDATE */
private volatile boolean labelsEnsured;
@Override
@Transactional(rollbackFor = Exception.class)
public void updateTimeoutHours(String id, int timeoutHours, String updateBy) {
if (timeoutHours < 0) {
throw new IllegalArgumentException("超时小时不能为负数");
}
InquiryResponseTimeout row = getById(id);
if (row == null) {
throw new IllegalArgumentException("超时配置不存在: " + id);
}
// 只写小时数与审计字段,禁止 updateById 整行误伤维度列
lambdaUpdate()
.eq(InquiryResponseTimeout::getId, id)
.set(InquiryResponseTimeout::getTimeoutHours, timeoutHours)
.set(InquiryResponseTimeout::getUpdateBy, updateBy)
.set(InquiryResponseTimeout::getUpdateTime, new Date())
.update();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void ensureCanonicalTransportTypeLabels() {
if (labelsEnsured) {
return;
}
synchronized (this) {
if (labelsEnsured) {
return;
}
Date now = new Date();
for (Map.Entry<String, String> e : LAND_PRODUCT_TYPE_RENAMES.entrySet()) {
// 按旧文案精确匹配;同文案可跨多条线路类别一并改写
lambdaUpdate()
.eq(InquiryResponseTimeout::getTransportMode, "LAND")
.eq(InquiryResponseTimeout::getRoleCode, "product")
.eq(InquiryResponseTimeout::getTransportType, e.getKey())
.set(InquiryResponseTimeout::getTransportType, e.getValue())
.set(InquiryResponseTimeout::getUpdateBy, "system")
.set(InquiryResponseTimeout::getUpdateTime, now)
.update();
}
labelsEnsured = true;
}
}
}
@@ -0,0 +1,120 @@
package org.jeecg.modules.inquiry.template;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.Map;
/**
* 主账 → 智能体:提交报价模板 AI 映射解析任务(只入队,不在此等千问)。
* <p>
* 副作用:出站 HTTP 打 AGENT_HTTP_BASE_URL;失败由调用方把版本标 failed。
* 禁止:本类同步等 LLM;禁止把密钥写进日志。
*/
@Slf4j
@Component
public class QuoteTemplateAiParseClient {
@Value("${inquiry.agent.base-url:${AGENT_HTTP_BASE_URL:http://127.0.0.1:8910}}")
private String agentBaseUrl;
@Value("${inquiry.agent.callback-token:${AGENT_CALLBACK_TOKEN:}}")
private String agentToken;
@Value("${inquiry.agent.parse-timeout-ms:${QUOTE_TEMPLATE_PARSE_ENQUEUE_TIMEOUT_MS:8000}}")
private int timeoutMs;
/**
* 把解析任务交给智能体 HTTP(入队后快回)。
*
* @param body versionId / transportMode / fileName / sheet / cellInventory 等
* @return null 表示成功;非空为失败原因(中文)
*/
public String enqueueParse(Map<String, Object> body) {
String base = StringUtils.removeEnd(StringUtils.defaultIfBlank(agentBaseUrl, ""), "/");
if (StringUtils.isBlank(base)) {
return "智能体地址未配置(AGENT_HTTP_BASE_URL)";
}
String url = base + "/internal/quote-template/parse";
HttpURLConnection conn = null;
try {
conn = (HttpURLConnection) new URL(url).openConnection();
conn.setRequestMethod("POST");
conn.setConnectTimeout(Math.max(2000, timeoutMs));
conn.setReadTimeout(Math.max(2000, timeoutMs));
conn.setDoOutput(true);
conn.setRequestProperty("Content-Type", "application/json;charset=UTF-8");
conn.setRequestProperty("Accept", "application/json");
if (StringUtils.isNotBlank(agentToken)) {
conn.setRequestProperty("X-Agent-Callback-Token", agentToken);
}
byte[] bytes = JSON.toJSONBytes(body);
try (OutputStream out = conn.getOutputStream()) {
out.write(bytes);
}
int code = conn.getResponseCode();
String respText = readQuietly(conn, code >= 200 && code < 300);
if (code == 202 || code == 200) {
JSONObject resp = safeJson(respText);
if (resp != null && Boolean.FALSE.equals(resp.getBoolean("accepted"))) {
return "智能体拒绝入队: " + StringUtils.defaultIfBlank(resp.getString("reason"), respText);
}
return null;
}
return "无法提交解析任务: HTTP " + code
+ (StringUtils.isNotBlank(respText) ? " " + truncate(respText, 180) : "");
} catch (Exception e) {
log.warn("提交模板 AI 解析失败 url={} err={}", url, e.getMessage());
return "无法提交解析任务: " + e.getMessage();
} finally {
if (conn != null) {
conn.disconnect();
}
}
}
private static String readQuietly(HttpURLConnection conn, boolean ok) {
try {
java.io.InputStream in = ok ? conn.getInputStream() : conn.getErrorStream();
if (in == null) {
return "";
}
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[2048];
int n;
while ((n = in.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
return new String(bos.toByteArray(), StandardCharsets.UTF_8);
} catch (Exception e) {
return "";
}
}
private static JSONObject safeJson(String text) {
if (StringUtils.isBlank(text)) {
return null;
}
try {
return JSON.parseObject(text);
} catch (Exception e) {
return null;
}
}
private static String truncate(String s, int max) {
if (s == null) {
return "";
}
String t = s.replaceAll("\\s+", " ").trim();
return t.length() <= max ? t : t.substring(0, max) + "…";
}
}
@@ -193,6 +193,15 @@ public final class QuoteTemplateExcelSupport {
* @return mapping 草案(含 fields / fixedYellowCells / candidates / unmappedRequired)
*/
public static JSONObject parseWorkbook(Path xlsxPath, JSONObject contract) throws Exception {
try (InputStream in = Files.newInputStream(xlsxPath)) {
return parseWorkbook(in, contract);
}
}
/**
* 从已打开的流解析 xlsx。上传当下用 Multipart 字节,避免先写成 MinIO URL 再 Paths.get。
*/
public static JSONObject parseWorkbook(InputStream in, JSONObject contract) throws Exception {
Map<String, String> dict = toStringMap(contract.getJSONObject("labelDictionary"));
JSONArray systemFields = contract.getJSONArray("systemFields");
Set<String> requiredHints = new HashSet<>();
@@ -212,7 +221,7 @@ public final class QuoteTemplateExcelSupport {
JSONArray candidates = new JSONArray();
Set<String> mappedCodes = new HashSet<>();
try (InputStream in = Files.newInputStream(xlsxPath); Workbook wb = WorkbookFactory.create(in)) {
try (Workbook wb = WorkbookFactory.create(in)) {
Sheet sheet = wb.getNumberOfSheets() > 0 ? wb.getSheetAt(0) : null;
if (sheet == null) {
mapping.put("parseError", "empty_workbook");
@@ -274,17 +283,263 @@ public final class QuoteTemplateExcelSupport {
}
/**
* 按 mapping.fields / fee_rows 写入事实;先清空映射覆盖的可填区(跳过黄区)。
* 抽取给映射 AI 的单元格清单(上传线程可跑:只读 POI,不调 LLM)。
* <p>
* 每行格式:地址 | 原文 | 合并范围 | 填充色。与「报价模板映射-AI提示词」用户消息对齐。
* 上限避免超大表撑爆模型上下文;优先保留有字或黄底的格。
*
* @return sheet / lines(文本行数组) / cellCount / yellowCount / truncated
*/
public static void fillWorkbook(Path sourceXlsx, Path targetXlsx, JSONObject mapping, JSONObject facts) throws Exception {
Set<String> yellow = new HashSet<>();
JSONArray yArr = mapping.getJSONArray("fixedYellowCells");
if (yArr != null) {
for (int i = 0; i < yArr.size(); i++) {
yellow.add(yArr.getString(i));
public static JSONObject buildCellInventory(InputStream in) throws Exception {
final int maxLines = 2500;
JSONObject out = new JSONObject(true);
JSONArray lines = new JSONArray();
int yellowCount = 0;
boolean truncated = false;
try (Workbook wb = WorkbookFactory.create(in)) {
Sheet sheet = wb.getNumberOfSheets() > 0 ? pickContentSheet(wb) : null;
if (sheet == null) {
out.put("sheet", "");
out.put("lines", lines);
out.put("cellCount", 0);
out.put("yellowCount", 0);
out.put("truncated", false);
out.put("error", "empty_workbook");
return out;
}
String sheetName = sheet.getSheetName();
out.put("sheet", sheetName);
DataFormatter fmt = new DataFormatter();
Map<String, String> mergeByOrigin = buildMergeIndex(sheet);
for (Row row : sheet) {
if (row == null) {
continue;
}
for (Cell cell : row) {
if (cell == null) {
continue;
}
String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell));
boolean yellow = isYellowFixed(cell);
String fill = cellFillHex(cell);
String addr = new CellReference(cell).formatAsString();
String merge = mergeByOrigin.getOrDefault(addr, "-");
if (text.isEmpty() && !yellow && "-".equals(merge) && StringUtils.isBlank(fill)) {
continue;
}
if (yellow) {
yellowCount++;
}
if (lines.size() >= maxLines) {
truncated = true;
break;
}
String safeText = text.replace('|', '/');
if (safeText.length() > 120) {
safeText = safeText.substring(0, 120) + "…";
}
lines.add(sheetName + "!" + addr + " | " + safeText + " | " + merge + " | "
+ (StringUtils.isBlank(fill) ? "-" : fill));
}
if (truncated) {
break;
}
}
}
out.put("lines", lines);
out.put("cellCount", lines.size());
out.put("yellowCount", yellowCount);
out.put("truncated", truncated);
return out;
}
public static JSONObject buildCellInventory(Path xlsxPath) throws Exception {
try (InputStream in = Files.newInputStream(xlsxPath)) {
return buildCellInventory(in);
}
}
/**
* 管理端「Excel 样式预览」:读上传原表前若干行/列,带出原文、黄区、映射字段。
* <p>
* 不写样例事实;只读文件。行/列有上限,避免大表拖垮后台弹窗。
*
* @param xlsxPath 本地可读取的模板文件
* @param mapping 可空;用于标注已映射写入格与优先工作表名
* @return sheet / colHeaders / rows(每行含 rowNo + cells)
*/
public static JSONObject buildSheetPreviewGrid(Path xlsxPath, JSONObject mapping) throws Exception {
final int maxRows = 40;
final int maxCols = 16;
JSONObject out = new JSONObject(true);
try (InputStream in = Files.newInputStream(xlsxPath); Workbook wb = WorkbookFactory.create(in)) {
if (wb.getNumberOfSheets() <= 0) {
out.put("error", "empty_workbook");
return out;
}
String prefer = mapping == null ? null : mapping.getString("sheet");
Sheet sheet = null;
if (StringUtils.isNotBlank(prefer)) {
sheet = wb.getSheet(prefer);
}
if (sheet == null) {
// 映射格子常在 Sheet2;优先选「有内容」的第一张,否则第 0 张
sheet = pickContentSheet(wb);
}
String sheetName = sheet.getSheetName();
out.put("sheet", sheetName);
Map<String, String> cellToField = new HashMap<>();
Map<String, String> fieldLabels = defaultFieldLabels();
if (mapping != null && mapping.getJSONObject("fields") != null) {
JSONObject fields = mapping.getJSONObject("fields");
for (String code : fields.keySet()) {
if ("fee_rows".equals(code)) {
continue;
}
String ref = fields.getString(code);
if (StringUtils.isBlank(ref)) {
continue;
}
cellToField.put(normalizeRef(ref, sheetName), code);
}
}
int lastRow = Math.min(sheet.getLastRowNum(), maxRows - 1);
int lastCol = 0;
DataFormatter fmt = new DataFormatter();
for (int r = 0; r <= lastRow; r++) {
Row row = sheet.getRow(r);
if (row == null) {
continue;
}
short last = row.getLastCellNum();
if (last > 0) {
lastCol = Math.max(lastCol, Math.min(last - 1, maxCols - 1));
}
}
// 至少展示 A–F,避免空白表太窄
lastCol = Math.max(lastCol, 5);
lastCol = Math.min(lastCol, maxCols - 1);
JSONArray colHeaders = new JSONArray();
for (int c = 0; c <= lastCol; c++) {
colHeaders.add(CellReference.convertNumToColString(c));
}
out.put("colHeaders", colHeaders);
JSONArray rows = new JSONArray();
for (int r = 0; r <= lastRow; r++) {
JSONObject rowObj = new JSONObject(true);
rowObj.put("rowNo", r + 1);
JSONArray cells = new JSONArray();
Row row = sheet.getRow(r);
for (int c = 0; c <= lastCol; c++) {
JSONObject cellObj = new JSONObject(true);
String addr = sheetName + "!" + CellReference.convertNumToColString(c) + (r + 1);
cellObj.put("addr", addr);
Cell cell = row == null ? null : row.getCell(c);
String text = "";
boolean yellow = false;
if (cell != null) {
text = StringUtils.trimToEmpty(fmt.formatCellValue(cell));
yellow = isYellowFixed(cell);
}
String code = cellToField.get(normalizeRef(addr, sheetName));
if (code == null) {
// 兼容 mapping 里只存 B4 不带 sheet 前缀
code = cellToField.get(CellReference.convertNumToColString(c) + (r + 1));
}
cellObj.put("text", text.length() > 80 ? text.substring(0, 80) + "…" : text);
cellObj.put("yellow", yellow);
if (code != null) {
cellObj.put("mappedCode", code);
cellObj.put("mappedLabel", fieldLabels.getOrDefault(code, code));
}
cells.add(cellObj);
}
rowObj.put("cells", cells);
rows.add(rowObj);
}
out.put("rows", rows);
out.put("rowCount", lastRow + 1);
out.put("colCount", lastCol + 1);
out.put("mappedCount", cellToField.size());
return out;
}
}
/** 选内容最多的工作表,便于空运模板主内容在 Sheet2 时仍能预览到。 */
private static Sheet pickContentSheet(Workbook wb) {
Sheet best = wb.getSheetAt(0);
int bestScore = -1;
for (int i = 0; i < wb.getNumberOfSheets(); i++) {
Sheet s = wb.getSheetAt(i);
int score = 0;
int last = Math.min(s.getLastRowNum(), 30);
for (int r = 0; r <= last; r++) {
Row row = s.getRow(r);
if (row == null) {
continue;
}
short n = row.getLastCellNum();
if (n > 0) {
score += n;
}
}
if (score > bestScore) {
bestScore = score;
best = s;
}
}
return best;
}
private static String normalizeRef(String ref, String sheetName) {
if (StringUtils.isBlank(ref)) {
return "";
}
String r = ref.trim();
if (r.contains("!")) {
return r;
}
return sheetName + "!" + r;
}
private static Map<String, String> defaultFieldLabels() {
Map<String, String> m = new LinkedHashMap<>();
m.put("customer_name", "客户名称");
m.put("contact_name", "联系人");
m.put("contact_phone", "电话");
m.put("quote_no", "报价单编号");
m.put("quote_date", "报价日期");
m.put("valid_until", "有效期至");
m.put("currency", "币别");
m.put("sales_name", "业务员");
m.put("pol", "起运港");
m.put("pod", "目的港");
m.put("origin", "始发地");
m.put("destination", "目的地");
m.put("commodity", "品名");
m.put("quantity", "货物数量");
m.put("container_type_qty", "箱型箱量");
m.put("fcl_or_lcl", "整柜或拼柜");
m.put("trade_terms", "贸易条款");
m.put("cargo_ready_date", "货好时间");
m.put("pieces", "件数");
m.put("weight_kg", "重量");
m.put("volume_cbm", "体积");
m.put("total_amount", "报价合计");
return m;
}
/**
* 按 mapping.fields / fee_rows / feeRegions / routeRegions 写入事实;先清空映射覆盖的可填区(跳过黄区/固定区)。
*/
public static void fillWorkbook(Path sourceXlsx, Path targetXlsx, JSONObject mapping, JSONObject facts) throws Exception {
Set<String> yellow = collectFixedCells(mapping);
try (InputStream in = Files.newInputStream(sourceXlsx); Workbook wb = WorkbookFactory.create(in)) {
JSONObject fieldMeta = mapping.getJSONObject("fieldMeta");
JSONObject fields = mapping.getJSONObject("fields");
if (fields != null) {
for (String code : fields.keySet()) {
@@ -296,15 +551,24 @@ public final class QuoteTemplateExcelSupport {
if ("fee_rows".equals(code)) {
continue;
}
// 空值不覆盖模板原字,避免把单位格/币别格擦掉
if (val == null || StringUtils.isBlank(String.valueOf(val))) {
continue;
}
writeCell(wb, cellRef, String.valueOf(val));
boolean afterColon = false;
if (fieldMeta != null && fieldMeta.getJSONObject(code) != null) {
afterColon = Boolean.TRUE.equals(fieldMeta.getJSONObject(code).getBoolean("sameCellAfterColon"));
}
if (afterColon) {
writeCellAfterColon(wb, cellRef, String.valueOf(val));
} else {
writeCell(wb, cellRef, String.valueOf(val));
}
}
}
// 海运询价单映射常只对上 4 个点,且起运港映进了标签合并格。按标签再写一遍正确格子。
writeFactsByLabel(wb, facts, yellow);
writeFeeRegions(wb, mapping, facts, yellow);
writeRouteRegions(wb, mapping, facts, yellow);
writeFeeRows(wb, mapping, facts, yellow);
// 后台常未配 fee_rows:按「空运费」等名称写入金额列,SUM 才能加算
writeFeeAmountsByName(wb, facts, yellow);
@@ -320,6 +584,337 @@ public final class QuoteTemplateExcelSupport {
}
}
/**
* 固定区:AI 的 fixedCells ∪ 词典解析的 fixedYellowCells。
*/
static Set<String> collectFixedCells(JSONObject mapping) {
Set<String> yellow = new HashSet<>();
if (mapping == null) {
return yellow;
}
for (String key : new String[]{"fixedYellowCells", "fixedCells"}) {
JSONArray yArr = mapping.getJSONArray(key);
if (yArr == null) {
continue;
}
for (int i = 0; i < yArr.size(); i++) {
String ref = yArr.getString(i);
if (StringUtils.isNotBlank(ref)) {
yellow.add(ref);
}
}
}
return yellow;
}
/**
* 同格冒号后改值:强制保留标签前缀。
*/
private static void writeCellAfterColon(Workbook wb, String cellRef, String value) {
ParsedRef ref = ParsedRef.parse(cellRef);
Sheet sheet = wb.getSheet(ref.sheet);
if (sheet == null && wb.getNumberOfSheets() > 0) {
sheet = wb.getSheetAt(0);
}
if (sheet == null) {
return;
}
int[] dest = resolveMergeOrigin(sheet, ref.row, ref.col);
Row row = sheet.getRow(dest[0]);
if (row == null) {
row = sheet.createRow(dest[0]);
}
Cell cell = row.getCell(dest[1]);
if (cell == null) {
cell = row.createCell(dest[1]);
}
String existing = readCellText(cell);
int colon = Math.max(existing.indexOf(':'), existing.indexOf(':'));
if (colon >= 0) {
cell.setCellValue(existing.substring(0, colon + 1) + (value == null ? "" : value));
} else {
cell.setCellValue(value == null ? "" : value);
}
}
/**
* 按 AI feeRegions 写入费用行;支持 insert_before_stop 增行。
* facts 可带 fee_rows / fee_rows_{tmsSource} / fee_rows_{regionId}。
*/
private static void writeFeeRegions(Workbook wb, JSONObject mapping, JSONObject facts, Set<String> yellow) {
JSONArray regions = mapping.getJSONArray("feeRegions");
if (regions == null || regions.isEmpty()) {
return;
}
for (int i = 0; i < regions.size(); i++) {
JSONObject region = regions.getJSONObject(i);
if (region == null) {
continue;
}
JSONArray rows = resolveRegionFeeRows(facts, region);
if (rows.isEmpty()) {
continue;
}
String anchor = region.getString("anchor");
JSONObject columns = region.getJSONObject("columns");
if (StringUtils.isBlank(anchor) || columns == null) {
continue;
}
ParsedRef anchorRef = ParsedRef.parse(anchor);
Sheet sheet = wb.getSheet(anchorRef.sheet);
if (sheet == null) {
sheet = wb.getSheetAt(0);
}
int startRow = anchorRef.row;
int stopRow = findStopRow(sheet, region.getJSONArray("stopAtText"), startRow);
int styleCopy = region.getIntValue("styleCopyRows");
if (styleCopy <= 0) {
styleCopy = 1;
}
ensureRowsBeforeStop(sheet, startRow, rows.size(), stopRow, styleCopy);
for (int r = 0; r < rows.size(); r++) {
JSONObject rowObj = rows.getJSONObject(r);
int rowIdx = startRow + r;
writeFeeCol(sheet, rowIdx, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName());
writeFeeCol(sheet, rowIdx, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName());
writeFeeCol(sheet, rowIdx, columns.getString("quantity"), rowObj.getString("quantity"), yellow, sheet.getSheetName());
writeFeeCol(sheet, rowIdx, columns.getString("unit_price"),
firstNonBlank(rowObj.getString("unit_price"), rowObj.getString("unitPrice")),
yellow, sheet.getSheetName());
writeFeeCol(sheet, rowIdx, columns.getString("amount"), rowObj.getString("amount"), yellow, sheet.getSheetName());
if (columns.containsKey("currency")) {
writeFeeCol(sheet, rowIdx, columns.getString("currency"), rowObj.getString("currency"), yellow, sheet.getSheetName());
}
if (columns.containsKey("remark")) {
writeFeeCol(sheet, rowIdx, columns.getString("remark"),
formatFeeRemark(rowObj.getString("remark")), yellow, sheet.getSheetName());
}
if (columns.containsKey("container_rate")) {
writeFeeCol(sheet, rowIdx, columns.getString("container_rate"),
firstNonBlank(rowObj.getString("container_rate"), rowObj.getString("containerType"),
rowObj.getString("unit_price")),
yellow, sheet.getSheetName());
}
}
}
}
/**
* 陆运 routeRegions:按 origin/destination/vehicleRates 等列写入。
*/
private static void writeRouteRegions(Workbook wb, JSONObject mapping, JSONObject facts, Set<String> yellow) {
JSONArray regions = mapping.getJSONArray("routeRegions");
if (regions == null || regions.isEmpty()) {
return;
}
JSONArray routes = parseFeeRows(facts == null ? null : facts.get("route_rows"));
if (routes.isEmpty()) {
routes = parseFeeRows(facts == null ? null : facts.get("landSegments"));
}
if (routes.isEmpty()) {
// 单行兜底:用 facts 里的 origin/destination
JSONObject one = new JSONObject(true);
if (facts != null) {
one.put("origin", facts.getString("origin"));
one.put("destination", facts.getString("destination"));
one.put("currency", facts.getString("currency"));
one.put("transit_time", firstNonBlank(facts.getString("transit_time"), facts.getString("transit_days")));
one.put("remark", facts.getString("special_remarks"));
}
if (StringUtils.isNotBlank(one.getString("origin")) || StringUtils.isNotBlank(one.getString("destination"))) {
routes.add(one);
}
}
if (routes.isEmpty()) {
return;
}
for (int i = 0; i < regions.size(); i++) {
JSONObject region = regions.getJSONObject(i);
if (region == null) {
continue;
}
JSONObject columns = region.getJSONObject("columns");
if (columns == null) {
continue;
}
int startRow = region.getIntValue("dataStartRow");
if (startRow <= 0) {
startRow = region.getIntValue("headerRow") + 1;
}
if (startRow <= 0) {
continue;
}
// dataStartRow 在合同里常是 1-based Excel 行号
int rowIdx0 = startRow - 1;
String sheetName = mapping.getString("sheet");
Sheet sheet = StringUtils.isNotBlank(sheetName) ? wb.getSheet(sheetName) : null;
if (sheet == null) {
sheet = wb.getSheetAt(0);
}
int stopRow = findStopRow(sheet, region.getJSONArray("stopAtText"), rowIdx0);
int styleCopy = region.getIntValue("styleCopyRows");
if (styleCopy <= 0) {
styleCopy = 1;
}
ensureRowsBeforeStop(sheet, rowIdx0, routes.size(), stopRow, styleCopy);
for (int r = 0; r < routes.size(); r++) {
JSONObject rowObj = routes.getJSONObject(r);
int ri = rowIdx0 + r;
writeFeeCol(sheet, ri, columns.getString("seq"), String.valueOf(r + 1), yellow, sheet.getSheetName());
writeFeeCol(sheet, ri, columns.getString("origin"),
firstNonBlank(rowObj.getString("origin"), rowObj.getString("originCode")),
yellow, sheet.getSheetName());
writeFeeCol(sheet, ri, columns.getString("destination"),
firstNonBlank(rowObj.getString("destination"), rowObj.getString("destinationCode")),
yellow, sheet.getSheetName());
writeFeeCol(sheet, ri, columns.getString("currency"), rowObj.getString("currency"), yellow, sheet.getSheetName());
writeFeeCol(sheet, ri, columns.getString("transit_time"),
firstNonBlank(rowObj.getString("transit_time"), rowObj.getString("transitDays")),
yellow, sheet.getSheetName());
writeFeeCol(sheet, ri, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName());
JSONObject vehicleRates = columns.getJSONObject("vehicleRates");
if (vehicleRates != null) {
String vehicleType = firstNonBlank(rowObj.getString("vehicleType"), rowObj.getString("vehicle_type"));
String price = firstNonBlank(rowObj.getString("salePrice"), rowObj.getString("costPrice"),
rowObj.getString("amount"));
for (String vk : vehicleRates.keySet()) {
String col = vehicleRates.getString(vk);
if (StringUtils.isBlank(col)) {
continue;
}
if (StringUtils.isBlank(vehicleType) || vehicleTypeEquals(vehicleType, vk)) {
writeFeeCol(sheet, ri, col, price, yellow, sheet.getSheetName());
}
}
}
}
}
}
private static boolean vehicleTypeEquals(String actual, String headerKey) {
if (StringUtils.isBlank(actual) || StringUtils.isBlank(headerKey)) {
return false;
}
String a = actual.replace(" ", "").toUpperCase(Locale.ROOT);
String b = headerKey.replace(" ", "").toUpperCase(Locale.ROOT);
return a.equals(b) || a.contains(b) || b.contains(a);
}
private static JSONArray resolveRegionFeeRows(JSONObject facts, JSONObject region) {
if (facts == null) {
return new JSONArray();
}
String id = StringUtils.defaultString(region.getString("id"));
String tms = StringUtils.defaultString(region.getString("tmsSource"));
JSONArray rows = parseFeeRows(facts.get("fee_rows_" + id));
if (rows.isEmpty() && StringUtils.isNotBlank(tms)) {
rows = parseFeeRows(facts.get("fee_rows_" + tms));
if (rows.isEmpty()) {
rows = parseFeeRows(facts.get(tms));
}
}
if (rows.isEmpty()) {
rows = parseFeeRows(facts.get("fee_rows"));
}
return rows;
}
private static int findStopRow(Sheet sheet, JSONArray stopAtText, int fromRow) {
if (sheet == null || stopAtText == null || stopAtText.isEmpty()) {
return Integer.MAX_VALUE;
}
DataFormatter fmt = new DataFormatter();
List<String> needles = new ArrayList<>();
for (int i = 0; i < stopAtText.size(); i++) {
String t = StringUtils.trimToEmpty(stopAtText.getString(i));
if (!t.isEmpty()) {
needles.add(t);
}
}
if (needles.isEmpty()) {
return Integer.MAX_VALUE;
}
int last = sheet.getLastRowNum();
for (int r = Math.max(0, fromRow); r <= last; r++) {
Row row = sheet.getRow(r);
if (row == null) {
continue;
}
for (Cell cell : row) {
if (cell == null) {
continue;
}
String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell));
if (text.isEmpty()) {
continue;
}
for (String n : needles) {
if (text.contains(n)) {
return r;
}
}
}
}
return Integer.MAX_VALUE;
}
/**
* 需要的行数若会撞到 stop 行,则在 stop 前插入并复制样式行。
*/
private static void ensureRowsBeforeStop(Sheet sheet, int startRow, int needCount, int stopRow, int styleCopyRows) {
if (sheet == null || needCount <= 0) {
return;
}
int endNeeded = startRow + needCount;
if (stopRow == Integer.MAX_VALUE || endNeeded <= stopRow) {
return;
}
int insertAt = Math.max(startRow, stopRow);
int insertCount = endNeeded - stopRow;
if (insertCount <= 0) {
return;
}
sheet.shiftRows(insertAt, Math.max(sheet.getLastRowNum(), insertAt), insertCount, true, false);
int styleFrom = Math.max(0, startRow);
for (int i = 0; i < insertCount; i++) {
int target = insertAt + i;
Row src = sheet.getRow(styleFrom + (i % Math.max(1, styleCopyRows)));
Row dst = sheet.getRow(target);
if (dst == null) {
dst = sheet.createRow(target);
}
if (src != null) {
dst.setHeight(src.getHeight());
for (int c = 0; c < src.getLastCellNum(); c++) {
Cell sc = src.getCell(c);
if (sc == null) {
continue;
}
Cell dc = dst.getCell(c);
if (dc == null) {
dc = dst.createCell(c);
}
if (sc.getCellStyle() != null) {
dc.setCellStyle(sc.getCellStyle());
}
dc.setCellValue("");
}
}
}
}
private static String firstNonBlank(String... vals) {
if (vals == null) {
return "";
}
for (String v : vals) {
if (StringUtils.isNotBlank(v) && !"-".equals(v.trim())) {
return v.trim();
}
}
return "";
}
private static void writeFeeRows(Workbook wb, JSONObject mapping, JSONObject facts, Set<String> yellow) {
JSONObject feeRows = mapping.getJSONObject("fee_rows");
if (feeRows == null) {
@@ -1310,6 +1905,47 @@ public final class QuoteTemplateExcelSupport {
return false;
}
/**
* 合并区索引:origin 单元格地址 → 「A1:C2」范围串;非合并原点不收录。
*/
private static Map<String, String> buildMergeIndex(Sheet sheet) {
Map<String, String> map = new HashMap<>();
if (sheet == null) {
return map;
}
for (int i = 0; i < sheet.getNumMergedRegions(); i++) {
org.apache.poi.ss.util.CellRangeAddress range = sheet.getMergedRegion(i);
if (range == null) {
continue;
}
String origin = new CellReference(range.getFirstRow(), range.getFirstColumn()).formatAsString();
String end = new CellReference(range.getLastRow(), range.getLastColumn()).formatAsString();
map.put(origin, origin + ":" + end);
}
return map;
}
/**
* 单元格填充色十六进制(无则空串)。给 AI 清单用,黄区识别不依赖此方法。
*/
private static String cellFillHex(Cell cell) {
if (cell == null || cell.getCellStyle() == null) {
return "";
}
if (!(cell instanceof XSSFCell)) {
return "";
}
XSSFColor color = ((XSSFCell) cell).getCellStyle().getFillForegroundXSSFColor();
if (color == null) {
return "";
}
byte[] rgb = color.getRGB();
if (rgb == null || rgb.length < 3) {
return "";
}
return String.format("%02X%02X%02X", rgb[0] & 0xff, rgb[1] & 0xff, rgb[2] & 0xff);
}
private static Map<String, String> toStringMap(JSONObject obj) {
Map<String, String> map = new LinkedHashMap<>();
if (obj == null) {
@@ -1546,18 +2182,6 @@ public final class QuoteTemplateExcelSupport {
return "";
}
static String firstNonBlank(String... vals) {
if (vals == null) {
return "";
}
for (String v : vals) {
if (StringUtils.isNotBlank(v) && !"-".equals(v.trim())) {
return v.trim();
}
}
return "";
}
/** sheet!A1 解析。 */
private static final class ParsedRef {
final String sheet;
@@ -0,0 +1,238 @@
package org.jeecg.modules.inquiry.template;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.common.util.MinioUtil;
import org.jeecg.common.util.oss.OssBootUtil;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.InputStream;
import java.net.URI;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Locale;
/**
* 报价模板文件指针解析。
* <p>
* Jeecg MinIO/OSS 上传返回完整 http(s) URL,Windows 上 {@code Paths.get("http://...")}
* 会在冒号处抛 Illegal char &lt;:&gt;。本类先认远程 URL,拉到本地缓存再给 POI / 下载。
* 副作用:写 {@code upload/inquiry/quote-templates/_remote-cache/}。
* 线程:跟随调用方 HTTP 线程;不持有全局锁。
*/
@Slf4j
@Component
public class QuoteTemplateObjectStore {
private static final String BIZ_PATH = "inquiry/quote-templates";
@Value("${jeecg.path.upload:}")
private String uploadPath;
/**
* 尽量删除 objectKey 对应文件(MinIO/OSS/本地)。失败只记日志,不抛给业务层。
* <p>
* 删除模板时调用:库记录必须删掉,对象存储偶发失败不应阻断运营。
*/
public void tryDelete(String objectKey) {
if (StringUtils.isBlank(objectKey)) {
return;
}
String key = objectKey.trim();
try {
if (isRemoteUrl(key)) {
String[] parts = splitStorageUrl(key, MinioUtil.getMinioUrl());
if (parts != null) {
MinioUtil.removeObject(parts[0], parts[1]);
return;
}
log.warn("无法从 URL 拆出 bucket/object,跳过存储删除 key={}", key);
return;
}
Path local = tryLocalFile(key);
if (local != null) {
Files.deleteIfExists(local);
}
} catch (Exception e) {
log.warn("模板对象存储删除失败 key={} err={}", key, e.getMessage());
}
}
/**
* 把 objectKey(本地相对路径或 MinIO/OSS URL)变成可读取的本地文件。
*
* @param objectKey 主账 version.object_key
* @return 已存在的本地文件,或从对象存储落地后的缓存文件
*/
public Path resolve(String objectKey) {
if (StringUtils.isBlank(objectKey)) {
throw new IllegalArgumentException("objectKey 空");
}
String key = objectKey.trim();
if (isRemoteUrl(key)) {
return fetchRemoteToCache(key);
}
Path local = tryLocalFile(key);
if (local != null) {
return local;
}
throw new IllegalStateException("找不到模板文件: " + key);
}
/**
* 是否为对象存储返回的完整 URL。禁止对这些值调用 Paths.get。
*/
static boolean isRemoteUrl(String key) {
if (StringUtils.isBlank(key)) {
return false;
}
String lower = key.trim().toLowerCase(Locale.ROOT);
return lower.startsWith("http://") || lower.startsWith("https://");
}
/**
* 拆 MinIO 风格 URL:{@code {minioUrl}{bucket}/{objectName}}。
*
* @return [bucket, objectName],无法识别则 null
*/
static String[] splitStorageUrl(String url, String minioBase) {
if (StringUtils.isBlank(url)) {
return null;
}
String trimmed = url.trim();
if (StringUtils.isNotBlank(minioBase)) {
String base = minioBase.endsWith("/") ? minioBase : minioBase + "/";
if (trimmed.startsWith(base)) {
return splitBucketAndObject(trimmed.substring(base.length()));
}
}
try {
URI uri = URI.create(trimmed);
String path = uri.getPath();
if (StringUtils.isBlank(path)) {
return null;
}
if (path.startsWith("/")) {
path = path.substring(1);
}
return splitBucketAndObject(path);
} catch (Exception e) {
return null;
}
}
private static String[] splitBucketAndObject(String rest) {
if (StringUtils.isBlank(rest)) {
return null;
}
int slash = rest.indexOf('/');
if (slash <= 0 || slash >= rest.length() - 1) {
return null;
}
return new String[]{rest.substring(0, slash), rest.substring(slash + 1)};
}
/**
* 从 MinIO/OSS 拉到本地缓存。同一 URL 只落一次(文件名带时间戳,一般不会覆盖)。
*/
private Path fetchRemoteToCache(String url) {
Path cacheDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, System.getProperty("java.io.tmpdir")),
BIZ_PATH, "_remote-cache");
try {
Files.createDirectories(cacheDir);
} catch (Exception e) {
throw new IllegalStateException("无法创建模板缓存目录: " + cacheDir, e);
}
String ext = guessExtension(url);
Path cacheFile = cacheDir.resolve(DigestUtils.sha256Hex(url) + ext);
try {
if (Files.isRegularFile(cacheFile) && Files.size(cacheFile) > 0) {
return cacheFile;
}
} catch (Exception ignored) {
// 读缓存失败则重新拉取
}
try (InputStream in = openRemoteStream(url)) {
Files.copy(in, cacheFile, StandardCopyOption.REPLACE_EXISTING);
} catch (Exception e) {
log.error("对象存储落地失败 url={}", url, e);
throw new IllegalStateException("对象存储读取失败: " + e.getMessage(), e);
}
return cacheFile;
}
private InputStream openRemoteStream(String url) {
String[] parts = splitStorageUrl(url, MinioUtil.getMinioUrl());
if (parts != null) {
InputStream minio = MinioUtil.getMinioFile(parts[0], parts[1]);
if (minio != null) {
return minio;
}
InputStream oss = OssBootUtil.getOssFile(parts[1], parts[0]);
if (oss != null) {
return oss;
}
}
InputStream ossByUrl = OssBootUtil.getOssFile(url, null);
if (ossByUrl != null) {
return ossByUrl;
}
throw new IllegalStateException("对象存储没有该文件: " + url);
}
private Path tryLocalFile(String key) {
try {
Path p = Paths.get(key);
if (p.isAbsolute() && Files.isRegularFile(p)) {
return p;
}
} catch (InvalidPathException ignored) {
return null;
}
if (StringUtils.isNotBlank(uploadPath)) {
Path under = Paths.get(uploadPath, key.replace("/", java.io.File.separator));
if (Files.isRegularFile(under)) {
return under;
}
try {
Path resolved = Paths.get(uploadPath).resolve(key);
if (Files.isRegularFile(resolved)) {
return resolved;
}
} catch (InvalidPathException ignored) {
// 混入奇怪字符时跳过
}
}
try {
Path relative = Paths.get(key);
if (Files.isRegularFile(relative)) {
return relative;
}
} catch (InvalidPathException ignored) {
return null;
}
return null;
}
private static String guessExtension(String url) {
String path = url;
int q = path.indexOf('?');
if (q >= 0) {
path = path.substring(0, q);
}
int slash = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'));
int dot = path.lastIndexOf('.');
if (dot > slash && path.length() - dot <= 8) {
String ext = path.substring(dot).toLowerCase(Locale.ROOT);
if (ext.matches("\\.[a-z0-9]+")) {
return ext;
}
}
return ".bin";
}
}
@@ -0,0 +1,201 @@
package org.jeecg.modules.inquiry.template;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* 管理端报价模板「原表截图」:本机 LibreOffice 把 xlsx 转 PNG(失败则 PDF)。
* <p>
* 为何放在主账:后台预览不应依赖智能体进程;与出单 PDF 错开靠文件锁互斥。
* 禁止:接到企微回调 / 智能体出单热路径;禁止本类起第二台 soffice 并行。
* 副作用:写临时目录与预览缓存;短阻塞当前 HTTP 线程(仅管理端预览)。
*/
@Slf4j
@Component
public class QuoteTemplateSnapshotRenderer {
@Value("${inquiry.quote-template.soffice-path:${LIBREOFFICE_PATH:C:/Program Files/LibreOffice/program/soffice.exe}}")
private String sofficePath;
@Value("${inquiry.quote-template.soffice-timeout-seconds:90}")
private int timeoutSeconds;
@Value("${jeecg.path.upload:}")
private String uploadPath;
/**
* 把上传原表转成预览字节(优先 PNG)。
*
* @param xlsxPath 已落地的本地 xlsx
* @param versionId 用于临时目录命名
*/
public PreviewBytes render(Path xlsxPath, String versionId) {
if (xlsxPath == null || !Files.isRegularFile(xlsxPath)) {
throw new IllegalStateException("模板文件不存在,无法生成预览");
}
Path soffice = Paths.get(StringUtils.defaultIfBlank(sofficePath,
"C:/Program Files/LibreOffice/program/soffice.exe"));
if (!Files.isRegularFile(soffice)) {
throw new IllegalStateException("本机未安装 LibreOffice,无法生成原表预览");
}
Path workRoot = Paths.get(StringUtils.defaultIfBlank(uploadPath, System.getProperty("java.io.tmpdir")),
"inquiry/quote-templates/_snapshot-work");
Path lockFile = workRoot.resolve("soffice-admin-preview.lock");
try {
Files.createDirectories(workRoot);
if (!Files.exists(lockFile)) {
Files.createFile(lockFile);
}
} catch (IOException e) {
throw new IllegalStateException("无法创建预览工作目录: " + e.getMessage(), e);
}
try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.WRITE);
FileLock ignored = channel.lock()) {
Path workDir = workRoot.resolve("v-" + sanitize(versionId) + "-" + System.currentTimeMillis());
Files.createDirectories(workDir);
try {
Path localXlsx = workDir.resolve("source.xlsx");
Files.copy(xlsxPath, localXlsx, StandardCopyOption.REPLACE_EXISTING);
Path pdf = convert(soffice, localXlsx, workDir, "pdf:calc_pdf_Export", ".pdf");
if (pdf == null || !Files.isRegularFile(pdf)) {
throw new IllegalStateException("LibreOffice 未生成 PDF");
}
Path png = convert(soffice, pdf, workDir, "png", ".png");
if (png != null && Files.isRegularFile(png) && Files.size(png) > 0) {
return new PreviewBytes(Files.readAllBytes(png), "image/png");
}
log.warn("PNG 未生成,退回 PDF 预览 versionId={}", versionId);
return new PreviewBytes(Files.readAllBytes(pdf), "application/pdf");
} finally {
deleteQuietly(workDir);
}
} catch (IllegalStateException e) {
throw e;
} catch (Exception e) {
log.warn("原表截图失败 versionId={} err={}", versionId, e.getMessage());
throw new IllegalStateException("原表预览生成失败:" + e.getMessage(), e);
}
}
/**
* 调用 soffice 无界面转换;已在文件锁内,禁止再开并行实例。
*/
private Path convert(Path soffice, Path source, Path outDir, String filter, String suffix)
throws IOException, InterruptedException {
List<String> cmd = new ArrayList<>();
cmd.add(soffice.toAbsolutePath().toString());
cmd.add("--headless");
cmd.add("--nologo");
cmd.add("--nolockcheck");
cmd.add("--convert-to");
cmd.add(filter);
cmd.add("--outdir");
cmd.add(outDir.toAbsolutePath().toString());
cmd.add(source.toAbsolutePath().toString());
ProcessBuilder pb = new ProcessBuilder(cmd);
pb.redirectErrorStream(true);
Process p = pb.start();
boolean finished = p.waitFor(Math.max(30, timeoutSeconds), TimeUnit.SECONDS);
String out = new String(readAll(p.getInputStream()), StandardCharsets.UTF_8);
if (!finished) {
p.destroyForcibly();
throw new IllegalStateException("LibreOffice 转换超时");
}
if (p.exitValue() != 0) {
log.warn("soffice rc={} filter={} out={}", p.exitValue(), filter,
out.length() > 400 ? out.substring(0, 400) : out);
}
String stem = stripExt(source.getFileName().toString());
try (Stream<Path> stream = Files.list(outDir)) {
List<Path> hits = stream
.filter(f -> Files.isRegularFile(f))
.filter(f -> {
String n = f.getFileName().toString().toLowerCase();
return n.startsWith(stem.toLowerCase()) && n.endsWith(suffix.toLowerCase());
})
.sorted(Comparator.comparingLong((Path f) -> f.toFile().lastModified()).reversed())
.collect(Collectors.toList());
return hits.isEmpty() ? null : hits.get(0);
}
}
private static byte[] readAll(InputStream in) throws IOException {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
byte[] chunk = new byte[4096];
int n;
while ((n = in.read(chunk)) >= 0) {
buf.write(chunk, 0, n);
}
return buf.toByteArray();
}
private static String stripExt(String name) {
int i = name.lastIndexOf('.');
return i > 0 ? name.substring(0, i) : name;
}
private static String sanitize(String id) {
if (StringUtils.isBlank(id)) {
return "unknown";
}
return id.replaceAll("[^a-zA-Z0-9_-]", "_");
}
private static void deleteQuietly(Path dir) {
if (dir == null || !Files.exists(dir)) {
return;
}
try (Stream<Path> walk = Files.walk(dir)) {
walk.sorted(Comparator.reverseOrder()).forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (IOException ignored) {
// 清理失败不影响预览结果
}
});
} catch (IOException ignored) {
// ignore
}
}
/** 预览字节 + Content-Type。 */
public static final class PreviewBytes {
private final byte[] bytes;
private final String contentType;
public PreviewBytes(byte[] bytes, String contentType) {
this.bytes = bytes;
this.contentType = contentType;
}
public byte[] getBytes() {
return bytes;
}
public String getContentType() {
return contentType;
}
public boolean isImage() {
return contentType != null && contentType.toLowerCase().startsWith("image/");
}
}
}
@@ -17,11 +17,16 @@
},
"mappingJsonShape": {
"schemaVersion": "quote-template-mapping-v1",
"transportMode": "sea|air|land",
"sheet": "主工作表名(可空,默认第一个)",
"layoutType": "sea_fee_list|air_fee_list|fee_list_vertical|route_matrix|mixed|other",
"fields": {
"customer_name": "SheetName!C3",
"quote_no": "SheetName!F4"
},
"fieldMeta": {
"quote_no": { "sameCellAfterColon": true }
},
"fee_rows": {
"anchor": "SheetName!A15",
"columns": {
@@ -31,9 +36,45 @@
},
"styleCopyRows": 1
},
"feeRegions": [
{
"id": "origin_charges",
"title": "始发港费用",
"tmsSource": "polCostItems",
"headerRow": 18,
"anchor": "Sheet!A19",
"stopAtText": ["始发港费用汇总", "海运费用"],
"columns": { "name": "A", "currency": "B", "amount": "H" },
"styleCopyRows": 1,
"grow": "insert_before_stop"
}
],
"routeRegions": [
{
"id": "route_block_1",
"title": "项目一",
"tmsSource": "landSegments",
"headerRow": 8,
"dataStartRow": 10,
"stopAtText": ["项目二", "交易条件说明"],
"columns": {
"origin": "C",
"destination": "D",
"vehicleRates": { "3T": "E" },
"currency": "J"
},
"grow": "insert_before_stop",
"styleCopyRows": 1
}
],
"fixedYellowCells": ["SheetName!A1", "SheetName!B2"],
"fixedCells": ["SheetName!A1"],
"candidates": [],
"unmappedRequired": []
"unmappedRequired": [],
"unmappedLabels": [],
"mappingGaps": [],
"confidence": 0.85,
"notes": []
},
"systemFields": [
{ "code": "customer_name", "label": "客户名称", "requiredHint": true },
@@ -57,7 +98,25 @@
{ "code": "pieces", "label": "件数", "requiredHint": false },
{ "code": "weight_kg", "label": "重量", "requiredHint": false },
{ "code": "volume_cbm", "label": "体积", "requiredHint": false },
{ "code": "chargeable_weight_kg", "label": "计费重", "requiredHint": false },
{ "code": "delivery_address", "label": "派送地址", "requiredHint": false },
{ "code": "pickup_address", "label": "提货地址", "requiredHint": false },
{ "code": "transit_time", "label": "时效", "requiredHint": false },
{ "code": "cargo_value", "label": "货值", "requiredHint": false },
{ "code": "hs_code", "label": "HS编码", "requiredHint": false },
{ "code": "oil_elec_mag", "label": "是否含油电磁", "requiredHint": false },
{ "code": "has_battery", "label": "是否含电", "requiredHint": false },
{ "code": "on_board_date", "label": "开船日", "requiredHint": false },
{ "code": "special_remarks", "label": "特别说明", "requiredHint": false },
{ "code": "eta_request", "label": "要求抵达时间", "requiredHint": false },
{ "code": "sailing_schedule", "label": "船期航班", "requiredHint": false },
{ "code": "shipping_line", "label": "船公司航司", "requiredHint": false },
{ "code": "flight_no", "label": "航班号", "requiredHint": false },
{ "code": "transfer_airport", "label": "中转港", "requiredHint": false },
{ "code": "total_amount", "label": "报价合计", "requiredHint": false },
{ "code": "effective_date", "label": "生效时间", "requiredHint": false },
{ "code": "fuel_surcharge_rate", "label": "燃油附加费率", "requiredHint": false },
{ "code": "tax_rate", "label": "税率", "requiredHint": false },
{ "code": "fee_rows", "label": "费用明细行", "requiredHint": false }
],
"labelDictionary": {
@@ -0,0 +1,125 @@
package org.jeecg.modules.inquiry.template;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import static org.junit.jupiter.api.Assertions.*;
/**
* 单元格清单抽取与 feeRegions/routeRegions 填表冒烟。
* <p>
* 桌面样表存在时跑完整清单;否则跳过。不调 LLM。
*/
public class QuoteTemplateAiInventoryTest {
private static final Path DESKTOP_DIR = Paths.get(
"C:/Users/Administrator/Desktop/海陆空报价单模板最新-0917/海陆空报价单模板最新-0917");
@Test
public void buildCellInventory_fromSeaTemplate() throws Exception {
Path sea = findDesktop("海运报价单模板.xlsx");
Assumptions.assumeTrue(sea != null && Files.isRegularFile(sea), "桌面海运样表不存在则跳过");
JSONObject inv = QuoteTemplateExcelSupport.buildCellInventory(sea);
assertTrue(inv.getIntValue("cellCount") > 10, "应抽出多行单元格");
assertNotNull(inv.getString("sheet"));
JSONArray lines = inv.getJSONArray("lines");
assertNotNull(lines);
String first = lines.getString(0);
assertTrue(first.contains("|"), "清单行须含分隔符");
assertTrue(inv.getIntValue("yellowCount") >= 0);
}
@Test
public void fillWorkbook_feeRegions_doesNotThrow() throws Exception {
Path sea = findDesktop("海运报价单模板.xlsx");
Assumptions.assumeTrue(sea != null && Files.isRegularFile(sea), "桌面海运样表不存在则跳过");
JSONObject mapping = new JSONObject(true);
mapping.put("schemaVersion", "quote-template-mapping-v1");
mapping.put("sheet", "Sheet1");
mapping.put("fields", new JSONObject(true));
mapping.put("fixedCells", new JSONArray());
JSONArray regions = new JSONArray();
JSONObject region = new JSONObject(true);
region.put("id", "ocean_freight");
region.put("tmsSource", "oceanCostItems");
region.put("anchor", "Sheet1!A20");
region.put("stopAtText", JSONArray.parseArray("[\"费用总计\",\"预估费用\"]"));
region.put("grow", "insert_before_stop");
region.put("styleCopyRows", 1);
JSONObject cols = new JSONObject(true);
cols.put("name", "A");
cols.put("amount", "H");
region.put("columns", cols);
regions.add(region);
mapping.put("feeRegions", regions);
JSONObject facts = new JSONObject(true);
facts.put("quote_date", "2026-09-18");
JSONArray feeRows = new JSONArray();
JSONObject row = new JSONObject(true);
row.put("name", "海运费");
row.put("amount", "100");
feeRows.add(row);
facts.put("fee_rows", feeRows);
Path out = Files.createTempFile("quote-fee-region-", ".xlsx");
try {
QuoteTemplateExcelSupport.fillWorkbook(sea, out, mapping, facts);
assertTrue(Files.size(out) > 1000);
} finally {
Files.deleteIfExists(out);
}
}
@Test
public void bizTypeToTransportMode() {
assertEquals("sea", InquiryQuoteTemplateServiceImplProxy.sea());
assertEquals("air", InquiryQuoteTemplateServiceImplProxy.air());
assertEquals("land", InquiryQuoteTemplateServiceImplProxy.land());
}
/** 避免引入 Spring,直接测静态映射方法。 */
static final class InquiryQuoteTemplateServiceImplProxy {
static String sea() {
return org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.bizTypeToTransportMode("海运");
}
static String air() {
return org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.bizTypeToTransportMode("空运");
}
static String land() {
return org.jeecg.modules.inquiry.service.impl.InquiryQuoteTemplateServiceImpl
.bizTypeToTransportMode("陆运跨境");
}
}
private static Path findDesktop(String name) {
if (!Files.isDirectory(DESKTOP_DIR)) {
return null;
}
Path direct = DESKTOP_DIR.resolve(name);
if (Files.isRegularFile(direct)) {
return direct;
}
try {
return Files.list(DESKTOP_DIR)
.filter(p -> p.getFileName().toString().equals(name)
|| p.getFileName().toString().startsWith(name.replace(".xlsx", "")))
.findFirst()
.orElse(null);
} catch (Exception e) {
return null;
}
}
}
@@ -0,0 +1,45 @@
package org.jeecg.modules.inquiry.template;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* MinIO 返回的 http URL 不能当本地路径;拆 bucket/objectName 不连存储。
*/
public class QuoteTemplateObjectStoreTest {
@Test
public void detectRemoteUrl() {
assertTrue(QuoteTemplateObjectStore.isRemoteUrl(
"http://127.0.0.1:9000/inquiry-robot/inquiry/quote-templates/海运报价单校版_1.xlsx"));
assertTrue(QuoteTemplateObjectStore.isRemoteUrl("https://oss.example.com/a.xlsx"));
assertFalse(QuoteTemplateObjectStore.isRemoteUrl("inquiry/quote-templates/a.xlsx"));
assertFalse(QuoteTemplateObjectStore.isRemoteUrl("D:\\\\upload\\\\a.xlsx"));
}
@Test
public void splitMinioUrlWithChineseName() {
String url = "http://127.0.0.1:9000/inquiry-robot/inquiry/quote-templates/海运报价单校版_1789608110270.xlsx";
String[] parts = QuoteTemplateObjectStore.splitStorageUrl(url, "http://127.0.0.1:9000/");
assertArrayEquals(new String[]{
"inquiry-robot",
"inquiry/quote-templates/海运报价单校版_1789608110270.xlsx"
}, parts);
}
@Test
public void splitFallsBackToUriPathWhenBaseMismatches() {
String url = "http://127.0.0.1:9000/inquiry-robot/inquiry/quote-templates/a.xlsx";
String[] parts = QuoteTemplateObjectStore.splitStorageUrl(url, "http://minio:9000/");
assertArrayEquals(new String[]{"inquiry-robot", "inquiry/quote-templates/a.xlsx"}, parts);
}
@Test
public void splitRejectsBlank() {
assertNull(QuoteTemplateObjectStore.splitStorageUrl(" ", "http://127.0.0.1:9000/"));
}
}
@@ -156,6 +156,16 @@ justauth:
inquiry:
tms:
note: YTD_TMS_V2_ENABLED / TMS_V2_BASE_URL / TMS_V2_CLIENT_* 从进程环境注入;LOCK 默认关。测服联调空运锁舱再设 TMS_V2_LOCK_ENABLED=true,现网验收期保持关。
quote-template:
# 管理端原表截图:主账本机 soffice,不经智能体
soffice-path: ${LIBREOFFICE_PATH:C:/Program Files/LibreOffice/program/soffice.exe}
soffice-timeout-seconds: ${QUOTE_TEMPLATE_SOFFICE_TIMEOUT:90}
agent:
# 主账提交模板 AI 解析任务的智能体 HTTP(只入队)
base-url: ${AGENT_HTTP_BASE_URL:http://127.0.0.1:8910}
callback-token: ${AGENT_CALLBACK_TOKEN:}
biz-token: ${BIZ_API_TOKEN:}
parse-timeout-ms: ${QUOTE_TEMPLATE_PARSE_ENQUEUE_TIMEOUT_MS:8000}
logging:
level: