落地报价模板 AI 解析与对象存储,并增加回复超时系统设置页。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+29
@@ -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,
|
||||
|
||||
-2
@@ -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));
|
||||
|
||||
+60
-5
@@ -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")
|
||||
|
||||
+110
@@ -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";
|
||||
}
|
||||
}
|
||||
+65
@@ -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;
|
||||
}
|
||||
+12
@@ -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> {
|
||||
}
|
||||
+51
-1
@@ -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 的模板指针列表。
|
||||
*/
|
||||
|
||||
+31
@@ -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();
|
||||
}
|
||||
+683
-57
@@ -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 "已解析";
|
||||
|
||||
+97
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+120
@@ -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) + "…";
|
||||
}
|
||||
}
|
||||
+646
-22
@@ -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;
|
||||
|
||||
+238
@@ -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 <:>。本类先认远程 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";
|
||||
}
|
||||
}
|
||||
+201
@@ -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/");
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
-1
@@ -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": {
|
||||
|
||||
+125
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -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/"));
|
||||
}
|
||||
}
|
||||
+10
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user