落地报价模板主账与后台:合同映射、灌种 SQL/脚本、智能体填表,并补测服运维探针与合同文档对齐。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-14 14:09:45 +08:00
co-authored by Cursor
parent 8bdf4990a2
commit a70bce3c8f
138 changed files with 7239 additions and 1085 deletions
@@ -0,0 +1,240 @@
package org.jeecg.modules.inquiry.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
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.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.aspect.annotation.AutoLog;
import org.jeecg.common.system.vo.LoginUser;
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate;
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion;
import org.jeecg.modules.inquiry.service.IInquiryQuoteTemplateService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.net.URLEncoder;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 报价模板管理 API。
* <p>
* 路径 /inquiry/quoteTemplate;权限 inquiry:template:*。
* 智能体只读列表走 /agent/listEnabled(服务账号 token)。
* 禁止:在本 Controller 调 LibreOffice / LLM。
*/
@Slf4j
@Api(tags = "询价-报价模板")
@RestController
@RequestMapping("/inquiry/quoteTemplate")
public class InquiryQuoteTemplateController {
@Autowired
private IInquiryQuoteTemplateService quoteTemplateService;
@AutoLog(value = "报价模板-分页")
@ApiOperation("分页列表(含当前版本摘要)")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/list")
public Result<IPage<Map<String, Object>>> list(@RequestParam(required = false) String keyword,
@RequestParam(required = false) String bizType,
@RequestParam(required = false) String category,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "1") Integer pageNo,
@RequestParam(defaultValue = "20") Integer pageSize) {
return Result.OK(quoteTemplateService.pageWithVersion(keyword, bizType, category, status, pageNo, pageSize));
}
@AutoLog(value = "报价模板-上传版本")
@ApiOperation("上传新模板或追加新版本(解析黄区并自动映射)")
@RequiresPermissions("inquiry:template:upload")
@PostMapping("/upload")
public Result<Map<String, Object>> upload(@RequestParam(required = false) String templateId,
@RequestParam(required = false) String name,
@RequestParam(required = false) String bizType,
@RequestParam(required = false) String category,
@RequestParam(required = false) String keywordsJson,
@RequestParam(required = false, defaultValue = "auto") String mode,
@RequestParam(required = false, defaultValue = "false") Boolean isDefault,
@RequestParam("file") MultipartFile file) {
Map<String, Object> row = quoteTemplateService.uploadVersion(
templateId, name, bizType, category, keywordsJson, mode,
Boolean.TRUE.equals(isDefault), file, currentUser());
return Result.OK(row);
}
@ApiOperation("版本详情(含 mappingJson)")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/version")
public Result<InquiryQuoteTemplateVersion> version(@RequestParam String versionId) {
return Result.OK(quoteTemplateService.getVersion(versionId));
}
@ApiOperation("某模板全部版本")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/versions")
public Result<List<InquiryQuoteTemplateVersion>> versions(@RequestParam String templateId) {
return Result.OK(quoteTemplateService.listVersions(templateId));
}
@AutoLog(value = "报价模板-保存映射")
@ApiOperation("确认/修订本版 mappingJson")
@RequiresPermissions("inquiry:template:edit")
@PutMapping("/mapping")
public Result<String> saveMapping(@RequestBody Map<String, String> body) {
String versionId = body.get("versionId");
String mappingJson = body.get("mappingJson");
if (StringUtils.isBlank(versionId) || StringUtils.isBlank(mappingJson)) {
return Result.error("versionId 与 mappingJson 必填");
}
quoteTemplateService.saveMapping(versionId, mappingJson, currentUser());
return Result.OK("映射已保存");
}
@AutoLog(value = "报价模板-启用")
@ApiOperation("启用指定版本(auto 模式须映射完整)")
@RequiresPermissions("inquiry:template:toggle")
@PostMapping("/enable")
public Result<String> enable(@RequestParam String templateId, @RequestParam String versionId) {
quoteTemplateService.enableTemplate(templateId, versionId, currentUser());
return Result.OK("已启用");
}
@AutoLog(value = "报价模板-停用")
@RequiresPermissions("inquiry:template:toggle")
@PostMapping("/disable")
public Result<String> disable(@RequestParam String templateId) {
quoteTemplateService.disableTemplate(templateId, currentUser());
return Result.OK("已停用");
}
@AutoLog(value = "报价模板-设默认")
@RequiresPermissions("inquiry:template:edit")
@PostMapping("/setDefault")
public Result<String> setDefault(@RequestParam String templateId,
@RequestParam(defaultValue = "true") Boolean isDefault) {
quoteTemplateService.setDefault(templateId, Boolean.TRUE.equals(isDefault), currentUser());
return Result.OK("已更新默认模板");
}
@ApiOperation("逻辑头详情")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/queryById")
public Result<InquiryQuoteTemplate> queryById(@RequestParam String id) {
return Result.OK(quoteTemplateService.getById(id));
}
@ApiOperation("编辑逻辑头(名称/分类/关键词/模式,不换文件)")
@RequiresPermissions("inquiry:template:edit")
@PutMapping("/editMeta")
public Result<String> editMeta(@RequestBody InquiryQuoteTemplate body) {
if (StringUtils.isBlank(body.getId())) {
return Result.error("id 不能为空");
}
InquiryQuoteTemplate db = quoteTemplateService.getById(body.getId());
if (db == null) {
return Result.error("模板不存在");
}
if (StringUtils.isNotBlank(body.getName())) {
db.setName(body.getName());
}
if (StringUtils.isNotBlank(body.getBizType())) {
db.setBizType(body.getBizType());
}
if (StringUtils.isNotBlank(body.getCategory())) {
db.setCategory(body.getCategory());
}
if (StringUtils.isNotBlank(body.getKeywordsJson())) {
db.setKeywordsJson(body.getKeywordsJson());
}
if (StringUtils.isNotBlank(body.getMode())) {
db.setMode(body.getMode());
}
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());
}
return Result.OK("已保存");
}
@ApiOperation("样例回填预览文件下载(xlsx)")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/previewSample")
public ResponseEntity<Resource> previewSample(@RequestParam String versionId) throws Exception {
Path path = quoteTemplateService.buildSamplePreview(versionId);
InquiryQuoteTemplateVersion ver = quoteTemplateService.getVersion(versionId);
String downloadName = (ver != null ? ver.getFileName() : "preview.xlsx");
if (!downloadName.toLowerCase().endsWith(".xlsx")) {
downloadName = downloadName + "-preview.xlsx";
} else {
downloadName = downloadName.replace(".xlsx", "-preview.xlsx");
}
String encoded = URLEncoder.encode(downloadName, "UTF-8").replace("+", "%20");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
.body(new FileSystemResource(path));
}
@ApiOperation("下载原模板文件")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/download")
public ResponseEntity<Resource> download(@RequestParam String versionId) throws Exception {
InquiryQuoteTemplateVersion ver = quoteTemplateService.getVersion(versionId);
if (ver == null) {
return ResponseEntity.notFound().build();
}
Path path = quoteTemplateService.resolveObjectPath(ver.getObjectKey());
String encoded = URLEncoder.encode(ver.getFileName(), "UTF-8").replace("+", "%20");
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename*=UTF-8''" + encoded)
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(new FileSystemResource(path));
}
/**
* 智能体只读:已启用 auto 模板指针(含 mappingJson / objectKey)。
* 权限:登录服务账号即可;与后台权限码解耦,避免 Worker 缺菜单权限。
*/
@ApiOperation("智能体-已启用模板列表")
@GetMapping("/agent/listEnabled")
public Result<List<Map<String, Object>>> agentListEnabled(
@RequestParam(defaultValue = "true") Boolean enabledOnly) {
return Result.OK(quoteTemplateService.listEnabledForAgent(Boolean.TRUE.equals(enabledOnly)));
}
@ApiOperation("合同字段与样例事实(前端映射编辑用)")
@RequiresPermissions("inquiry:template:view")
@GetMapping("/contractMeta")
public Result<Map<String, Object>> contractMeta() {
Map<String, Object> m = new HashMap<>();
m.put("hint", "见仓库 contracts/quote-template-mapping-v1.json");
m.put("schemaVersion", "quote-template-mapping-v1");
return Result.OK(m);
}
private String currentUser() {
try {
Object p = SecurityUtils.getSubject().getPrincipal();
if (p instanceof LoginUser) {
return ((LoginUser) p).getUsername();
}
} catch (Exception ignored) {
}
return "system";
}
}
@@ -0,0 +1,73 @@
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_quote_template。
* <p>
* 职责:业务身份(海陆空/分类/关键词/默认/启停/auto|manual)。
* 版式字节与映射在版本表;历史报价应绑 templateId + versionNo。
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("inq_quote_template")
@ApiModel(value = "inq_quote_template", description = "报价模板逻辑头")
public class InquiryQuoteTemplate implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
private String id;
@ApiModelProperty("模板名称")
private String name;
@ApiModelProperty("海运/空运/陆运")
private String bizType;
@ApiModelProperty("分类")
private String category;
@ApiModelProperty("关键词 JSON 数组字符串")
private String keywordsJson;
@ApiModelProperty("auto / manual")
private String mode;
@ApiModelProperty("enabled / disabled")
private String status;
@ApiModelProperty("同业务默认 0/1")
private Integer isDefault;
@ApiModelProperty("当前启用版本号")
private Integer currentVersion;
@ApiModelProperty("累计使用次数")
private Integer useCount;
private String createBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date createTime;
private String updateBy;
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss")
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date updateTime;
}
@@ -0,0 +1,85 @@
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_quote_template_version。
* <p>
* 每次改版上传新建一行;mapping_json 为本版填表计划。
* 黄色固定区不写入;启用前须完成必填映射(manual 模式可放宽)。
*/
@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
@TableName("inq_quote_template_version")
@ApiModel(value = "inq_quote_template_version", description = "报价模板版本")
public class InquiryQuoteTemplateVersion implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(type = IdType.ASSIGN_ID)
private String id;
@ApiModelProperty("逻辑模板 id")
private String templateId;
@ApiModelProperty("版本号从 1 起")
private Integer versionNo;
@ApiModelProperty("原始文件名")
private String fileName;
@ApiModelProperty("excel / pdf")
private String format;
@ApiModelProperty("对象存储键或本地上传相对路径")
private String objectKey;
@ApiModelProperty("SHA-256")
private String sha256;
@ApiModelProperty("字节数")
private Long fileSize;
@ApiModelProperty("pending/parsed/failed/mapping_incomplete")
private String parseStatus;
@ApiModelProperty("已映射字段点数")
private Integer mappingPointCount;
@ApiModelProperty("本版 mapping JSON")
private String mappingJson;
@ApiModelProperty("可填候选 JSON")
private String candidatesJson;
@ApiModelProperty("解析说明")
private String parseMessage;
@ApiModelProperty("是否当前启用版本 0/1")
private Integer enabled;
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;
}
@@ -67,6 +67,15 @@ public class InquiryTicket implements Serializable {
@ApiModelProperty("品名")
private String goodsName;
/**
* 报价日期:销售询价/报价单填表用的业务日期,不是 createTime。
* 对应模板字段 quote_date;可空,旧单补列后保持 NULL。
*/
@ApiModelProperty("报价日期")
@JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd")
@DateTimeFormat(pattern = "yyyy-MM-dd")
private Date quoteDate;
@ApiModelProperty("件数")
private Integer pieces;
@@ -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.InquiryQuoteTemplate;
/**
* 报价模板逻辑头 Mapper。
*/
@Mapper
public interface InquiryQuoteTemplateMapper extends BaseMapper<InquiryQuoteTemplate> {
}
@@ -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.InquiryQuoteTemplateVersion;
/**
* 报价模板版本 Mapper。
*/
@Mapper
public interface InquiryQuoteTemplateVersionMapper extends BaseMapper<InquiryQuoteTemplateVersion> {
}
@@ -1,8 +1,8 @@
/**
* 友通达新系统 · 询价主账手写模块(从零实现)。
* <p>
* 本期能力:分析看板、工单六态、员工企微岗位、机器人回复语。
* 排除:报价模板、识别任务、报价生成、存档死信。
* 本期能力:分析看板、工单六态、员工企微岗位、机器人回复语、报价模板(逻辑头+版本+映射)。
* 排除:识别任务、存档死信、Graph/自然语言抽取。
* 数据只写 inquiry_robot;禁止连现网 jeecg-boot。
*/
package org.jeecg.modules.inquiry;
@@ -0,0 +1,57 @@
package org.jeecg.modules.inquiry.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate;
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplateVersion;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
/**
* 报价模板:上传新版本、映射确认、启停、样例预览、智能体只读列表。
*/
public interface IInquiryQuoteTemplateService extends IService<InquiryQuoteTemplate> {
IPage<Map<String, Object>> pageWithVersion(String keyword, String bizType, String category, String status,
int pageNo, int pageSize);
/**
* 新建逻辑模板并写入第一版;或对已有模板追加新版本。
*/
Map<String, Object> uploadVersion(String templateId, String name, String bizType, String category,
String keywordsJson, String mode, boolean isDefault,
MultipartFile file, String operator);
InquiryQuoteTemplateVersion getVersion(String versionId);
List<InquiryQuoteTemplateVersion> listVersions(String templateId);
/**
* 运营确认/修订 mapping_json。
*/
void saveMapping(String versionId, String mappingJson, String operator);
void enableTemplate(String templateId, String versionId, String operator);
void disableTemplate(String templateId, String operator);
void setDefault(String templateId, boolean isDefault, String operator);
/**
* 按本版映射写入样例事实,返回生成的预览文件绝对路径。
*/
Path buildSamplePreview(String versionId) throws Exception;
/**
* 解析本地上传路径为绝对 Path。
*/
Path resolveObjectPath(String objectKey);
/**
* 智能体:已启用且 mode=auto 的模板指针列表。
*/
List<Map<String, Object>> listEnabledForAgent(boolean enabledOnly);
}
@@ -0,0 +1,557 @@
package org.jeecg.modules.inquiry.service.impl;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.common.util.CommonUtils;
import org.jeecg.modules.inquiry.entity.InquiryQuoteTemplate;
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.QuoteTemplateExcelSupport;
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.web.multipart.MultipartFile;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
/**
* 报价模板服务实现。
* <p>
* 副作用:写 inq_quote_template / version;文件经 Jeecg upload(local/minio)。
* 线程:HTTP 请求线程;解析/回填可能稍慢但不上 LibreOffice,禁止在此 sleep 等 LLM。
*/
@Slf4j
@Service
public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTemplateMapper, InquiryQuoteTemplate>
implements IInquiryQuoteTemplateService {
private static final String BIZ_PATH = "inquiry/quote-templates";
@Autowired
private InquiryQuoteTemplateVersionMapper versionMapper;
@Value("${jeecg.path.upload:}")
private String uploadPath;
@Value("${jeecg.uploadType:local}")
private String uploadType;
/** 可选:仓库 contracts 绝对/相对路径,便于开发机读正式合同。 */
@Value("${inquiry.quoteTemplate.contractPath:}")
private String contractPath;
@Override
public IPage<Map<String, Object>> pageWithVersion(String keyword, String bizType, String category, String status,
int pageNo, int pageSize) {
QueryWrapper<InquiryQuoteTemplate> qw = new QueryWrapper<>();
if (StringUtils.isNotBlank(bizType)) {
qw.eq("biz_type", bizType);
}
if (StringUtils.isNotBlank(category)) {
qw.eq("category", category);
}
if (StringUtils.isNotBlank(status)) {
qw.eq("status", status);
}
if (StringUtils.isNotBlank(keyword)) {
qw.and(w -> w.like("name", keyword).or().like("category", keyword).or().like("keywords_json", keyword));
}
qw.orderByDesc("update_time").orderByDesc("create_time");
IPage<InquiryQuoteTemplate> page = page(new Page<>(pageNo, pageSize), qw);
List<Map<String, Object>> records = new ArrayList<>();
for (InquiryQuoteTemplate t : page.getRecords()) {
records.add(toListRow(t));
}
Page<Map<String, Object>> out = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
out.setRecords(records);
return out;
}
private Map<String, Object> toListRow(InquiryQuoteTemplate t) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", t.getId());
m.put("name", t.getName());
m.put("bizType", t.getBizType());
m.put("category", t.getCategory());
m.put("keywords", parseKeywords(t.getKeywordsJson()));
m.put("mode", t.getMode());
m.put("status", t.getStatus());
m.put("isDefault", t.getIsDefault() != null && t.getIsDefault() == 1);
m.put("currentVersion", t.getCurrentVersion());
m.put("useCount", t.getUseCount());
m.put("createdAt", t.getCreateTime());
m.put("operator", t.getUpdateBy() != null ? t.getUpdateBy() : t.getCreateBy());
InquiryQuoteTemplateVersion ver = findEnabledOrLatest(t.getId());
if (ver != null) {
m.put("versionId", ver.getId());
m.put("fileName", ver.getFileName());
m.put("fileLabel", stripExt(ver.getFileName()));
m.put("format", ver.getFormat());
m.put("parseStatus", mapParseStatusLabel(ver.getParseStatus()));
m.put("mappingPointCount", ver.getMappingPointCount());
m.put("objectKey", ver.getObjectKey());
m.put("parseMessage", ver.getParseMessage());
m.put("unmappedRequired", extractUnmapped(ver.getMappingJson()));
} else {
m.put("versionId", null);
m.put("fileName", "");
m.put("fileLabel", "");
m.put("format", "excel");
m.put("parseStatus", "未上传");
m.put("mappingPointCount", 0);
}
return m;
}
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> uploadVersion(String templateId, String name, String bizType, String category,
String keywordsJson, String mode, boolean isDefault,
MultipartFile file, String operator) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("请上传模板文件");
}
String original = file.getOriginalFilename() == null ? "template.xlsx" : file.getOriginalFilename();
boolean isPdf = original.toLowerCase(Locale.ROOT).endsWith(".pdf");
String format = isPdf ? "pdf" : "excel";
InquiryQuoteTemplate template;
boolean creating = StringUtils.isBlank(templateId);
if (creating) {
template = new InquiryQuoteTemplate();
template.setName(StringUtils.defaultIfBlank(name, stripExt(original)));
template.setBizType(StringUtils.defaultIfBlank(bizType, "海运"));
template.setCategory(StringUtils.defaultIfBlank(category, defaultCategory(template.getBizType())));
template.setKeywordsJson(StringUtils.defaultIfBlank(keywordsJson, "[]"));
template.setMode(StringUtils.defaultIfBlank(mode, "auto"));
template.setStatus("disabled");
template.setIsDefault(0);
template.setCurrentVersion(0);
template.setUseCount(0);
template.setCreateBy(operator);
template.setCreateTime(new Date());
save(template);
} else {
template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在: " + templateId);
}
if (StringUtils.isNotBlank(name)) {
template.setName(name);
}
if (StringUtils.isNotBlank(bizType)) {
template.setBizType(bizType);
}
if (StringUtils.isNotBlank(category)) {
template.setCategory(category);
}
if (StringUtils.isNotBlank(keywordsJson)) {
template.setKeywordsJson(keywordsJson);
}
if (StringUtils.isNotBlank(mode)) {
template.setMode(mode);
}
template.setUpdateBy(operator);
template.setUpdateTime(new Date());
updateById(template);
}
String objectKey;
try {
objectKey = CommonUtils.upload(file, BIZ_PATH, uploadType);
} catch (Exception e) {
log.error("模板上传失败", e);
throw new IllegalStateException("文件上传失败: " + e.getMessage(), e);
}
if (StringUtils.isBlank(objectKey)) {
throw new IllegalStateException("文件上传返回空路径");
}
int nextVer = nextVersionNo(template.getId());
String sha;
try (InputStream in = file.getInputStream()) {
sha = DigestUtils.sha256Hex(in);
} catch (Exception e) {
sha = null;
}
InquiryQuoteTemplateVersion version = new InquiryQuoteTemplateVersion();
version.setTemplateId(template.getId());
version.setVersionNo(nextVer);
version.setFileName(original);
version.setFormat(format);
version.setObjectKey(objectKey);
version.setSha256(sha);
version.setFileSize(file.getSize());
version.setEnabled(0);
version.setCreateBy(operator);
version.setCreateTime(new Date());
if (isPdf) {
version.setParseStatus("parsed");
version.setMappingPointCount(0);
version.setMappingJson("{\"schemaVersion\":\"quote-template-mapping-v1\",\"fields\":{},\"note\":\"pdf_manual_or_download_only\"}");
version.setParseMessage("PDF 不做单元格映射,可用于 manual 下载");
} 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);
}
}
versionMapper.insert(version);
if (isDefault) {
setDefault(template.getId(), true, operator);
}
Map<String, Object> result = toListRow(getById(template.getId()));
result.put("versionId", version.getId());
result.put("parseStatus", mapParseStatusLabel(version.getParseStatus()));
result.put("mappingPointCount", version.getMappingPointCount());
result.put("parseMessage", version.getParseMessage());
result.put("mappingJson", version.getMappingJson());
return result;
}
@Override
public InquiryQuoteTemplateVersion getVersion(String versionId) {
return versionMapper.selectById(versionId);
}
@Override
public List<InquiryQuoteTemplateVersion> listVersions(String templateId) {
return versionMapper.selectList(new QueryWrapper<InquiryQuoteTemplateVersion>()
.eq("template_id", templateId)
.orderByDesc("version_no"));
}
@Override
@Transactional(rollbackFor = Exception.class)
public void saveMapping(String versionId, String mappingJson, String operator) {
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null) {
throw new IllegalArgumentException("版本不存在");
}
JSONObject mapping = JSON.parseObject(mappingJson);
if (mapping == null) {
throw new IllegalArgumentException("mappingJson 非法");
}
mapping.put("schemaVersion", "quote-template-mapping-v1");
JSONObject fields = mapping.getJSONObject("fields");
int points = fields == null ? 0 : fields.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())) {
ver.setParseStatus("mapping_incomplete");
ver.setParseMessage("映射已保存,尚有未映射必填:" + unmapped.toJSONString());
} else {
ver.setParseStatus("parsed");
ver.setParseMessage("映射已确认,共 " + points + " 个映射点");
}
ver.setUpdateBy(operator);
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void enableTemplate(String templateId, String versionId, String operator) {
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
}
InquiryQuoteTemplateVersion ver = versionMapper.selectById(versionId);
if (ver == null || !templateId.equals(ver.getTemplateId())) {
throw new IllegalArgumentException("版本不属于该模板");
}
if ("failed".equals(ver.getParseStatus())) {
throw new IllegalStateException("解析失败的版本不能启用");
}
if ("auto".equals(template.getMode()) && "mapping_incomplete".equals(ver.getParseStatus())) {
throw new IllegalStateException("自动模板须完成必填映射后才能启用");
}
// 同模板其它版本取消启用
List<InquiryQuoteTemplateVersion> all = listVersions(templateId);
for (InquiryQuoteTemplateVersion v : all) {
if (v.getEnabled() != null && v.getEnabled() == 1) {
v.setEnabled(0);
v.setUpdateTime(new Date());
versionMapper.updateById(v);
}
}
ver.setEnabled(1);
ver.setUpdateBy(operator);
ver.setUpdateTime(new Date());
versionMapper.updateById(ver);
template.setCurrentVersion(ver.getVersionNo());
template.setStatus("enabled");
template.setUpdateBy(operator);
template.setUpdateTime(new Date());
updateById(template);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void disableTemplate(String templateId, String operator) {
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
}
template.setStatus("disabled");
template.setUpdateBy(operator);
template.setUpdateTime(new Date());
updateById(template);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void setDefault(String templateId, boolean isDefault, String operator) {
InquiryQuoteTemplate template = getById(templateId);
if (template == null) {
throw new IllegalArgumentException("模板不存在");
}
if (isDefault) {
List<InquiryQuoteTemplate> sameBiz = list(new QueryWrapper<InquiryQuoteTemplate>()
.eq("biz_type", template.getBizType())
.eq("is_default", 1));
for (InquiryQuoteTemplate t : sameBiz) {
if (!t.getId().equals(templateId)) {
t.setIsDefault(0);
t.setUpdateTime(new Date());
updateById(t);
}
}
template.setIsDefault(1);
} else {
template.setIsDefault(0);
}
template.setUpdateBy(operator);
template.setUpdateTime(new Date());
updateById(template);
}
@Override
public Path buildSamplePreview(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 contract = QuoteTemplateExcelSupport.loadContract(resolveContractPath());
JSONObject mapping;
try {
mapping = StringUtils.isNotBlank(ver.getMappingJson())
? JSON.parseObject(ver.getMappingJson())
: new JSONObject();
} catch (Exception e) {
throw new IllegalStateException(
"mappingJson 非法(需合法 JSON)。请重新上传解析或执行 mapping 修复脚本: " + e.getMessage(), e);
}
JSONObject facts = contract.getJSONObject("samplePreviewFacts");
if (facts == null) {
facts = new JSONObject();
}
Path previewDir = Paths.get(uploadPath, BIZ_PATH, "preview");
Files.createDirectories(previewDir);
Path target = previewDir.resolve(ver.getId() + "-preview.xlsx");
QuoteTemplateExcelSupport.fillWorkbook(source, target, mapping, facts);
return target;
}
@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 + ")");
}
@Override
public List<Map<String, Object>> listEnabledForAgent(boolean enabledOnly) {
QueryWrapper<InquiryQuoteTemplate> qw = new QueryWrapper<>();
if (enabledOnly) {
qw.eq("status", "enabled");
}
qw.eq("mode", "auto");
qw.orderByDesc("is_default").orderByAsc("biz_type").orderByAsc("name");
List<Map<String, Object>> out = new ArrayList<>();
for (InquiryQuoteTemplate t : list(qw)) {
InquiryQuoteTemplateVersion ver = findEnabledOrLatest(t.getId());
if (ver == null || (enabledOnly && (ver.getEnabled() == null || ver.getEnabled() != 1))) {
continue;
}
Map<String, Object> m = new LinkedHashMap<>();
m.put("templateId", t.getId());
m.put("name", t.getName());
m.put("bizType", t.getBizType());
m.put("category", t.getCategory());
m.put("keywords", parseKeywords(t.getKeywordsJson()));
m.put("isDefault", t.getIsDefault() != null && t.getIsDefault() == 1);
m.put("versionNo", ver.getVersionNo());
m.put("versionId", ver.getId());
m.put("objectKey", ver.getObjectKey());
m.put("fileName", ver.getFileName());
m.put("sha256", ver.getSha256());
m.put("mappingJson", ver.getMappingJson());
out.add(m);
}
return out;
}
private InquiryQuoteTemplateVersion findEnabledOrLatest(String templateId) {
InquiryQuoteTemplateVersion enabled = versionMapper.selectOne(new QueryWrapper<InquiryQuoteTemplateVersion>()
.eq("template_id", templateId)
.eq("enabled", 1)
.last("LIMIT 1"));
if (enabled != null) {
return enabled;
}
return versionMapper.selectOne(new QueryWrapper<InquiryQuoteTemplateVersion>()
.eq("template_id", templateId)
.orderByDesc("version_no")
.last("LIMIT 1"));
}
private int nextVersionNo(String templateId) {
InquiryQuoteTemplateVersion latest = versionMapper.selectOne(new QueryWrapper<InquiryQuoteTemplateVersion>()
.eq("template_id", templateId)
.orderByDesc("version_no")
.last("LIMIT 1"));
return latest == null || latest.getVersionNo() == null ? 1 : latest.getVersionNo() + 1;
}
private Path resolveContractPath() {
if (StringUtils.isNotBlank(contractPath)) {
return Paths.get(contractPath);
}
// 开发机常见:进程 cwd 在 inquiry-api 或其子目录
Path[] candidates = new Path[]{
Paths.get("contracts", "quote-template-mapping-v1.json"),
Paths.get("..", "contracts", "quote-template-mapping-v1.json"),
Paths.get("..", "..", "contracts", "quote-template-mapping-v1.json")
};
for (Path c : candidates) {
if (Files.isRegularFile(c)) {
return c.toAbsolutePath().normalize();
}
}
return null;
}
private static List<String> parseKeywords(String json) {
if (StringUtils.isBlank(json)) {
return Collections.emptyList();
}
try {
return JSON.parseArray(json, String.class);
} catch (Exception e) {
return Collections.singletonList(json);
}
}
private static List<String> extractUnmapped(String mappingJson) {
if (StringUtils.isBlank(mappingJson)) {
return Collections.emptyList();
}
try {
JSONArray arr = JSON.parseObject(mappingJson).getJSONArray("unmappedRequired");
return arr == null ? Collections.emptyList() : arr.toJavaList(String.class);
} catch (Exception e) {
return Collections.emptyList();
}
}
private static String mapParseStatusLabel(String status) {
if ("parsed".equals(status)) {
return "已解析";
}
if ("mapping_incomplete".equals(status)) {
return "映射未完成";
}
if ("failed".equals(status)) {
return "失败";
}
if ("pending".equals(status)) {
return "解析中";
}
return status == null ? "" : status;
}
private static String stripExt(String name) {
if (name == null) {
return "";
}
int i = name.lastIndexOf('.');
return i > 0 ? name.substring(0, i) : name;
}
private static String defaultCategory(String bizType) {
if ("空运".equals(bizType)) {
return "空运到港报价单模板";
}
if ("陆运".equals(bizType)) {
return "国内拼车";
}
return "海运报价单模板";
}
}
@@ -0,0 +1,380 @@
package org.jeecg.modules.inquiry.template;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.ss.util.CellReference;
import org.apache.poi.xssf.usermodel.XSSFCell;
import org.apache.poi.xssf.usermodel.XSSFColor;
import org.springframework.core.io.ClassPathResource;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
/**
* 报价模板 Excel 解析与按映射回填。
* <p>
* 黄区=固定不覆盖;标签词典自动建议字段→单元格;样例/冻结事实写入映射点。
* 禁止:发明价格;不在此做 Graph / 六态。
* 线程:跟随调用方(上传请求或 Worker 委托的 HTTP 线程),无全局锁。
*/
@Slf4j
public final class QuoteTemplateExcelSupport {
private static final Set<String> YELLOW_RGB = new HashSet<>(Arrays.asList(
"FFFFFF00", "FFFF00", "FFFFEB9C", "FFFFC000", "FFFFFF99",
"FFFFCC00", "FFFFD966", "FFFFF2CC", "FFFFE699"
));
private QuoteTemplateExcelSupport() {
}
/**
* 加载 contracts 合同(优先 classpath,其次仓库相对路径由调用方传入)。
*/
public static JSONObject loadContract(Path optionalRepoContract) {
try {
if (optionalRepoContract != null && Files.isRegularFile(optionalRepoContract)) {
byte[] bytes = Files.readAllBytes(optionalRepoContract);
String text = new String(bytes, java.nio.charset.StandardCharsets.UTF_8);
return JSON.parseObject(text);
}
} catch (Exception e) {
log.warn("读取仓库合同失败,尝试 classpath: {}", e.getMessage());
}
try {
ClassPathResource res = new ClassPathResource("contracts/quote-template-mapping-v1.json");
if (res.exists()) {
try (InputStream in = res.getInputStream()) {
byte[] buf = new byte[4096];
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
int n;
while ((n = in.read(buf)) >= 0) {
bos.write(buf, 0, n);
}
return JSON.parseObject(new String(bos.toByteArray(), java.nio.charset.StandardCharsets.UTF_8));
}
}
} catch (Exception e) {
log.warn("classpath 合同不可用: {}", e.getMessage());
}
// 最小内置:避免上传直接失败
JSONObject fallback = new JSONObject();
fallback.put("labelDictionary", new JSONObject());
fallback.put("samplePreviewFacts", new JSONObject());
fallback.put("systemFields", new JSONArray());
return fallback;
}
/**
* 解析 xlsx:黄区列表、标签候选、自动映射建议。
*
* @return mapping 草案(含 fields / fixedYellowCells / candidates / unmappedRequired)
*/
public static JSONObject parseWorkbook(Path xlsxPath, JSONObject contract) throws Exception {
Map<String, String> dict = toStringMap(contract.getJSONObject("labelDictionary"));
JSONArray systemFields = contract.getJSONArray("systemFields");
Set<String> requiredHints = new HashSet<>();
if (systemFields != null) {
for (int i = 0; i < systemFields.size(); i++) {
JSONObject f = systemFields.getJSONObject(i);
if (f != null && Boolean.TRUE.equals(f.getBoolean("requiredHint"))) {
requiredHints.add(f.getString("code"));
}
}
}
JSONObject mapping = new JSONObject(true);
mapping.put("schemaVersion", "quote-template-mapping-v1");
JSONObject fields = new JSONObject(true);
JSONArray yellow = new JSONArray();
JSONArray candidates = new JSONArray();
Set<String> mappedCodes = new HashSet<>();
try (InputStream in = Files.newInputStream(xlsxPath); Workbook wb = WorkbookFactory.create(in)) {
Sheet sheet = wb.getNumberOfSheets() > 0 ? wb.getSheetAt(0) : null;
if (sheet == null) {
mapping.put("parseError", "empty_workbook");
return mapping;
}
String sheetName = sheet.getSheetName();
mapping.put("sheet", sheetName);
DataFormatter fmt = new DataFormatter();
for (Row row : sheet) {
if (row == null) {
continue;
}
for (Cell cell : row) {
if (cell == null) {
continue;
}
String ref = sheetName + "!" + new CellReference(cell).formatAsString();
boolean isYellow = isYellowFixed(cell);
if (isYellow) {
yellow.add(ref);
}
String text = StringUtils.trimToEmpty(fmt.formatCellValue(cell));
if (text.isEmpty()) {
continue;
}
String normalized = text.replace(":", ":").trim();
String code = matchLabel(normalized, dict);
String fillRef = null;
if (code != null) {
fillRef = resolveFillTarget(sheet, cell, sheetName, text, fmt);
if (fillRef != null && !mappedCodes.contains(code)) {
fields.put(code, fillRef);
mappedCodes.add(code);
}
}
JSONObject cand = new JSONObject(true);
cand.put("cell", ref);
cand.put("text", text.length() > 80 ? text.substring(0, 80) : text);
cand.put("yellowFixed", isYellow);
cand.put("suggestedField", code);
cand.put("suggestedFill", fillRef);
candidates.add(cand);
}
}
}
mapping.put("fields", fields);
mapping.put("fixedYellowCells", yellow);
mapping.put("candidates", candidates);
JSONArray unmapped = new JSONArray();
for (String req : requiredHints) {
if (!mappedCodes.contains(req)) {
unmapped.add(req);
}
}
mapping.put("unmappedRequired", unmapped);
return mapping;
}
/**
* 按 mapping.fields / fee_rows 写入事实;先清空映射覆盖的可填区(跳过黄区)。
*/
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));
}
}
try (InputStream in = Files.newInputStream(sourceXlsx); Workbook wb = WorkbookFactory.create(in)) {
JSONObject fields = mapping.getJSONObject("fields");
if (fields != null) {
for (String code : fields.keySet()) {
String cellRef = fields.getString(code);
if (StringUtils.isBlank(cellRef) || yellow.contains(cellRef)) {
continue;
}
Object val = facts.get(code);
if (val == null) {
clearCell(wb, cellRef);
continue;
}
if ("fee_rows".equals(code)) {
continue;
}
writeCell(wb, cellRef, String.valueOf(val));
}
}
writeFeeRows(wb, mapping, facts, yellow);
Files.createDirectories(targetXlsx.getParent());
try (OutputStream out = Files.newOutputStream(targetXlsx)) {
wb.write(out);
}
}
}
private static void writeFeeRows(Workbook wb, JSONObject mapping, JSONObject facts, Set<String> yellow) {
JSONObject feeRows = mapping.getJSONObject("fee_rows");
if (feeRows == null) {
return;
}
Object raw = facts.get("fee_rows");
if (!(raw instanceof JSONArray) && !(raw instanceof List)) {
return;
}
JSONArray rows = raw instanceof JSONArray ? (JSONArray) raw : JSON.parseArray(JSON.toJSONString(raw));
String anchor = feeRows.getString("anchor");
JSONObject columns = feeRows.getJSONObject("columns");
if (StringUtils.isBlank(anchor) || columns == null || rows == null || rows.isEmpty()) {
return;
}
ParsedRef anchorRef = ParsedRef.parse(anchor);
Sheet sheet = wb.getSheet(anchorRef.sheet);
if (sheet == null) {
sheet = wb.getSheetAt(0);
}
int startRow = anchorRef.row;
for (int i = 0; i < rows.size(); i++) {
JSONObject rowObj = rows.getJSONObject(i);
int r = startRow + i;
writeFeeCol(sheet, r, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName());
writeFeeCol(sheet, r, columns.getString("amount"), rowObj.getString("amount"), yellow, sheet.getSheetName());
if (columns.containsKey("remark")) {
writeFeeCol(sheet, r, columns.getString("remark"), rowObj.getString("remark"), yellow, sheet.getSheetName());
}
}
}
private static void writeFeeCol(Sheet sheet, int rowIdx, String colLetter, String value, Set<String> yellow, String sheetName) {
if (StringUtils.isBlank(colLetter)) {
return;
}
int col = CellReference.convertColStringToIndex(colLetter.trim());
String ref = sheetName + "!" + new CellReference(rowIdx, col).formatAsString();
if (yellow.contains(ref)) {
return;
}
Row row = sheet.getRow(rowIdx);
if (row == null) {
row = sheet.createRow(rowIdx);
}
Cell cell = row.getCell(col);
if (cell == null) {
cell = row.createCell(col);
}
cell.setCellValue(value == null ? "" : value);
}
private static void clearCell(Workbook wb, String cellRef) {
writeCell(wb, cellRef, "");
}
private static void writeCell(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;
}
Row row = sheet.getRow(ref.row);
if (row == null) {
row = sheet.createRow(ref.row);
}
Cell cell = row.getCell(ref.col);
if (cell == null) {
cell = row.createCell(ref.col);
}
cell.setCellValue(value == null ? "" : value);
}
private static String resolveFillTarget(Sheet sheet, Cell labelCell, String sheetName, String text, DataFormatter fmt) {
// 同一格「标签:值」
int idx = Math.max(text.indexOf(':'), text.indexOf(':'));
if (idx >= 0 && idx < text.length() - 1 && StringUtils.isNotBlank(text.substring(idx + 1).trim())) {
// 值已在标签格内:映射到本格(生成时会整格重写为纯值,运营需知悉)
return sheetName + "!" + new CellReference(labelCell).formatAsString();
}
// 右侧相邻非黄空格或任意右侧格
Row row = labelCell.getRow();
int nextCol = labelCell.getColumnIndex() + 1;
Cell right = row.getCell(nextCol);
if (right == null) {
right = row.createCell(nextCol);
}
return sheetName + "!" + new CellReference(right).formatAsString();
}
private static String matchLabel(String normalized, Map<String, String> dict) {
if (dict == null || dict.isEmpty()) {
return null;
}
String key = normalized;
if (dict.containsKey(key)) {
return dict.get(key);
}
// 去冒号再试
String noColon = key.replace(":", "").trim();
for (Map.Entry<String, String> e : dict.entrySet()) {
String dk = e.getKey().replace(":", ":").replace(":", "").trim();
if (dk.equalsIgnoreCase(noColon) || noColon.startsWith(dk) || dk.startsWith(noColon)) {
if (dk.length() >= 2 && noColon.length() >= 2) {
return e.getValue();
}
}
}
return null;
}
private static boolean isYellowFixed(Cell cell) {
CellStyle style = cell.getCellStyle();
if (style == null) {
return false;
}
short fill = style.getFillForegroundColor();
if (fill == IndexedColors.YELLOW.getIndex()
|| fill == IndexedColors.LIGHT_YELLOW.getIndex()
|| fill == IndexedColors.GOLD.getIndex()
|| fill == IndexedColors.LEMON_CHIFFON.getIndex()) {
return true;
}
if (cell instanceof XSSFCell) {
XSSFColor color = ((XSSFCell) cell).getCellStyle().getFillForegroundXSSFColor();
if (color != null) {
byte[] rgb = color.getRGB();
if (rgb != null && rgb.length >= 3) {
String hex = String.format("%02X%02X%02X", rgb[0] & 0xff, rgb[1] & 0xff, rgb[2] & 0xff);
if (YELLOW_RGB.contains(hex) || YELLOW_RGB.contains("FF" + hex)) {
return true;
}
// 近似黄:高 R/G、低 B
int r = rgb[0] & 0xff, g = rgb[1] & 0xff, b = rgb[2] & 0xff;
if (r > 200 && g > 180 && b < 160) {
return true;
}
}
}
}
return false;
}
private static Map<String, String> toStringMap(JSONObject obj) {
Map<String, String> map = new LinkedHashMap<>();
if (obj == null) {
return map;
}
for (String k : obj.keySet()) {
map.put(k, obj.getString(k));
}
return map;
}
/** sheet!A1 解析。 */
private static final class ParsedRef {
final String sheet;
final int row;
final int col;
private ParsedRef(String sheet, int row, int col) {
this.sheet = sheet;
this.row = row;
this.col = col;
}
static ParsedRef parse(String cellRef) {
String sheet = "Sheet1";
String addr = cellRef;
int bang = cellRef.indexOf('!');
if (bang > 0) {
sheet = cellRef.substring(0, bang);
addr = cellRef.substring(bang + 1);
}
CellReference cr = new CellReference(addr);
return new ParsedRef(sheet, cr.getRow(), cr.getCol());
}
}
}
@@ -0,0 +1,145 @@
{
"schemaVersion": "quote-template-mapping-v1",
"description": "报价模板常变版:原 Excel 几乎不改;黄色=固定区;映射点=系统字段→单元格;千问只辅助映射不发明价格。",
"rules": {
"yellowMeansFixed": true,
"yellowNote": "黄色填充单元格为版式固定内容,生成与预览时永不覆盖。",
"clearFillableBeforeWrite": true,
"layoutAuthority": "uploaded_excel",
"noUnifiedColumns": true,
"llmRole": "suggest_field_to_cell_mapping_only",
"priceSource": "frozen_quote_facts_only",
"versionFreeze": "generated_quote_binds_templateId_plus_templateVersion"
},
"modes": {
"auto": "可自动生成报价文件",
"manual": "仅下载给人工填写,不进自动生成"
},
"mappingJsonShape": {
"schemaVersion": "quote-template-mapping-v1",
"sheet": "主工作表名(可空,默认第一个)",
"fields": {
"customer_name": "SheetName!C3",
"quote_no": "SheetName!F4"
},
"fee_rows": {
"anchor": "SheetName!A15",
"columns": {
"name": "B",
"amount": "D",
"remark": "F"
},
"styleCopyRows": 1
},
"fixedYellowCells": ["SheetName!A1", "SheetName!B2"],
"candidates": [],
"unmappedRequired": []
},
"systemFields": [
{ "code": "customer_name", "label": "客户名称", "requiredHint": true },
{ "code": "contact_name", "label": "联系人", "requiredHint": false },
{ "code": "contact_phone", "label": "电话", "requiredHint": false },
{ "code": "quote_no", "label": "报价单编号", "requiredHint": true },
{ "code": "quote_date", "label": "报价日期", "requiredHint": true },
{ "code": "valid_until", "label": "有效期至", "requiredHint": false },
{ "code": "currency", "label": "币别", "requiredHint": false },
{ "code": "sales_name", "label": "业务员", "requiredHint": false },
{ "code": "pol", "label": "起运港", "requiredHint": false },
{ "code": "pod", "label": "目的港", "requiredHint": false },
{ "code": "origin", "label": "始发地", "requiredHint": false },
{ "code": "destination", "label": "目的地", "requiredHint": false },
{ "code": "commodity", "label": "品名", "requiredHint": false },
{ "code": "quantity", "label": "货物数量", "requiredHint": false },
{ "code": "container_type_qty", "label": "箱型箱量", "requiredHint": false },
{ "code": "fcl_or_lcl", "label": "整柜或拼柜", "requiredHint": false },
{ "code": "trade_terms", "label": "贸易条款", "requiredHint": false },
{ "code": "cargo_ready_date", "label": "货好时间", "requiredHint": false },
{ "code": "pieces", "label": "件数", "requiredHint": false },
{ "code": "weight_kg", "label": "重量", "requiredHint": false },
{ "code": "volume_cbm", "label": "体积", "requiredHint": false },
{ "code": "total_amount", "label": "报价合计", "requiredHint": false },
{ "code": "fee_rows", "label": "费用明细行", "requiredHint": false }
],
"labelDictionary": {
"客户名称": "customer_name",
"客户": "customer_name",
"客户:": "customer_name",
"联系人": "contact_name",
"联系人:": "contact_name",
"电话": "contact_phone",
"电话:": "contact_phone",
"报价单编号": "quote_no",
"报价编号": "quote_no",
"报价日期": "quote_date",
"询价日期": "quote_date",
"有效期至": "valid_until",
"报价有效期": "valid_until",
"币别": "currency",
"业务员": "sales_name",
"起运港": "pol",
"起 运 港": "pol",
"目的港": "pod",
"目 的 港": "pod",
"始发地": "origin",
"始发站": "origin",
"目的地": "destination",
"品名": "commodity",
"品 名": "commodity",
"货物数量": "quantity",
"箱型箱量": "container_type_qty",
"整柜或拼柜": "fcl_or_lcl",
"贸易条款": "trade_terms",
"货好时间": "cargo_ready_date",
"件数": "pieces",
"件 数": "pieces",
"重量": "weight_kg",
"重 量": "weight_kg",
"体积": "volume_cbm",
"体 积": "volume_cbm"
},
"samplePreviewFacts": {
"customer_name": "ABC Trading Co.",
"contact_name": "张三",
"contact_phone": "13800000000",
"quote_no": "QT-202607020001",
"quote_date": "2026-07-02",
"valid_until": "2026-07-05",
"currency": "USD",
"sales_name": "张敏",
"pol": "CNSHA",
"pod": "USLAX",
"origin": "深圳",
"destination": "洛杉矶",
"commodity": "通用货物",
"quantity": "2",
"container_type_qty": "40HQ x 2",
"fcl_or_lcl": "FCL",
"trade_terms": "FOB",
"cargo_ready_date": "2026-07-10",
"pieces": "10",
"weight_kg": "1200",
"volume_cbm": "8.5",
"total_amount": "3680.00",
"fee_rows": [
{ "name": "O/F 海运费", "amount": "2900", "remark": "" },
{ "name": "THC + DOC + SEAL", "amount": "780", "remark": "" }
]
},
"categories": {
"海运": ["海运报价单模板"],
"空运": ["空运到港报价单模板", "空运到门报价单模板"],
"陆运": [
"双清或中越",
"国内整车",
"国内拼车",
"跨境整车",
"跨境集拼",
"中港",
"凭祥保税仓&普仓仓储"
]
},
"seedModeHints": {
"manualKeywords": ["人工报价", "双清", "保税仓", "仓储"],
"autoDefault": true
}
}