Compare commits

...
17 changed files with 1026 additions and 76 deletions
@@ -1,7 +1,7 @@
"""
陆运选线路 H5 页。
本文件职责:按完整线路列出各段成本与合计,提交所选下标。
本文件职责:按完整线路列出各段车型、成本与合计,提交所选下标。
样式对齐空运选线路页。禁止 URL userid;禁止本页查 TMS。
"""
@@ -64,19 +64,40 @@ def render_land_route_ok() -> str:
)
def _segment_vehicle(leg: dict[str, Any] | None) -> str:
"""每段车型用 TMS 的 vehicleType 原文。没有台数,空值显示 -。"""
src = dict(leg or {})
raw = str(
src.get("vehicleType") or src.get("vehicle_type") or src.get("truckType") or ""
).strip()
if raw.lower() in {"none", "null"}:
raw = ""
return raw or "-"
def _route_card(index: int, route: dict[str, Any], quote: dict[str, Any] | None = None) -> str:
"""一张线路:航线、各段路段+TMS 费目、合计。"""
"""一张线路:航线、各段路段、车型、TMS 费目、合计。"""
line = html.escape(route_line(route))
blocks = land_leg_fee_blocks_for_route(quote, route)
shown_legs = [
leg
for leg in list(route.get("legs") or [])
if str(leg.get("origin") or "").strip() or str(leg.get("destination") or "").strip()
]
etas = {i: d for i, (_, d) in enumerate(option_eta_bits(route), 1)}
fee_bits: list[str] = []
items = ""
if blocks:
for i, (title, fees, eta) in enumerate(blocks, 1):
vehicle = _segment_vehicle(shown_legs[i - 1] if i - 1 < len(shown_legs) else None)
items += (
f"<li class='leg'><span class='n'>{html.escape(title)}</span>"
"<span></span></li>"
)
items += (
f"<li><span class='n'>车型 {html.escape(vehicle)}</span>"
"<span></span></li>"
)
for fee in fees:
amount = _money(fee.get("amount"), str(fee.get("currency") or "").strip())
name = str(fee.get("name") or "").strip()
@@ -2624,6 +2624,20 @@ def group_tms_confirm_hint(*, business_line: str = "") -> str:
return f"请{who}回复“确认报价”确定采用TMS报价。\n{_GROUP_QUOTE_TAIL}"
def multi_group_field_hint(*, includes_air: bool) -> str:
"""
多段群里每次带出各段询价字段时,贴在最后一行字段下面。
任一段是空运,喊航线或产品;没有空运只喊产品。成交跟进不走这里。
注里说的是附件报价单只存档,不改报价写入规则。
"""
who = "航线或产品人员" if includes_air else "产品人员"
return (
f"{who}可通过“纯文字”告知新报价,或直接将新报价excel表发给智能体。\n"
"(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)"
)
def group_no_tms_quote_hint(*, business_line: str = "") -> str:
"""
TMS 无价时写在「暂无匹配」下方:谁可以用文字或 Excel 另报。
@@ -385,6 +385,7 @@ def group_brief(
结构对齐私聊核对:@销售和各段产品,下一行直接接段1询价字段。
不写 TMS,不写协同段。工单号已在私聊提示过,群里不再重复一行。
每次拼这段字段都在底部加另报提示;成交跟进是另一条消息,不加。
"""
_ = work_order_no
names: list[str] = []
@@ -406,6 +407,8 @@ def group_brief(
body = _segment_inquiry_block(seg).strip()
if body:
lines.extend(body.splitlines())
# 紧挨最后一行字段,中间不空行。含空运与不含空运用不同开头。
lines.append(copy.multi_group_field_hint(includes_air=includes_air(segments)))
return "\n".join(lines)
@@ -433,6 +433,41 @@ class LandRouteOptionsTests(unittest.TestCase):
self.assertIn("CNY 7500", page)
self.assertIn("成本价合计", page)
def test_h5_shows_vehicle_type_on_each_leg(self) -> None:
"""多条线路的 H5 卡片按段展示 TMS 车型,没有就写 -。"""
from agent.channel.h5.land_route_page import render_land_route_page
quote = _three_routes()
for leg in quote["landSegments"]:
dest = str(leg.get("destination") or "")
origin = str(leg.get("origin") or "")
if dest == "深圳":
leg["vehicleType"] = "9米6"
elif origin == "深圳":
leg["vehicleType"] = "13米"
elif dest == "广西":
leg["vehicleType"] = None
quote["fee_lines"] = _tms_land_fees(
(1, 1, "运输费", 7500),
(1, 2, "运输费", 9600),
(2, 1, "运输费", 7500),
(2, 2, "运输费", 9600),
(3, 1, "运输费", 7500),
(3, 2, "运输费", 9600),
)
page = render_land_route_page(
{"work_order_no": "WO202609220010", "quote": quote}
)
od = page.find("上海 → 深圳")
vehicle = page.find("车型 9米6", od)
fee = page.find("运输费", vehicle)
self.assertGreaterEqual(od, 0)
self.assertGreater(vehicle, od)
self.assertGreater(fee, vehicle)
self.assertIn("车型 13米", page)
self.assertIn("车型 -", page)
self.assertNotIn("9米6/1", page)
def test_h5_and_detail_use_tms_fees_when_java_sends_json_strings(self) -> None:
"""主账 quote 常是 JSON 串,且 fee_lines 可能没带段号。"""
from agent.channel.h5.land_route_page import render_land_route_page
+5
View File
@@ -177,6 +177,7 @@ class QuoteTests(unittest.TestCase):
)
self.assertEqual(phase, "multi_deal_sent")
self.assertIn("120", self.client.texts[-1])
self.assertNotIn("纯文字", self.client.texts[-1])
self.assertNotIn("100", deal_text(
work_order_no=self.ticket.work_order_no,
segments=[Segment(index=1, mode="SEA"), Segment(index=2, mode="LAND")],
@@ -198,6 +199,8 @@ class QuoteTests(unittest.TestCase):
brief = self.client.texts[-1]
self.assertIn("段1:海运", brief)
self.assertIn("段2:陆运", brief)
self.assertIn("产品人员可通过“纯文字”告知新报价", brief)
self.assertNotIn("航线或产品人员", brief)
self.assertNotIn("TMS报价", brief)
self.assertNotIn("其他段报完", brief)
self.assertEqual(self.ledger.get_ticket(work_order_no=no).collab_chat_id, "room-9")
@@ -265,6 +268,8 @@ class QuoteTests(unittest.TestCase):
self.assertIn("段2:空运", brief)
self.assertIn("目的地(必填):CRK", brief)
self.assertIn("贸易条款(非必填):DAP", brief)
self.assertTrue(brief.endswith("(注:附件报价单仅作为文件存储于后台,智能体不读取且不将报价内容写进系统)"))
self.assertIn("航线或产品人员可通过“纯文字”告知新报价", brief)
self.assertNotIn("其他段报完", "\n".join(self.client.texts))
self.assertNotIn("询价字段:", brief)
@@ -167,6 +167,10 @@ class SplitTests(unittest.TestCase):
)
self.assertIn("@吴季林@林绵助\n段1:", text)
self.assertNotIn("@林绵助\n\n段1", text)
self.assertTrue(
text.endswith(copy.multi_group_field_hint(includes_air=True)),
text,
)
def test_missing_fields_use_single_chat_examples(self) -> None:
outcome = split_segments("段1:海运\n起运港:广州港\n段2:空运\n目的地:CRK")
@@ -694,6 +698,11 @@ class PullTests(unittest.TestCase):
segments=flow._segments(flow.session_of("sales")),
)
self.assertNotIn("TMS", brief_text)
self.assertTrue(
brief_text.endswith(copy.multi_group_field_hint(includes_air=False)),
brief_text,
)
self.assertNotIn("航线或产品人员", brief_text)
class CardPullTests(unittest.TestCase):
@@ -235,6 +235,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
int nextVer = nextVersionNo(template.getId());
String sha = DigestUtils.sha256Hex(fileBytes);
// 本地 by-sha 镜像:MinIO 对象损坏时仍能按版本 sha 回退出完好模板
objectStore.writeByShaMirror(fileBytes, sha);
InquiryQuoteTemplateVersion version = new InquiryQuoteTemplateVersion();
version.setTemplateId(template.getId());
@@ -564,7 +566,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (!"excel".equals(ver.getFormat())) {
throw new IllegalStateException("仅 Excel 支持样例回填预览");
}
Path source = resolveObjectPath(ver.getObjectKey());
Path source = resolveObjectPath(ver.getObjectKey(), ver.getSha256());
JSONObject contract = QuoteTemplateExcelSupport.loadContract(resolveContractPath());
JSONObject mapping;
try {
@@ -599,7 +601,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
if (!"excel".equals(ver.getFormat())) {
throw new IllegalStateException("仅 Excel 支持表格预览");
}
Path source = resolveObjectPath(ver.getObjectKey());
Path source = resolveObjectPath(ver.getObjectKey(), ver.getSha256());
JSONObject mapping;
try {
mapping = StringUtils.isNotBlank(ver.getMappingJson())
@@ -681,7 +683,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
Files.createDirectories(previewDir);
Path cachedPng = previewDir.resolve(ver.getId() + "-snapshot-v5.png");
Path cachedPdf = previewDir.resolve(ver.getId() + "-snapshot-v5.pdf");
Path source = resolveObjectPath(ver.getObjectKey());
Path source = resolveObjectPath(ver.getObjectKey(), ver.getSha256());
QuoteTemplateSnapshotRenderer.PreviewBytes rendered = snapshotRenderer.render(source, ver.getId());
Path target = rendered.isImage() ? cachedPng : cachedPdf;
Files.write(target, rendered.getBytes());
@@ -696,6 +698,13 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
return objectStore.resolve(objectKey);
}
/**
* 带版本 sha256 的解析:远程缓存/对象与指纹不一致时删缓存并回退 by-sha 镜像。
*/
public Path resolveObjectPath(String objectKey, String expectedSha256) {
return objectStore.resolve(objectKey, expectedSha256);
}
@Override
public List<Map<String, Object>> listEnabledForAgent(boolean enabledOnly) {
QueryWrapper<InquiryQuoteTemplate> qw = new QueryWrapper<>();
@@ -750,13 +759,16 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
String objectKey = String.valueOf(picked.getOrDefault("objectKey", ""));
String mappingJson = String.valueOf(picked.getOrDefault("mappingJson", ""));
String expectedSha = String.valueOf(picked.getOrDefault("sha256", ""));
if (StringUtils.isBlank(objectKey) || "null".equalsIgnoreCase(objectKey)) {
log.warn("报价出单模板无文件 wo={} template={}", workOrderNo, picked.get("templateId"));
return fail;
}
Path source;
try {
source = resolveObjectPath(objectKey);
// 传入版本 sha:防止 MinIO/缓存被掏空后仍出「费目名全空」的坏表
source = resolveObjectPath(objectKey,
("null".equalsIgnoreCase(expectedSha) ? null : expectedSha));
} catch (Exception e) {
log.warn("报价出单找不到模板文件 wo={} err={}", workOrderNo, e.getMessage());
return fail;
@@ -1043,6 +1055,18 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
out.put("chargeable_weight_kg", chargeable);
out.put("chargeableWeightKg", chargeable);
}
// 重量单位:仅供「已有费用行」时写入报价明细「单位」;无报价不出费用行,也不填单位。
String weightUnitEarly = first(src, "重量单位", "weight_unit", "weightUnit", "毛重单位");
if (StringUtils.isBlank(weightUnitEarly)) {
String rawW = first(src, "毛重", "重量", "重量(KG)", "weight_kg");
if (StringUtils.isNotBlank(rawW) && rawW.matches("(?i).*\\b(KG|KGS|公斤)\\b.*")) {
weightUnitEarly = "KG";
}
}
if (looksAirInquiry(src, q, businessLine)) {
out.put("weight_unit", QuoteTemplateExcelSupport.normalizeWeightUnit(weightUnitEarly));
out.put("重量单位", out.getString("weight_unit"));
}
for (Map.Entry<String, String> e : QuoteTemplateExcelSupport.LAND_TMS_KEY_TO_CODE.entrySet()) {
String val = first(q, e.getKey(), e.getValue());
if (StringUtils.isNotBlank(val) && !"-".equals(val) && StringUtils.isBlank(out.getString(e.getValue()))) {
@@ -1080,8 +1104,8 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
}
}
// 空运:已落库的旧明细若只有费率,出单时按计费重补 单位/单价/数量/行金额
if (!rows.isEmpty() && StringUtils.isNotBlank(chargeable)) {
// 空运:补单位/数量/单价(手工常只有金额,WO020 空运费与增点费)
if (!rows.isEmpty()) {
List<Map<String, String>> lineMaps = new ArrayList<>();
for (int i = 0; i < rows.size(); i++) {
JSONObject one = rows.getJSONObject(i);
@@ -1097,8 +1121,15 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
}
lineMaps.add(m);
}
// 出单路径自带重算(单位/单价/数量/金额)
QuoteTemplateExcelSupport.enrichAirWeightFeeRows(lineMaps, chargeable);
String weightUnit = first(src, "重量单位", "weight_unit", "weightUnit", "毛重单位");
if (StringUtils.isBlank(weightUnit)) {
// 从「262 KG」「重量:262KG」里剥单位
String rawW = first(src, "毛重", "重量", "重量(KG)", "weight_kg");
if (StringUtils.isNotBlank(rawW) && rawW.matches("(?i).*\\b(KG|KGS|公斤)\\b.*")) {
weightUnit = "KG";
}
}
QuoteTemplateExcelSupport.enrichAirWeightFeeRows(lineMaps, chargeable, weightUnit);
JSONArray enriched = new JSONArray();
for (Map<String, String> m : lineMaps) {
// Fastjson 只要 Map<String,Object>,不能直接塞 Map<String,String>。
@@ -1573,7 +1604,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
throw new IllegalArgumentException("模板不存在");
}
try {
Path local = resolveObjectPath(ver.getObjectKey());
Path local = resolveObjectPath(ver.getObjectKey(), ver.getSha256());
byte[] fileBytes = Files.readAllBytes(local);
ver.setParseStatus("pending");
ver.setParseMessage("解析中,请稍候");
@@ -1642,7 +1673,7 @@ public class InquiryQuoteTemplateServiceImpl extends ServiceImpl<InquiryQuoteTem
org.apache.poi.ss.usermodel.Workbook wb = null;
try {
if (StringUtils.isNotBlank(ver.getObjectKey())) {
Path local = resolveObjectPath(ver.getObjectKey());
Path local = resolveObjectPath(ver.getObjectKey(), ver.getSha256());
try (InputStream in = Files.newInputStream(local)) {
wb = org.apache.poi.ss.usermodel.WorkbookFactory.create(in);
}
@@ -2213,9 +2213,8 @@ public final class QuoteTemplateExcelSupport {
continue;
}
JSONArray rows = resolveRegionFeeRows(facts, region);
if (rows.isEmpty()) {
continue;
}
// 有费用的才出行:无单价/金额的行不占明细(对齐空运到港;到门模板样例名不得冒充有费)
rows = filterFeeRowsWithCharge(rows);
String anchor = region.getString("anchor");
JSONObject columns = region.getJSONObject("columns");
if (StringUtils.isBlank(anchor) || columns == null) {
@@ -2235,6 +2234,12 @@ public final class QuoteTemplateExcelSupport {
continue;
}
int stopRow = findStopRow(sheet, region.getJSONArray("stopAtText"), startRow);
// 无明细:保留项目列骨架(空运段/起运港/目的港/其他),清掉样例费目名。
// 对齐到港「有费用的才显示行」——到门模板样例(操作费/目的港派送费…)无报价时不得残留。
if (rows.isEmpty()) {
clearFeeRowsAfter(sheet, startRow, stopRow, columns, yellow, sheet.getSheetName(), true);
continue;
}
int styleCopy = region.getIntValue("styleCopyRows");
if (styleCopy <= 0) {
styleCopy = 1;
@@ -2255,9 +2260,34 @@ public final class QuoteTemplateExcelSupport {
// 不整行跳过:备注「发生时收取」常标黄,整行跳过会把费用名留成模板样例(如 ZUH)。
// 黄格仍由 writeFeeCol 逐格跳过。
JSONObject rowObj = rows.getJSONObject(r);
writeFeeCol(sheet, rowIdx, columns.getString("project"),
firstNonBlank(rowObj.getString("project"), rowObj.getString("segment")),
yellow, sheet.getSheetName());
String projectVal = firstNonBlank(rowObj.getString("project"), rowObj.getString("segment"));
// 增点/操作等禁止落在模板首行「空运段」骨架上:无 project 时按费目名归类
if (StringUtils.isBlank(projectVal)) {
projectVal = isAirWeightFeeName(rowObj.getString("name")) ? "空运段" : "起运港";
} else if (!isAirWeightFeeName(rowObj.getString("name"))
&& ("空运段".equals(projectVal) || "空运".equals(projectVal))) {
projectVal = "起运港";
}
String projectBefore = readCellText(
sheet.getRow(rowIdx) == null || StringUtils.isBlank(columns.getString("project"))
? null
: sheet.getRow(rowIdx).getCell(
CellReference.convertColStringToIndex(columns.getString("project").trim())));
writeFeeCol(sheet, rowIdx, columns.getString("project"), projectVal, yellow, sheet.getSheetName());
String projectAfter = readCellText(
sheet.getRow(rowIdx) == null || StringUtils.isBlank(columns.getString("project"))
? null
: sheet.getRow(rowIdx).getCell(
CellReference.convertColStringToIndex(columns.getString("project").trim())));
// writeFeeCol 可能因黄底/保护跳过:项目骨架必须能改成费目归属(增点费→起运港)
if (StringUtils.isNotBlank(projectVal) && !projectVal.equals(projectAfter)
&& StringUtils.isNotBlank(columns.getString("project"))) {
forceWriteProjectSkeleton(sheet, rowIdx,
CellReference.convertColStringToIndex(columns.getString("project").trim()),
projectVal);
projectAfter = readCellText(sheet.getRow(rowIdx).getCell(
CellReference.convertColStringToIndex(columns.getString("project").trim())));
}
writeFeeCol(sheet, rowIdx, columns.getString("name"), rowObj.getString("name"), yellow, sheet.getSheetName());
writeFeeCol(sheet, rowIdx, columns.getString("unit"), rowObj.getString("unit"), yellow, sheet.getSheetName());
writeFeeCol(sheet, rowIdx, columns.getString("quantity"),
@@ -2296,20 +2326,121 @@ public final class QuoteTemplateExcelSupport {
}
}
}
// 明细行数少于模板占位时,清掉后面非黄底样例行(避免 ZUH/文件费残留)
clearFeeRowsAfter(sheet, startRow + rows.size(), stopRow, columns, yellow, sheet.getSheetName());
// 明细行数少于模板占位时,清掉后面样例行(含黄底「发生时收取」备注),只留有费用的行
clearFeeRowsAfter(sheet, startRow + rows.size(), stopRow, columns, yellow, sheet.getSheetName(), true);
// 有明细时再扫一遍:防止 enrich 未覆盖到的空运费行单位仍空
fillAirWeightUnitOnTemplateRows(sheet, startRow, startRow + rows.size(), columns, facts, yellow);
}
}
/**
* 清空 [fromRow, stopRow) 费用列;黄底固定行保留(如「发生时收取」占位)。
* 只保留有单价或金额的费用行;纯名称占位不写出(有费用的才显示行)。
*/
static JSONArray filterFeeRowsWithCharge(JSONArray rows) {
JSONArray out = new JSONArray();
if (rows == null || rows.isEmpty()) {
return out;
}
for (int i = 0; i < rows.size(); i++) {
JSONObject row = rows.getJSONObject(i);
if (row == null) {
continue;
}
String amount = StringUtils.trimToEmpty(firstNonBlank(row.getString("amount"), row.getString("salePrice")));
String unitPrice = StringUtils.trimToEmpty(firstNonBlank(row.getString("unit_price"),
row.getString("unitPrice")));
if ((amount.isEmpty() || "-".equals(amount)) && (unitPrice.isEmpty() || "-".equals(unitPrice))) {
continue;
}
out.add(row);
}
return out;
}
/**
* 已写费用行:空运重量计价费目缺「单位」时,填入询价重量单位(默认 KG)。
* <p>
* stopAtText 未命中时 {@link #findStopRow} 会回 Integer.MAX_VALUE。
* 若原样循环清空,会捅到 Excel 最大行(1048576)并抛 IllegalArgumentException,整张出单失败。
* 因此未命中时只清到表内已有末行,绝不 createRow 扩到上限。
* 只动空运运输费行,不起运港操作费/文件费等(那些不是按毛重计价)。
* 副作用:写 Excel 单位格。线程:调用方线程。
*/
static int fillAirWeightUnitOnTemplateRows(Sheet sheet, int startRow, int stopRow, JSONObject columns,
JSONObject facts, Set<String> yellow) {
if (sheet == null || columns == null || startRow < 0) {
return 0;
}
String unitCol = columns.getString("unit");
String nameCol = columns.getString("name");
if (StringUtils.isBlank(unitCol) || StringUtils.isBlank(nameCol)) {
return 0;
}
String weightUnit = resolveWeightUnitFromFacts(facts);
int lastExisting = sheet.getLastRowNum();
int end = stopRow;
if (end == Integer.MAX_VALUE || end > lastExisting + 1) {
end = lastExisting + 1;
}
end = Math.min(end, 1048575 + 1);
if (startRow >= end) {
return 0;
}
int nameIdx = CellReference.convertColStringToIndex(nameCol.trim());
int unitIdx = CellReference.convertColStringToIndex(unitCol.trim());
int filled = 0;
for (int r = startRow; r < end; r++) {
Row row = sheet.getRow(r);
if (row == null) {
continue;
}
String name = readCellText(row.getCell(nameIdx));
if (!isAirWeightFeeName(name)) {
continue;
}
String existing = readCellText(row.getCell(unitIdx));
if (StringUtils.isNotBlank(existing) && !"-".equals(existing.trim())) {
continue;
}
writeFeeCol(sheet, r, unitCol, weightUnit, yellow, sheet.getSheetName());
filled++;
}
return filled;
}
/**
* 从填表事实取重量单位:显式字段优先,其次毛重/重量原文里的 KG/公斤,默认 KG。
*/
static String resolveWeightUnitFromFacts(JSONObject facts) {
if (facts == null) {
return "KG";
}
String raw = firstNonBlank(facts.getString("weight_unit"), facts.getString("重量单位"),
facts.getString("weightUnit"), facts.getString("毛重单位"));
if (StringUtils.isBlank(raw)) {
String w = firstNonBlank(facts.getString("毛重"), facts.getString("重量"),
facts.getString("重量(KG)"), facts.getString("weight_kg"));
if (StringUtils.isNotBlank(w) && w.matches("(?i).*\\b(KG|KGS|公斤)\\b.*")) {
raw = "KG";
}
}
return normalizeWeightUnit(raw);
}
/**
* 清空 [fromRow, stopRow) 费用列。
* <p>
* 项目列「空运段/起运港/目的港/其他」是模板骨架,禁止擦掉。
* {@code clearSampleRemarks=true} 时连备注样例(含黄底「发生时收取」)一并清掉,
* 实现空运到门与到港一致的「有费用的才显示行」。
* <p>
* stopAtText 未命中时 {@link #findStopRow} 会回 Integer.MAX_VALUE;
* 未命中时只清到表内已有末行,绝不 createRow 扩到上限。
*/
private static void clearFeeRowsAfter(Sheet sheet, int fromRow, int stopRow, JSONObject columns,
Set<String> yellow, String sheetName) {
clearFeeRowsAfter(sheet, fromRow, stopRow, columns, yellow, sheetName, false);
}
private static void clearFeeRowsAfter(Sheet sheet, int fromRow, int stopRow, JSONObject columns,
Set<String> yellow, String sheetName, boolean clearSampleRemarks) {
if (sheet == null || columns == null || fromRow < 0) {
return;
}
@@ -2323,19 +2454,104 @@ public final class QuoteTemplateExcelSupport {
if (fromRow >= end) {
return;
}
// 备注列常黄底「发生时收取」:不清 remark,只清费用数据列;黄格由 writeFeeCol 跳过
String[] keys = {"project", "name", "unit", "quantity", "unit_price", "amount", "currency", "container_rate"};
// 有费才出行时:映射若缺备注列,按表头补上,否则「发生时收取」清不掉
if (clearSampleRemarks && StringUtils.isBlank(columns.getString("remark"))) {
Map<String, Integer> header = findFeeTableColumnsAbove(sheet, Math.max(fromRow, 0));
if (header == null || header.isEmpty()) {
header = findFeeTableColumns(sheet);
}
if (header != null && header.get("remark") != null) {
columns.put("remark", CellReference.convertNumToColString(header.get("remark")));
}
}
int projectCol = -1;
if (StringUtils.isNotBlank(columns.getString("project"))) {
projectCol = CellReference.convertColStringToIndex(columns.getString("project").trim());
}
// 项目列保留骨架;费用名/金额等可清;有费才出行时备注样例也清。
String[] keys = clearSampleRemarks
? new String[]{"name", "unit", "quantity", "unit_price", "amount", "currency",
"container_rate", "remark"}
: new String[]{"name", "unit", "quantity", "unit_price", "amount", "currency", "container_rate"};
for (int r = fromRow; r < end; r++) {
for (String key : keys) {
String colLetter = columns.getString(key);
if (StringUtils.isBlank(colLetter)) {
continue;
}
writeFeeCol(sheet, r, colLetter, "", yellow, sheetName);
int col = CellReference.convertColStringToIndex(colLetter.trim());
// 绝对不清项目列(含误配成 name=A 的情况)
if (projectCol >= 0 && col == projectCol) {
continue;
}
int[] origin = resolveMergeOrigin(sheet, r, col);
// 合并区顶落在已写费用行:禁止经 origin 误清
if (origin[0] < fromRow) {
continue;
}
// 合并原点若落在项目列(到门 A18:A21 起运港),只清本格,绝不擦骨架
if (projectCol >= 0 && origin[1] == projectCol) {
origin = new int[]{r, col};
}
if (clearSampleRemarks) {
// 黄底样例备注/费目名也要清,否则到门模板仍像「有一行费用」
forceBlankFeeCell(sheet, origin[0], origin[1], projectCol);
} else {
writeFeeCol(sheet, r, colLetter, "", yellow, sheetName);
}
}
}
}
/**
* 强制写入项目骨架文案(空运段/起运港/目的港/其他),绕过黄底与 writeFeeCol 保护。
* 用于「增点费」落到模板首行时把「空运段」改成「起运港」。
*/
private static void forceWriteProjectSkeleton(Sheet sheet, int rowIdx, int colIdx, String value) {
if (sheet == null || rowIdx < 0 || colIdx < 0 || StringUtils.isBlank(value)) {
return;
}
Row row = sheet.getRow(rowIdx);
if (row == null) {
row = sheet.createRow(rowIdx);
}
Cell cell = row.getCell(colIdx);
if (cell == null) {
cell = row.createCell(colIdx);
}
cell.setCellValue(value);
}
/**
* 强制清空费用格(绕过黄底保护)。仅用于清模板样例占位,不用于写业务值。
*
* @param projectCol 项目列索引;该列及「空运段/起运港/目的港/其他」文案禁止清空
*/
private static void forceBlankFeeCell(Sheet sheet, int rowIdx, int colIdx, int projectCol) {
if (sheet == null || rowIdx < 0 || rowIdx > 1048575 || colIdx < 0) {
return;
}
if (projectCol >= 0 && colIdx == projectCol) {
return;
}
Row row = sheet.getRow(rowIdx);
if (row == null) {
return;
}
Cell cell = row.getCell(colIdx);
if (cell == null) {
return;
}
String exist = readCellText(cell);
// 项目骨架与表头/章节标题不碰
if (isSectionTitle(sheet, cell) || isFeeTableHeader(exist)
|| "空运段".equals(exist) || "起运港".equals(exist) || "目的港".equals(exist)
|| "其他".equals(exist) || "其它".equals(exist)) {
return;
}
cell.setBlank();
}
/**
* 把费用区表头「箱型(动态填充箱型)」替换为真实箱型码(20GP / 40HQ…)。
*/
@@ -4837,7 +5053,9 @@ public final class QuoteTemplateExcelSupport {
boolean needPrice = StringUtils.isBlank(out.getString("unit_price"));
boolean needQty = StringUtils.isBlank(out.getString("quantity"));
boolean needProject = StringUtils.isBlank(out.getString("project"));
if (!needUnit && !needPrice && !needQty && !needProject) {
boolean needRemark = StringUtils.isBlank(out.getString("remark"));
if (!needUnit && !needPrice && !needQty && !needProject && !needRemark
&& StringUtils.isNotBlank(out.getString("name"))) {
return out;
}
Map<String, Integer> header = findFeeTableColumnsAbove(sheet, Math.max(dataStartRow, 0));
@@ -4859,6 +5077,9 @@ public final class QuoteTemplateExcelSupport {
if (needProject && header.get("project") != null) {
out.put("project", CellReference.convertNumToColString(header.get("project")));
}
if (needRemark && header.get("remark") != null) {
out.put("remark", CellReference.convertNumToColString(header.get("remark")));
}
if (StringUtils.isBlank(out.getString("name")) && header.get("name") != null) {
out.put("name", CellReference.convertNumToColString(header.get("name")));
}
@@ -4867,7 +5088,11 @@ public final class QuoteTemplateExcelSupport {
/**
* 空运 airOptions[].departureCharges → 费用行(出单用)。
* <p>
* 已落库 payload 常把 airOptions 存成 JSON 字符串。
* TMS 常把「空运费」只放在舱位 salePrice/unitPrice,departureCharges 只有增点费/拆板费;
* 若 firstNonNull 只取 departureCharges,出单会缺空运费,且模板样例「空运段|增点费」会残留。
* 这里合并 departureCharges+feeItems,缺运费时用舱位价注入「空运费」,并保证项目列归类。
*/
public static JSONArray parseAirDepartureChargeRows(Object airOptions, Object airOptionsAlt,
String chargeableWeightKg, String headerCurrency) {
@@ -4883,9 +5108,11 @@ public final class QuoteTemplateExcelSupport {
if (opt == null) {
continue;
}
JSONArray charges = parseFeeRows(firstNonNull(opt.get("departureCharges"), opt.get("departure_charges"),
opt.get("charges"), opt.get("feeItems")));
// 合并起运附加费与 feeItems,禁止 firstNonNull 只吃一边丢掉空运费
JSONArray charges = mergeAirOptionChargeArrays(opt);
String optCcy = firstNonBlank(opt.getString("currency"), headerCurrency);
String optCw = firstNonBlank(opt.getString("chargeableWeightKg"), opt.getString("chargeable_weight_kg"),
chargeableWeightKg);
for (int c = 0; c < charges.size(); c++) {
JSONObject ch = charges.getJSONObject(c);
if (ch == null) {
@@ -4921,17 +5148,113 @@ public final class QuoteTemplateExcelSupport {
}
lines.add(line);
}
// departureCharges 只有附加费时,用舱位 salePrice/unitPrice 补「空运费」行
injectAirFreightLineIfMissing(lines, opt, optCw, optCcy);
if (!lines.isEmpty()) {
break;
}
}
enrichAirWeightFeeRows(lines, chargeableWeightKg);
// 空运段(运费)在前,起运港附加费在后,避免盖住模板首行样例「空运段」
lines.sort((a, b) -> {
boolean aAir = a != null && isAirWeightFeeName(a.get("name"));
boolean bAir = b != null && isAirWeightFeeName(b.get("name"));
if (aAir == bAir) {
return 0;
}
return aAir ? -1 : 1;
});
for (Map<String, String> line : lines) {
out.add(JSON.parseObject(JSON.toJSONString(line)));
}
return out;
}
/**
* 合并舱位上的 departureCharges 与 feeItems(去重:同名只留先出现的)。
* 副作用:无。线程:调用方线程。
*/
static JSONArray mergeAirOptionChargeArrays(JSONObject opt) {
JSONArray out = new JSONArray();
if (opt == null) {
return out;
}
Set<String> seen = new HashSet<>();
for (Object key : new Object[]{"departureCharges", "departure_charges", "charges", "feeItems", "fee_items"}) {
JSONArray part = parseFeeRows(opt.get(String.valueOf(key)));
for (int i = 0; i < part.size(); i++) {
JSONObject ch = part.getJSONObject(i);
if (ch == null) {
continue;
}
String name = firstNonBlank(ch.getString("chargeName"), ch.getString("feeName"), ch.getString("name"));
if (StringUtils.isBlank(name)) {
continue;
}
String dedupe = name.trim().toLowerCase(Locale.ROOT);
if (!seen.add(dedupe)) {
continue;
}
out.add(ch);
}
}
return out;
}
/**
* 明细里没有运输/空运费时,用舱位 unitPrice×计费重或 salePrice 注入一行「空运费」。
* WO202610090014:departureCharges 只有增点/拆板,salePrice=4750.95、计费重=500.1。
*/
static void injectAirFreightLineIfMissing(List<Map<String, String>> lines, JSONObject opt,
String chargeableWeightKg, String currency) {
if (lines == null || opt == null) {
return;
}
for (Map<String, String> line : lines) {
if (line != null && isAirWeightFeeName(line.get("name"))) {
return;
}
}
String unitPrice = firstNonBlank(opt.getString("unitPrice"), opt.getString("unit_price"),
opt.getString("pricePerKg"), opt.getString("freightRate"));
String salePrice = firstNonBlank(opt.getString("salePrice"), opt.getString("sale_price"),
opt.getString("totalSalePrice"), opt.getString("airFreight"), opt.getString("freight"));
String cw = StringUtils.trimToEmpty(chargeableWeightKg);
if (cw.isEmpty() || "-".equals(cw)) {
cw = "";
}
String amount = "";
String price = unitPrice;
if (StringUtils.isNotBlank(unitPrice) && StringUtils.isNotBlank(cw)) {
amount = multiplyDecimal(unitPrice, cw);
} else if (StringUtils.isNotBlank(salePrice) && !"-".equals(salePrice)) {
amount = salePrice;
if (StringUtils.isBlank(price) && StringUtils.isNotBlank(cw)) {
price = divideDecimal(salePrice, cw);
}
}
if (StringUtils.isBlank(amount) || "-".equals(amount)) {
return;
}
Map<String, String> air = new LinkedHashMap<>();
air.put("name", "空运费");
air.put("project", "空运段");
air.put("amount", amount);
if (StringUtils.isNotBlank(price)) {
air.put("unit_price", price);
} else {
air.put("unit_price", amount);
}
if (StringUtils.isNotBlank(cw)) {
air.put("quantity", cw);
air.put("unit", "KG");
}
if (StringUtils.isNotBlank(currency)) {
air.put("currency", currency);
}
lines.add(0, air);
}
private static Object firstNonNull(Object... vals) {
if (vals == null) {
return null;
@@ -4944,43 +5267,146 @@ public final class QuoteTemplateExcelSupport {
return null;
}
/**
* 空运运输费:单价 × 计费重 = 行金额。
* 出单时调用;有单价+数量则重算金额(数值比较,避免 11.00≠11.0 漏算)。
* 空运运输费:单价 × 计费重 = 行金额;缺单位时用询价重量单位(默认 KG)。
* <p>
* 手工报价常只有金额(WO020):无计费重时补 数量=1、单价=金额,避免单价/数量空白。
*/
public static void enrichAirWeightFeeRows(List<Map<String, String>> lines, String chargeableWeightKg) {
enrichAirWeightFeeRows(lines, chargeableWeightKg, null);
}
/**
* @param weightUnit 询价重量单位(如 KG/KGS/公斤);空则按 KG
*/
public static void enrichAirWeightFeeRows(List<Map<String, String>> lines, String chargeableWeightKg,
String weightUnit) {
if (lines == null || lines.isEmpty()) {
return;
}
String chargeable = StringUtils.trimToEmpty(chargeableWeightKg);
if (chargeable.isEmpty() || "-".equals(chargeable)) {
return;
if ("-".equals(chargeable)) {
chargeable = "";
}
String unitDefault = normalizeWeightUnit(weightUnit);
for (Map<String, String> line : lines) {
if (line == null || !isAirWeightFeeName(line.get("name"))) {
continue;
}
// 无 TMS / 手工报价常缺单位:先补单位,再按计费重或金额补数量/单价
if (StringUtils.isBlank(line.get("unit"))) {
line.put("unit", unitDefault);
}
String unitPrice = firstNonBlank(line.get("unit_price"), line.get("unitPrice"));
String qty = StringUtils.trimToEmpty(line.get("quantity"));
String amount = StringUtils.trimToEmpty(line.get("amount"));
String unit = StringUtils.trimToEmpty(line.get("unit"));
if ("-".equals(qty)) {
qty = "";
}
if ("-".equals(amount)) {
amount = "";
}
if (chargeable.isEmpty()) {
// 无计费重:手工只有总额 → 数量 1、单价=金额(WO020 空运费)
if (qty.isEmpty() && !amount.isEmpty()) {
line.put("quantity", "1");
qty = "1";
}
if (unitPrice.isEmpty() && !amount.isEmpty()) {
if ("1".equals(qty) || qty.isEmpty()) {
line.put("unit_price", amount);
} else {
String divided = divideDecimal(amount, qty);
if (!divided.isEmpty()) {
line.put("unit_price", divided);
} else {
line.put("unit_price", amount);
}
}
} else if (!unitPrice.isEmpty() && qty.isEmpty()) {
line.put("quantity", "1");
}
continue;
}
if (qty.isEmpty()) {
line.put("quantity", chargeable);
qty = chargeable;
}
// TMS 常给「板数」;按计费重计价统一 KG
line.put("unit", "KG");
// 有计费重时统一按重量计价单位
line.put("unit", unitDefault);
if (unitPrice.isEmpty() && !amount.isEmpty()) {
line.put("unit_price", amount);
unitPrice = amount;
String divided = divideDecimal(amount, qty);
line.put("unit_price", StringUtils.isNotBlank(divided) ? divided : amount);
unitPrice = line.get("unit_price");
}
if (!unitPrice.isEmpty() && !qty.isEmpty()) {
String product = multiplyDecimal(unitPrice, qty);
if (!product.isEmpty() && (amount.isEmpty() || !sameDecimal(amount, product))) {
line.put("amount", product);
// 已有明确总额时不要用单价×数量覆盖(手工总额优先)
if (amount.isEmpty()) {
line.put("amount", product);
}
}
}
}
// 起运港固定费(增点费等):补单位/数量/单价
enrichFixedAirAddonFeeRows(lines);
}
/**
* 空运起运附加费(增点/操作/文件等):手工常只有金额。
* 补单位=票、数量=1、单价=金额(WO020 增点费)。
*/
public static void enrichFixedAirAddonFeeRows(List<Map<String, String>> lines) {
if (lines == null || lines.isEmpty()) {
return;
}
for (Map<String, String> line : lines) {
if (line == null || isAirWeightFeeName(line.get("name"))) {
continue;
}
String name = StringUtils.trimToEmpty(line.get("name"));
if (name.isEmpty()) {
continue;
}
// 增点费等禁止留在「空运段」(手工/产品报价可能误带 project=空运段)
line.put("project", "起运港");
if (StringUtils.isBlank(line.get("unit"))) {
line.put("unit", "票");
}
String qty = StringUtils.trimToEmpty(line.get("quantity"));
String amount = StringUtils.trimToEmpty(line.get("amount"));
String unitPrice = firstNonBlank(line.get("unit_price"), line.get("unitPrice"));
if (qty.isEmpty() || "-".equals(qty)) {
line.put("quantity", "1");
qty = "1";
}
if ((unitPrice.isEmpty() || "-".equals(unitPrice)) && StringUtils.isNotBlank(amount) && !"-".equals(amount)) {
if ("1".equals(qty)) {
line.put("unit_price", amount);
} else {
String divided = divideDecimal(amount, qty);
line.put("unit_price", StringUtils.isNotBlank(divided) ? divided : amount);
}
}
}
}
/**
* 询价重量单位归一:KG / KGS / 公斤 → KG;空默认 KG。
*/
public static String normalizeWeightUnit(String raw) {
String u = StringUtils.trimToEmpty(raw);
if (u.isEmpty() || "-".equals(u)) {
return "KG";
}
String compact = u.replaceAll("\\s+", "").toUpperCase(Locale.ROOT);
if (compact.contains("公斤") || "KG".equals(compact) || "KGS".equals(compact)
|| "KILOGRAM".equals(compact) || "KILOGRAMS".equals(compact)) {
return "KG";
}
return u;
}
/**
@@ -5003,12 +5429,15 @@ public final class QuoteTemplateExcelSupport {
return feeQuantityOrDefault(quantity, "");
}
public static boolean isAirWeightFeeName(String name) {
String n = StringUtils.trimToEmpty(name);
if (n.isEmpty()) {
return false;
}
if (n.contains("提") || n.contains("送") || n.contains("人工") || n.contains("报关")
// 起运附加费禁止归进「空运段」(WO014/图2:增点费曾误留在空运段样例行)
if (n.contains("增点") || n.contains("拆板") || n.contains("打板") || n.contains("提")
|| n.contains("送") || n.contains("人工") || n.contains("报关")
|| n.contains("操作") || n.contains("文件") || n.contains("杂费")) {
return false;
}
@@ -5035,6 +5464,20 @@ public final class QuoteTemplateExcelSupport {
}
}
/** 除法:空运费总额 ÷ 计费重 → 单价;除数为 0 或非法时返回空串。 */
static String divideDecimal(String left, String right) {
try {
java.math.BigDecimal a = new java.math.BigDecimal(left.trim().replace(",", ""));
java.math.BigDecimal b = new java.math.BigDecimal(right.trim().replace(",", ""));
if (b.compareTo(java.math.BigDecimal.ZERO) == 0) {
return "";
}
return a.divide(b, 4, java.math.RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
} catch (Exception e) {
return "";
}
}
/**
* 金额/数量/单价写成数字,SUM 才能加算;名称等仍写文本。
*/
@@ -22,7 +22,8 @@ import java.util.Locale;
* <p>
* Jeecg MinIO/OSS 上传返回完整 http(s) URL,Windows 上 {@code Paths.get("http://...")}
* 会在冒号处抛 Illegal char &lt;:&gt;。本类先认远程 URL,拉到本地缓存再给 POI / 下载。
* 副作用:写 {@code upload/inquiry/quote-templates/_remote-cache/}。
* 副作用:写 {@code upload/inquiry/quote-templates/_remote-cache/},以及
* {@code upload/inquiry/quote-templates/by-sha/{sha256}.xlsx} 本地镜像(上传时写入,远程损坏时可回退)。
* 线程:跟随调用方 HTTP 线程;不持有全局锁。
*/
@Slf4j
@@ -70,18 +71,75 @@ public class QuoteTemplateObjectStore {
* @return 已存在的本地文件,或从对象存储落地后的缓存文件
*/
public Path resolve(String objectKey) {
return resolve(objectKey, null);
}
/**
* 解析模板文件,并在提供 expectedSha256 时校验内容指纹。
* <p>
* 为何:MinIO 对象偶发被掏空/覆盖后,旧逻辑只要缓存「非空」就直接用,导致空运报价费目名全空。
* 有 sha 时:缓存/远程不匹配则删缓存重拉;仍不匹配则回退 {@code by-sha/{sha}.xlsx}。
*
* @param objectKey 主账 version.object_key(本地相对路径或 http(s) URL)
* @param expectedSha256 版本表记录的 sha256(可空;空则只做路径解析,与历史行为一致)
* @return 可读本地文件
*/
public Path resolve(String objectKey, String expectedSha256) {
if (StringUtils.isBlank(objectKey)) {
throw new IllegalArgumentException("objectKey 空");
}
String key = objectKey.trim();
String want = normalizeSha(expectedSha256);
if (want != null) {
Path bySha = tryByShaMirror(want);
if (bySha != null) {
return bySha;
}
}
Path resolved;
if (isRemoteUrl(key)) {
return fetchRemoteToCache(key);
resolved = fetchRemoteToCache(key, want);
} else {
resolved = tryLocalFile(key);
if (resolved == null) {
throw new IllegalStateException("找不到模板文件: " + key);
}
if (want != null && !shaMatches(resolved, want)) {
Path fallback = tryByShaMirror(want);
if (fallback != null) {
log.warn("本地模板 sha 不匹配,改用 by-sha 镜像 key={}", key);
return fallback;
}
throw new IllegalStateException("模板文件 sha256 不匹配: " + key);
}
}
Path local = tryLocalFile(key);
if (local != null) {
return local;
return resolved;
}
/**
* 上传成功后写一份按内容指纹命名的本地镜像,供远程损坏时回退。
* <p>
* 副作用:写 {@code upload/inquiry/quote-templates/by-sha/{sha256}.xlsx}。
* 失败只记日志,不阻断上传主流程。
*/
public void writeByShaMirror(byte[] fileBytes, String sha256Hex) {
String sha = normalizeSha(sha256Hex);
if (fileBytes == null || fileBytes.length == 0 || sha == null) {
return;
}
try {
Path dir = Paths.get(StringUtils.defaultIfBlank(uploadPath, System.getProperty("java.io.tmpdir")),
BIZ_PATH, "by-sha");
Files.createDirectories(dir);
Path target = dir.resolve(sha + ".xlsx");
Files.write(target, fileBytes);
log.info("模板 by-sha 镜像已写入 sha={} bytes={}", sha.substring(0, 12), fileBytes.length);
} catch (Exception e) {
log.warn("模板 by-sha 镜像写入失败 sha={} err={}", sha, e.getMessage());
}
throw new IllegalStateException("找不到模板文件: " + key);
}
/**
@@ -138,9 +196,11 @@ public class QuoteTemplateObjectStore {
}
/**
* 从 MinIO/OSS 拉到本地缓存。同一 URL 只落一次(文件名带时间戳,一般不会覆盖)。
* 从 MinIO/OSS 拉到本地缓存。同一 URL 缓存文件名 = sha256(url)。
* <p>
* 有 expectedSha 时:已有缓存若指纹不对则删除重拉;重拉后仍不对则回退 by-sha 镜像。
*/
private Path fetchRemoteToCache(String url) {
private Path fetchRemoteToCache(String url, String expectedSha) {
Path cacheDir = Paths.get(StringUtils.defaultIfBlank(uploadPath, System.getProperty("java.io.tmpdir")),
BIZ_PATH, "_remote-cache");
try {
@@ -152,7 +212,11 @@ public class QuoteTemplateObjectStore {
Path cacheFile = cacheDir.resolve(DigestUtils.sha256Hex(url) + ext);
try {
if (Files.isRegularFile(cacheFile) && Files.size(cacheFile) > 0) {
return cacheFile;
if (expectedSha == null || shaMatches(cacheFile, expectedSha)) {
return cacheFile;
}
log.warn("模板远程缓存 sha 不匹配,删除后重拉 file={}", cacheFile.getFileName());
Files.deleteIfExists(cacheFile);
}
} catch (Exception ignored) {
// 读缓存失败则重新拉取
@@ -161,11 +225,66 @@ public class QuoteTemplateObjectStore {
Files.copy(in, cacheFile, StandardCopyOption.REPLACE_EXISTING);
} catch (Exception e) {
log.error("对象存储落地失败 url={}", url, e);
if (expectedSha != null) {
Path fallback = tryByShaMirror(expectedSha);
if (fallback != null) {
log.warn("对象存储读取失败,改用 by-sha 镜像 url={}", url);
return fallback;
}
}
throw new IllegalStateException("对象存储读取失败: " + e.getMessage(), e);
}
if (expectedSha != null && !shaMatches(cacheFile, expectedSha)) {
log.error("对象存储模板 sha256 与版本表不一致 urlHash={}",
DigestUtils.sha256Hex(url).substring(0, 12));
try {
Files.deleteIfExists(cacheFile);
} catch (Exception ignored) {
// ignore
}
Path fallback = tryByShaMirror(expectedSha);
if (fallback != null) {
log.warn("远程模板损坏,改用 by-sha 镜像");
return fallback;
}
throw new IllegalStateException("对象存储模板内容与版本 sha256 不一致");
}
return cacheFile;
}
private Path tryByShaMirror(String sha) {
if (StringUtils.isBlank(sha)) {
return null;
}
Path p = Paths.get(StringUtils.defaultIfBlank(uploadPath, System.getProperty("java.io.tmpdir")),
BIZ_PATH, "by-sha", sha + ".xlsx");
try {
if (Files.isRegularFile(p) && Files.size(p) > 0 && shaMatches(p, sha)) {
return p;
}
} catch (Exception ignored) {
return null;
}
return null;
}
private static String normalizeSha(String sha) {
if (StringUtils.isBlank(sha)) {
return null;
}
String s = sha.trim().toLowerCase(Locale.ROOT);
return s.matches("[0-9a-f]{64}") ? s : null;
}
private static boolean shaMatches(Path file, String expectedSha) {
try {
String actual = DigestUtils.sha256Hex(Files.readAllBytes(file)).toLowerCase(Locale.ROOT);
return expectedSha.equals(actual);
} catch (Exception e) {
return false;
}
}
private InputStream openRemoteStream(String url) {
String[] parts = splitStorageUrl(url, MinioUtil.getMinioUrl());
if (parts != null) {
@@ -11,6 +11,7 @@ import org.jeecg.modules.inquiry.entity.InquiryQuote;
import org.jeecg.modules.inquiry.entity.InquiryTicket;
import org.jeecg.modules.inquiry.mapper.InquiryQuoteMapper;
import org.jeecg.modules.inquiry.service.IInquiryWorkOrderService;
import org.jeecg.modules.inquiry.template.QuoteTemplateExcelSupport;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
@@ -1050,6 +1051,10 @@ public class InquiryTmsCabinService {
item.put("currency", currency);
}
String unit = text(row, "unit", "单位");
// 手工报价常只有金额无单位;空运运输费锁舱必须带 unit(WO007:单位未传 TMS)
if (QuoteTemplateExcelSupport.isAirWeightFeeName(name)) {
unit = QuoteTemplateExcelSupport.normalizeWeightUnit(unit);
}
if (StringUtils.isNotBlank(unit)) {
item.put("unit", unit);
}
@@ -20,9 +20,12 @@ import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
/**
@@ -803,17 +806,13 @@ public class InquiryTmsQueryService {
continue;
}
Map<?, ?> opt = (Map<?, ?>) optObj;
Object charges = first(opt.get("departureCharges"), opt.get("departure_charges"),
opt.get("charges"), opt.get("feeItems"));
if (!(charges instanceof List)) {
continue;
}
// 合并 departureCharges + feeItems,避免只取附加费丢掉空运费
List<Map<?, ?>> charges = mergeAirOptionChargeMaps(opt);
String optCcy = firstText(opt.get("currency"), headerCurrency);
for (Object one : (List<?>) charges) {
if (!(one instanceof Map)) {
for (Map<?, ?> ch : charges) {
if (ch == null) {
continue;
}
Map<?, ?> ch = (Map<?, ?>) one;
String name = firstText(ch.get("chargeName"), ch.get("feeName"), ch.get("name"));
String unitPrice = firstText(ch.get("unitPrice"), ch.get("unit_price"), ch.get("salePrice"), ch.get("amount"));
if (StringUtils.isBlank(name) || StringUtils.isBlank(unitPrice)) {
@@ -830,6 +829,8 @@ public class InquiryTmsQueryService {
// 先把单价写入 amount;运输费会在 enrich 里改成 单价×计费重
addFeeLine(out, name, unitPrice, lineCcy, extras);
}
// departureCharges 只有附加费时,用舱位 salePrice 补空运费(与出单 parse 对齐)
injectAirFreightFeeLineIfMissing(out, opt, headerCurrency);
// 通常只用首个舱位选项的费用明细
if (!out.isEmpty()) {
break;
@@ -838,6 +839,93 @@ public class InquiryTmsQueryService {
return out;
}
/** 合并舱位 departureCharges / feeItems,同名去重。 */
@SuppressWarnings("unchecked")
static List<Map<?, ?>> mergeAirOptionChargeMaps(Map<?, ?> opt) {
List<Map<?, ?>> out = new ArrayList<>();
if (opt == null) {
return out;
}
Set<String> seen = new HashSet<>();
for (Object key : new Object[]{"departureCharges", "departure_charges", "charges", "feeItems", "fee_items"}) {
Object raw = opt.get(key);
if (!(raw instanceof List)) {
continue;
}
for (Object one : (List<?>) raw) {
if (!(one instanceof Map)) {
continue;
}
Map<?, ?> ch = (Map<?, ?>) one;
String name = firstText(ch.get("chargeName"), ch.get("feeName"), ch.get("name"));
if (StringUtils.isBlank(name)) {
continue;
}
String dedupe = name.trim().toLowerCase(Locale.ROOT);
if (!seen.add(dedupe)) {
continue;
}
out.add(ch);
}
}
return out;
}
/**
* 查价投影 fee_lines:明细无运输/空运费时,用舱位 salePrice/unitPrice 注入。
* 与 QuoteTemplateExcelSupport.injectAirFreightLineIfMissing 口径一致。
*/
static void injectAirFreightFeeLineIfMissing(List<Map<String, String>> lines, Map<?, ?> opt, String headerCurrency) {
if (lines == null || opt == null) {
return;
}
for (Map<String, String> line : lines) {
if (line != null && isAirWeightFeeName(line.get("name"))) {
return;
}
}
String unitPrice = firstText(opt.get("unitPrice"), opt.get("unit_price"), opt.get("pricePerKg"), opt.get("freightRate"));
String salePrice = firstText(opt.get("salePrice"), opt.get("sale_price"), opt.get("totalSalePrice"),
opt.get("airFreight"), opt.get("freight"));
String cw = firstText(opt.get("chargeableWeightKg"), opt.get("chargeable_weight_kg"));
String amount = "";
String price = unitPrice;
if (StringUtils.isNotBlank(unitPrice) && StringUtils.isNotBlank(cw)) {
amount = multiplyDecimal(unitPrice, cw);
} else if (StringUtils.isNotBlank(salePrice) && !"-".equals(salePrice)) {
amount = salePrice;
if (StringUtils.isBlank(price) && StringUtils.isNotBlank(cw)) {
try {
java.math.BigDecimal a = new java.math.BigDecimal(salePrice.trim().replace(",", ""));
java.math.BigDecimal b = new java.math.BigDecimal(cw.trim().replace(",", ""));
if (b.compareTo(java.math.BigDecimal.ZERO) != 0) {
price = a.divide(b, 4, java.math.RoundingMode.HALF_UP).stripTrailingZeros().toPlainString();
}
} catch (Exception ignored) {
price = "";
}
}
}
if (StringUtils.isBlank(amount) || "-".equals(amount)) {
return;
}
String ccy = firstText(opt.get("currency"), headerCurrency);
Map<String, String> extras = new LinkedHashMap<>();
putExtra(extras, "project", "空运段");
putExtra(extras, "unit_price", StringUtils.isNotBlank(price) ? price : amount);
if (StringUtils.isNotBlank(cw)) {
putExtra(extras, "quantity", cw);
putExtra(extras, "unit", "KG");
}
// 先 append 再挪到最前:空运段在附加费之前
int before = lines.size();
addFeeLine(lines, "空运费", amount, ccy, extras);
if (lines.size() > before) {
Map<String, String> injected = lines.remove(lines.size() - 1);
lines.add(0, injected);
}
}
/**
* 空运运输费:单价 × 计费重 = 行金额。
* <p>
@@ -2,6 +2,7 @@ package org.jeecg.modules.inquiry.template;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.PrintSetup;
import org.apache.poi.ss.usermodel.Row;
@@ -38,6 +39,31 @@ public class QuoteTemplateFillTest {
assertEquals("14669.60", rows.getJSONObject(0).getString("amount"));
}
@Test
public void manualAirFeesOnlyAmountFillsUnitPriceQtyAndUnit() {
// WO020:手工只有金额 → 空运费补 KG/1/单价;增点费补 票/1/单价
java.util.List<Map<String, String>> lines = new java.util.ArrayList<>();
Map<String, String> air = new HashMap<>();
air.put("name", "空运费");
air.put("amount", "150");
air.put("currency", "CNY");
lines.add(air);
Map<String, String> add = new HashMap<>();
add.put("name", "增点费");
add.put("amount", "200");
add.put("currency", "CNY");
add.put("project", "空运段"); // 误归空运段也要纠正
lines.add(add);
QuoteTemplateExcelSupport.enrichAirWeightFeeRows(lines, "", "KG");
assertEquals("KG", lines.get(0).get("unit"));
assertEquals("1", lines.get(0).get("quantity"));
assertEquals("150", lines.get(0).get("unit_price"));
assertEquals("票", lines.get(1).get("unit"));
assertEquals("1", lines.get(1).get("quantity"));
assertEquals("200", lines.get(1).get("unit_price"));
assertEquals("起运港", lines.get(1).get("project"));
}
@Test
public void parseFeeLinesDoubleEncoded() {
JSONArray rows = QuoteTemplateExcelSupport.parseFeeRows("[{\"name\":\"空运费\",\"amount\":\"14669.60\"}]");
@@ -242,8 +268,8 @@ public class QuoteTemplateFillTest {
}
/**
* 回归:无 TMS 费用行时,费用表「项目=起运港」不得把 pol 写进右侧「费用名称」。
* 对应 WO202609230026 空运协同出单:空运段旁费用名称被写成「广州」。
* 回归:无 TMS 费用行时,不得把 pol 写进费用名称;
* 并保留模板项目骨架「空运段/起运港」(图2),禁止掏空项目列。
*/
@Test
public void airNoTmsMustNotWritePolIntoFeeNameColumn() throws Exception {
@@ -267,13 +293,17 @@ public class QuoteTemplateFillTest {
head.createCell(7).setCellValue("备注");
org.apache.poi.ss.usermodel.Row airSeg = sheet.createRow(16);
airSeg.createCell(0).setCellValue("空运段");
airSeg.createCell(1).setCellValue("");
airSeg.createCell(1).setCellValue("空运费");
org.apache.poi.ss.usermodel.Row polSeg = sheet.createRow(17);
polSeg.createCell(0).setCellValue("起运港");
polSeg.createCell(1).setCellValue("");
org.apache.poi.ss.usermodel.Row otherSeg = sheet.createRow(18);
polSeg.createCell(1).setCellValue("操作费");
org.apache.poi.ss.usermodel.Row destSeg = sheet.createRow(18);
destSeg.createCell(0).setCellValue("目的港");
destSeg.createCell(1).setCellValue("目的港派送费");
org.apache.poi.ss.usermodel.Row otherSeg = sheet.createRow(19);
otherSeg.createCell(0).setCellValue("其他");
otherSeg.createCell(1).setCellValue("");
otherSeg.createCell(1).setCellValue("*其他杂费");
otherSeg.createCell(7).setCellValue("发生时收取");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
@@ -300,13 +330,22 @@ public class QuoteTemplateFillTest {
b16 = fmt.formatCellValue(sheet.getRow(16).getCell(1));
b17 = fmt.formatCellValue(sheet.getRow(17).getCell(1));
b18 = fmt.formatCellValue(sheet.getRow(18).getCell(1));
String b19 = fmt.formatCellValue(sheet.getRow(19).getCell(1));
String h19 = fmt.formatCellValue(sheet.getRow(19).getCell(7));
String cargoPol = fmt.formatCellValue(sheet.getRow(5).getCell(1));
assertTrue(cargoPol.contains("广州"), "货物区起运港应写入广州,实际=" + cargoPol);
assertFalse(b16.contains("广州"), "空运段旁费用名称不应是起运港,实际=" + b16);
assertFalse(b17.contains("广州"), "起运港项目旁费用名称不应是起运港,实际=" + b17);
assertFalse(b18.contains("广州"), "其他旁费用名称不应是起运港,实际=" + b18);
assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0)));
assertFalse(b16.contains("广州"), "费用名称不应是起运港,实际=" + b16);
assertFalse(b17.contains("广州"), "费用名称不应是起运港,实际=" + b17);
assertFalse(b18.contains("广州"), "费用名称不应是起运港,实际=" + b18);
// 无 fee_rows:项目骨架保留;样例费目名/备注清掉(有费用的才显示行)
assertEquals("空运段", fmt.formatCellValue(sheet.getRow(16).getCell(0)));
assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0)));
assertEquals("目的港", fmt.formatCellValue(sheet.getRow(18).getCell(0)));
assertEquals("", b16.trim(), "无费用时空运费样例名应清空,实际=" + b16);
assertEquals("", b17.trim(), "无费用时操作费样例名应清空,实际=" + b17);
assertEquals("", b18.trim(), "无费用时目的港派送费样例名应清空,实际=" + b18);
assertEquals("", b19.trim(), "无费用时其他杂费样例名应清空,实际=" + b19);
assertEquals("", h19.trim(), "无费用时样例备注应清空,实际=" + h19);
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
@@ -857,6 +896,104 @@ public class QuoteTemplateFillTest {
assertEquals("16670", QuoteTemplateExcelSupport.feeQuantityOrDefault("16670", "运输费"));
}
/**
* WO014:departureCharges 只有增点/拆板时,必须注入空运费,且项目列归类正确。
*/
@Test
public void parseAirDepartureChargesInjectsFreightAndProjects() {
String airOptions = "["
+ "{\"optionNo\":\"AIR-OPT01\",\"salePrice\":4750.95,\"currency\":\"CNY\","
+ "\"chargeableWeightKg\":500.1,"
+ "\"departureCharges\":["
+ "{\"chargeName\":\"增点费\",\"unitPrice\":100.0,\"unit\":\"点\",\"currency\":\"CNY\"},"
+ "{\"chargeName\":\"拆板费\",\"unitPrice\":100.0,\"unit\":\"板数\",\"currency\":\"USD\"}"
+ "]}]";
JSONArray rows = QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
airOptions, null, "500.1", "CNY");
assertEquals(3, rows.size());
assertEquals("空运费", rows.getJSONObject(0).getString("name"));
assertEquals("空运段", rows.getJSONObject(0).getString("project"));
assertEquals("4750.95", rows.getJSONObject(0).getString("amount"));
assertEquals("增点费", rows.getJSONObject(1).getString("name"));
assertEquals("起运港", rows.getJSONObject(1).getString("project"));
assertEquals("拆板费", rows.getJSONObject(2).getString("name"));
assertEquals("起运港", rows.getJSONObject(2).getString("project"));
}
/**
* 映射未标 project 列时,表头「项目」仍要写入,增点费不能留在模板样例「空运段」。
*/
@Test
public void airFeeProjectColumnFilledFromHeaderWhenMappingOmitsIt() throws Exception {
Path src = Files.createTempFile("quote-air-proj-src", ".xlsx");
Path out = Files.createTempFile("quote-air-proj-out", ".xlsx");
try (Workbook wb = new XSSFWorkbook()) {
Sheet sheet = wb.createSheet("Sheet2");
org.apache.poi.ss.usermodel.Row head = sheet.createRow(15);
head.createCell(0).setCellValue("项目");
head.createCell(1).setCellValue("费用名称");
head.createCell(2).setCellValue("单位");
head.createCell(3).setCellValue("币别");
head.createCell(4).setCellValue("单价");
head.createCell(5).setCellValue("数量");
head.createCell(6).setCellValue("金额");
org.apache.poi.ss.usermodel.Row r1 = sheet.createRow(16);
r1.createCell(0).setCellValue("空运段");
r1.createCell(1).setCellValue("样例费");
r1.createCell(6).setCellValue(1.0);
org.apache.poi.ss.usermodel.Row r2 = sheet.createRow(17);
r2.createCell(0).setCellValue("起运港");
r2.createCell(1).setCellValue("样例2");
r2.createCell(6).setCellValue(1.0);
org.apache.poi.ss.usermodel.Row total = sheet.createRow(22);
total.createCell(0).setCellValue("预估费用");
total.createCell(6).setCellFormula("SUM(G17:G22)");
try (java.io.OutputStream os = Files.newOutputStream(src)) {
wb.write(os);
}
}
JSONObject mapping = new JSONObject();
mapping.put("fields", new JSONObject());
JSONObject region = new JSONObject(true);
region.put("id", "air_fee_detail");
region.put("anchor", "Sheet2!A17");
region.put("stopAtText", java.util.Collections.singletonList("预估费用"));
JSONObject cols = new JSONObject(true);
cols.put("name", "B");
cols.put("unit", "C");
cols.put("currency", "D");
cols.put("unit_price", "E");
cols.put("quantity", "F");
cols.put("amount", "G");
// 故意不写 project,验证 mergeFeeColumnsFromHeader 补 A 列
region.put("columns", cols);
mapping.put("feeRegions", new JSONArray(java.util.Collections.singletonList(region)));
JSONObject facts = new JSONObject(true);
facts.put("total_amount", "5001");
facts.put("currency", "CNY");
facts.put("fee_rows", QuoteTemplateExcelSupport.parseAirDepartureChargeRows(
"[{\"salePrice\":4750.95,\"currency\":\"CNY\",\"chargeableWeightKg\":500.1,"
+ "\"departureCharges\":["
+ "{\"chargeName\":\"增点费\",\"unitPrice\":100.0,\"unit\":\"点\",\"currency\":\"CNY\"},"
+ "{\"chargeName\":\"拆板费\",\"unitPrice\":100.0,\"unit\":\"板数\",\"currency\":\"USD\"}"
+ "]}]",
null, "500.1", "CNY"));
QuoteTemplateExcelSupport.fillWorkbook(src, out, mapping, facts);
try (Workbook got = new XSSFWorkbook(Files.newInputStream(out))) {
Sheet sheet = got.getSheet("Sheet2");
DataFormatter fmt = new DataFormatter();
assertEquals("空运段", fmt.formatCellValue(sheet.getRow(16).getCell(0)));
assertEquals("空运费", fmt.formatCellValue(sheet.getRow(16).getCell(1)));
assertEquals("起运港", fmt.formatCellValue(sheet.getRow(17).getCell(0)));
assertEquals("增点费", fmt.formatCellValue(sheet.getRow(17).getCell(1)));
assertEquals("起运港", fmt.formatCellValue(sheet.getRow(18).getCell(0)));
assertEquals("拆板费", fmt.formatCellValue(sheet.getRow(18).getCell(1)));
} finally {
Files.deleteIfExists(src);
Files.deleteIfExists(out);
}
}
/**
* 出单后必须 A4 水平居中,抬头行高够放下中英文公司名。
* 否则转 PDF 会顶部裁字、整页偏左。
@@ -42,4 +42,11 @@ public class QuoteTemplateObjectStoreTest {
public void splitRejectsBlank() {
assertNull(QuoteTemplateObjectStore.splitStorageUrl(" ", "http://127.0.0.1:9000/"));
}
@Test
public void remoteUrlStillDetectedAfterShaOverload() {
// 回归:增加 resolve(objectKey, sha) 后,URL 识别不能回退成 Paths.get
assertTrue(QuoteTemplateObjectStore.isRemoteUrl(
"http://127.0.0.1:9000/inquiry-robot/inquiry/quote-templates/空运到门报价单模板_1790069791709.xlsx"));
}
}
@@ -193,6 +193,30 @@ public class InquiryTmsCabinServiceTest {
java.util.List<Map<String, Object>> fees =
(java.util.List<Map<String, Object>>) req.get("feeItems");
assertEquals("空运费", fees.get(0).get("feeName"));
// 空运费缺单位时锁舱补 KG(WO007)
assertEquals("KG", fees.get(0).get("unit"));
}
@Test
public void manualFeeLinesWithoutUnitGetsKgOnLock() {
Map<String, Object> quote = new HashMap<>();
quote.put("source", "PRODUCT");
Map<String, Object> line = new HashMap<>();
line.put("name", "空运费");
line.put("unit_price", "10.00");
line.put("quantity", "5063");
line.put("amount", "50630");
line.put("currency", "CNY");
quote.put("fee_lines", java.util.Arrays.asList(line));
Map<String, Object> req = new HashMap<>();
InquiryTmsCabinService.fillLockQuoteExtras(req, quote, new HashMap<>());
@SuppressWarnings("unchecked")
java.util.List<Map<String, Object>> fees =
(java.util.List<Map<String, Object>>) req.get("feeItems");
assertNotNull(fees);
assertEquals(1, fees.size());
assertEquals("空运费", fees.get(0).get("feeName"));
assertEquals("KG", fees.get(0).get("unit"));
}
@Test
@@ -1,6 +1,6 @@
<script setup lang="ts">
/**
* 询价后台列表分页条:总数 + 每页条数 + 页码。
* 询价后台列表分页条:整组靠右,总数紧挨在「每页」左边,页码在后。
* 样式对齐 admin-fidelity.css 的 .pagination;翻页只发事件,不请求接口。
*/
import { computed } from "vue";
@@ -87,13 +87,13 @@ function onSizeChange(e: Event) {
<template>
<div class="pagination admin-pagination">
<span class="admin-pagination__total">
共 <b>{{ total }}</b> {{ unit }}
<span v-if="total > 0" class="admin-pagination__range">
(第 {{ pageNo }} / {{ totalPages }} 页)
</span>
</span>
<div class="admin-pagination__right">
<span class="admin-pagination__total">
共 <b>{{ total }}</b> {{ unit }}
<span v-if="total > 0" class="admin-pagination__range">
(第 {{ pageNo }} / {{ totalPages }} 页)
</span>
</span>
<label class="admin-pagination__size">
每页
<select :value="pageSize" :disabled="disabled" @change="onSizeChange">
@@ -127,6 +127,11 @@ function onSizeChange(e: Event) {
gap: 10px;
flex-wrap: wrap;
justify-content: flex-end;
margin-left: auto;
}
.admin-pagination__total {
white-space: nowrap;
}
.admin-pagination__size {
@@ -918,6 +918,10 @@ body {
color: #155eef !important;
}
.pagination.admin-pagination {
justify-content: flex-end !important;
}
.pagination .pages {
display: inline-flex;
align-items: center;
@@ -830,7 +830,7 @@ async function downloadPreviewOrOriginal() {
</tbody>
</table>
</div>
<div class="pagination">
<div class="pagination admin-pagination">
<span>
共 <b style="color: #0f172a">{{ filtered.length }}</b> 个模板
</span>