落地报价模板主账与后台:合同映射、灌种 SQL/脚本、智能体填表,并补测服运维探针与合同文档对齐。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+240
@@ -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";
|
||||
}
|
||||
}
|
||||
+73
@@ -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;
|
||||
}
|
||||
+85
@@ -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;
|
||||
}
|
||||
+9
@@ -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;
|
||||
|
||||
|
||||
+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.InquiryQuoteTemplate;
|
||||
|
||||
/**
|
||||
* 报价模板逻辑头 Mapper。
|
||||
*/
|
||||
@Mapper
|
||||
public interface InquiryQuoteTemplateMapper extends BaseMapper<InquiryQuoteTemplate> {
|
||||
}
|
||||
+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.InquiryQuoteTemplateVersion;
|
||||
|
||||
/**
|
||||
* 报价模板版本 Mapper。
|
||||
*/
|
||||
@Mapper
|
||||
public interface InquiryQuoteTemplateVersionMapper extends BaseMapper<InquiryQuoteTemplateVersion> {
|
||||
}
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* 友通达新系统 · 询价主账手写模块(从零实现)。
|
||||
* <p>
|
||||
* 本期能力:分析看板、工单六态、员工企微岗位、机器人回复语。
|
||||
* 排除:报价模板、识别任务、报价生成、存档死信。
|
||||
* 本期能力:分析看板、工单六态、员工企微岗位、机器人回复语、报价模板(逻辑头+版本+映射)。
|
||||
* 排除:识别任务、存档死信、Graph/自然语言抽取。
|
||||
* 数据只写 inquiry_robot;禁止连现网 jeecg-boot。
|
||||
*/
|
||||
package org.jeecg.modules.inquiry;
|
||||
|
||||
+57
@@ -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);
|
||||
}
|
||||
+557
@@ -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 "海运报价单模板";
|
||||
}
|
||||
}
|
||||
+380
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+145
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user