🐛 多货物运单的货名、汇总与合计重量按货物明细算

一张运单可以录多条货物,明细落在 goods_json,主表 cargo_name / cargo_type / quantity_unit
只是首行、quantity 是各行合计。列表与详情原先只读主表字段,第二种及以后的货物整条丢失
(如 YD-20261018-0005 只显示「阿什顿」,漏掉「显卡050」),并把「20堆 + 50方」算成「70堆」。

货物明细的取法收进 WaybillCargoSupport 一处:goods_json 能解析出货物就以它为准,
解析不出再回退主表(一格多名的历史写法按同一口径摊平)。

- 运次读服务的段一补取 goods_json:卡片货名与汇总都靠它,少取就只能拿首行
- 货名、货物汇总、合计重量改按货物行算;单位不一致时合计留空,不把汇总串冒充成重量
- 站点货物条目一张子单可出多条,并带上各自的货名与数量单位
- 运次级与子单级都补 cargoNames / cargoTypes 清单,供小程序缩写与展示
This commit is contained in:
2026-10-18 22:06:59 +08:00
parent 04389cd9d5
commit 6dc6932f39
9 changed files with 467 additions and 66 deletions
@@ -66,9 +66,12 @@ public class AdminWaybillDetailVO implements Serializable {
@Schema(description = "卸货地址")
private String unloadAddress;
@Schema(description = "货物名称")
@Schema(description = "货物名称:运单上录入的全部货名以「、」连接")
private String cargoName;
@Schema(description = "货物名称清单:运单上录入的全部货物,同名的只留一次")
private List<String> cargoNames;
@Schema(description = "货物数量(带单位)")
private String cargoQuantity;
@@ -93,12 +93,18 @@ public class TripVO implements Serializable {
@Schema(description = "运输类型")
private String transportType;
@Schema(description = "货物名称")
@Schema(description = "货物名称:整趟车全部货名以「、」连接,一格多名的历史写法已摊平")
private String cargoName;
@Schema(description = "货物类型")
@Schema(description = "货物名称清单:整趟车全部货物,同名的只留一次,保持子单与录入顺序")
private List<String> cargoNames;
@Schema(description = "货物类型:整趟车全部类型以「、」连接")
private String cargoType;
@Schema(description = "货物类型清单:整趟车全部货物类型,同类型的只留一次")
private List<String> cargoTypes;
@Schema(description = "发货地址")
private String departureAddress;
@@ -183,12 +189,18 @@ public class TripVO implements Serializable {
@Schema(description = "项目名称")
private String projectName;
@Schema(description = "货物名称")
@Schema(description = "货物名称:运单上录入的全部货名以「、」连接,一格多名的历史写法已摊平")
private String cargoName;
@Schema(description = "货物类型")
@Schema(description = "货物名称清单:运单上录入的全部货物,同名的只留一次")
private List<String> cargoNames;
@Schema(description = "货物类型:主表首行的类型,完整清单见 cargoTypes")
private String cargoType;
@Schema(description = "货物类型清单:运单上录入的全部货物类型,同类型的只留一次")
private List<String> cargoTypes;
@Schema(description = "数量")
private BigDecimal quantity;
@@ -46,13 +46,14 @@ import org.springblade.transport.pojo.dto.PunchPhotoDTO;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.ExceptionDisposal;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.CertificationGate;
import org.springblade.transport.support.LiveProcessConfigSupport;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
import org.springblade.transport.pojo.entity.WaybillNodePunch;
import org.springblade.transport.support.WaybillCargoSupport;
import org.springblade.transport.pojo.vo.DriverEnrouteRecordVO;
import org.springblade.transport.pojo.vo.DriverNodePunchVO;
import org.springblade.transport.pojo.vo.DriverPunchNodeVO;
@@ -973,9 +974,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
card.setToName(Func.toStrWithEmpty(waybill.getArrivalName(), Func.toStr(waybill.getArrivalAddress(), "")));
card.setFromAddress(Func.toStrWithEmpty(waybill.getDepartureAddress(), card.getFromName()));
card.setToAddress(Func.toStrWithEmpty(waybill.getArrivalAddress(), card.getToName()));
card.setCargoNames(splitCargoNames(waybill.getCargoName()));
card.setCargoCategory(Func.toStr(waybill.getCargoType(), ""));
card.setWeight(formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
// 货名取运单上录入的全部货物:主表 cargo_name 只落了第一行,直接拿它会漏掉这张票的其余货物
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
card.setCargoNames(cargoNames);
card.setCargoCategory(cargos.stream()
.map(WaybillCargoSupport.Cargo::cargoType)
.filter(Func::isNotEmpty)
.distinct()
.collect(Collectors.joining("、")));
card.setWeight(formatCargoWeight(cargos, formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())));
card.setStatus(toAppStatus(waybill.getBusinessStatus()));
String createTime = formatDateTime(waybill.getCreateTime());
card.setCreateTime(createTime);
@@ -992,7 +1000,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
card.setRejectReason(waybill.getDriverRejectReason());
// 详情页字段(列表也可带上,体积很小)
String cargoName = Func.toStr(waybill.getCargoName(), "");
String cargoName = String.join("、", cargoNames);
String weightText = card.getWeight();
card.setCargoName(cargoName);
card.setPickupAddress(card.getFromAddress());
@@ -1106,21 +1114,31 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
card.setStationCount(trip.getStationCount());
card.setPickupAddress(card.getFromAddress());
card.setUnloadAddress(card.getToAddress());
// 货物名可能是一格里的多个名字,按既有拆分口径摊平后再去重
List<String> cargoNames = children.stream()
.flatMap(child -> splitCargoNames(child.getCargoName()).stream())
.distinct()
.collect(Collectors.toList());
// 货名取运次上算好的整趟车清单:它按子单录入的货物明细摊平去重,主表 cargo_name 只是首行。
// 运次清单为空时按子单卡片自己的货名兜底,不让卡片上的货物行整个消失
List<String> cargoNames = trip.getCargoNames() == null ? List.of() : trip.getCargoNames();
if (cargoNames.isEmpty()) {
cargoNames = children.stream()
.flatMap(child -> splitCargoNames(child.getCargoName()).stream())
.distinct()
.collect(Collectors.toList());
}
card.setCargoNames(cargoNames);
card.setCargoName(String.join("、", cargoNames));
card.setCargoCategory(children.stream()
.map(TripVO.TripWaybillVO::getCargoType)
.filter(Func::isNotEmpty)
.distinct()
.collect(Collectors.joining("、")));
List<String> cargoTypes = trip.getCargoTypes() == null ? List.of() : trip.getCargoTypes();
if (cargoTypes.isEmpty()) {
cargoTypes = children.stream()
.map(TripVO.TripWaybillVO::getCargoType)
.filter(Func::isNotEmpty)
.distinct()
.collect(Collectors.toList());
}
card.setCargoCategory(String.join("、", cargoTypes));
card.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
// 合计重量只在全部货物同单位时才有值;单位不一致时留空,卡片上已经由货物汇总逐条列出,
// 拿汇总串冒充重量会让「货名 + 重量」两栏把同一段文字打印两遍
String weight = trip.getTotalQuantity() == null
? card.getCargoSummary()
? ""
: formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
card.setWeight(weight);
card.setCargoQuantity(weight);
@@ -1895,16 +1913,40 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
};
}
/**
* 一格多名的历史写法(顿号、逗号分隔)摊平成货名清单,与货物明细同一个拆分口径
*/
private List<String> splitCargoNames(String cargoName) {
if (Func.isEmpty(cargoName)) {
return Collections.emptyList();
List<String> names = WaybillCargoSupport.splitCargoNames(cargoName);
return names.isEmpty() ? List.of() : names;
}
/**
* 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计
* <p>
* 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。
* 拿 goodsJson 算不出合计(有货物没填数量或单位)时,退回主表那份汇总值——宁可显示一个来源不明的数,
* 也不要让卡片上的重量凭空消失。
*/
private String formatCargoWeight(List<WaybillCargoSupport.Cargo> cargos, String fallback) {
if (cargos.isEmpty()) {
return fallback;
}
String normalized = cargoName.replace(",", ",").replace("、", ",");
List<String> names = Func.toStrList(",", normalized).stream()
.map(String::trim)
.filter(Func::isNotEmpty)
.collect(Collectors.toList());
return names.isEmpty() ? List.of(cargoName.trim()) : names;
String unit = null;
BigDecimal total = BigDecimal.ZERO;
for (WaybillCargoSupport.Cargo cargo : cargos) {
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
return fallback;
}
if (unit == null) {
unit = cargoUnit;
} else if (!unit.equals(cargoUnit)) {
return "";
}
total = total.add(cargo.quantity());
}
return formatWeight(total, unit);
}
private String formatWeight(BigDecimal quantity, String unit) {
@@ -60,6 +60,7 @@ import org.springblade.transport.service.IRiskDisposalService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.WaybillCargoSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
@@ -428,6 +429,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
detail.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
detail.setCargoName(Func.toStr(trip.getCargoName(), ""));
detail.setCargoNames(trip.getCargoNames() == null ? List.of() : trip.getCargoNames());
detail.setCargoQuantity(weight);
detail.setWeight(weight);
detail.setTotalWeight(weight);
@@ -757,9 +759,12 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
detail.setPickupAddress(fromAddr);
detail.setUnloadAddress(toAddr);
String cargo = Func.toStr(waybill.getCargoName(), "");
String weight = formatWeight(waybill.getQuantity(), waybill.getQuantityUnit());
detail.setCargoName(cargo);
// 货名与合计重量取运单上录入的全部货物:主表 cargo_name 只是首行、quantity 是各行合计而单位只是首行的
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
detail.setCargoName(String.join("、", cargoNames));
detail.setCargoNames(cargoNames);
String weight = cargoWeight(cargos, formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
detail.setCargoQuantity(weight);
detail.setWeight(weight);
detail.setTotalWeight(weight);
@@ -997,6 +1002,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
return Func.isBlank(unit) ? qty : qty + unit;
}
/**
* 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计
* <p>
* 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。
* 拿明细算不出合计(有货物没填数量或单位)时退回主表那份汇总值,不让重量凭空消失。
*/
private String cargoWeight(List<WaybillCargoSupport.Cargo> cargos, String fallback) {
if (cargos.isEmpty()) {
return fallback;
}
String unit = null;
BigDecimal total = BigDecimal.ZERO;
for (WaybillCargoSupport.Cargo cargo : cargos) {
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
return fallback;
}
if (unit == null) {
unit = cargoUnit;
} else if (!unit.equals(cargoUnit)) {
return "";
}
total = total.add(cargo.quantity());
}
return formatWeight(total, unit);
}
private String formatLocalDate(LocalDate date) {
if (date == null) {
return "";
@@ -40,6 +40,7 @@ import org.springblade.transport.service.IProcessConfigService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.LiveProcessConfigSupport;
import org.springblade.transport.support.WaybillCargoSupport;
import org.springblade.transport.support.WaybillProcessSupport;
import org.springframework.stereotype.Service;
@@ -222,13 +223,16 @@ public class TripQueryServiceImpl implements ITripQueryService {
* 段一:一次取回该收口与行级筛选命中的可见运单行
* <p>
* 只 select 拼装与分档用得上的列:运单表上有几张大 JSON 列,整行拖回不值得。
* 可见口径与运单列表同一条:状态进运次的才取,被该司机拒单的不取。
* goods_json 必须随行取回——卡片上的货名、货物汇总与合计重量都以它为准,一张运单可以录多条货物,
* 主表 cargo_name 只落了第一行。可见口径与运单列表同一条:状态进运次的才取,被该司机拒单的不取。
*/
private List<Waybill> visibleRows(TripQueryVO condition) {
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
.select(Waybill::getId, Waybill::getLoadingNo, Waybill::getWaybillNo, Waybill::getBusinessStatus,
Waybill::getDriverAcceptStatus, Waybill::getProcessJson, Waybill::getProjectId, Waybill::getProjectName,
Waybill::getCargoName, Waybill::getCargoType, Waybill::getQuantity, Waybill::getQuantityUnit,
// 多货物运单的主表字段只有首行,货物明细只能从 goods_json 拿
Waybill::getGoodsJson,
Waybill::getDepartureName, Waybill::getDepartureAddress, Waybill::getArrivalName,
Waybill::getArrivalAddress, Waybill::getDriverName, Waybill::getDriverPhone, Waybill::getVehicleNo,
Waybill::getCustomerName, Waybill::getTransportType, Waybill::getTaskRemark,
@@ -587,21 +591,47 @@ public class TripQueryServiceImpl implements ITripQueryService {
if (withPayload) {
trip.setStations(stations);
}
List<WaybillCargoSupport.Cargo> cargos = children.stream()
.flatMap(child -> WaybillCargoSupport.of(child).stream())
.toList();
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
// 运次级货名覆盖整趟车:主表 cargo_name 只是首行,配载单的 cargo_name 也是派单时的快照
trip.setCargoNames(cargoNames);
if (!cargoNames.isEmpty()) {
trip.setCargoName(String.join("、", cargoNames));
}
List<String> cargoTypes = cargos.stream()
.map(WaybillCargoSupport.Cargo::cargoType)
.filter(Func::isNotEmpty)
.distinct()
.toList();
trip.setCargoTypes(cargoTypes);
if (!cargoTypes.isEmpty()) {
trip.setCargoType(String.join("、", cargoTypes));
}
trip.setCargoSummary(cargoSummary(children));
fillTotalQuantity(trip, children);
}
/**
* 货物汇总:同名货物合并且单位一致时取数量合计,没有数量的只列货名
* <p>
* 一条运单可以有多条货物,明细在 goods_json;主表 cargo_name 只落了第一行,拿它汇总等于把其余货物丢掉。
*/
private String cargoSummary(List<Waybill> children) {
Map<String, Map<String, BigDecimal>> grouped = new LinkedHashMap<>();
for (Waybill child : children) {
String name = Func.toStrWithEmpty(child.getCargoName(), "货物");
String unit = Func.toStr(child.getQuantityUnit(), "");
Map<String, BigDecimal> byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>());
BigDecimal quantity = child.getQuantity() == null ? BigDecimal.ZERO : child.getQuantity();
byUnit.merge(unit, quantity, BigDecimal::add);
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(child);
if (cargos.isEmpty()) {
continue;
}
for (WaybillCargoSupport.Cargo cargo : cargos) {
String name = cargo.displayName();
String unit = Func.toStr(cargo.quantityUnit(), "");
Map<String, BigDecimal> byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>());
BigDecimal quantity = cargo.quantity() == null ? BigDecimal.ZERO : cargo.quantity();
byUnit.merge(unit, quantity, BigDecimal::add);
}
}
List<String> parts = new ArrayList<>();
grouped.forEach((name, byUnit) -> byUnit.forEach((unit, quantity) ->
@@ -610,24 +640,31 @@ public class TripQueryServiceImpl implements ITripQueryService {
}
/**
* 合计重量:只有全部子运单同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出)
* 合计重量:只有全部货物同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出)
* <p>
* 按货物行算而不是按运单行算:一张运单里的多条货物可能是不同的单位与数量,
* 主表 quantity 是这些行的合计、quantity_unit 只是首行的单位,拿它当合计会把「20堆 + 50方」说成「70堆」。
*/
private void fillTotalQuantity(TripVO trip, List<Waybill> children) {
String unit = null;
BigDecimal total = BigDecimal.ZERO;
boolean any = false;
for (Waybill child : children) {
String childUnit = Func.toStr(child.getQuantityUnit(), "");
if (child.getQuantity() == null || Func.isEmpty(childUnit)) {
return;
for (WaybillCargoSupport.Cargo cargo : WaybillCargoSupport.of(child)) {
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
return;
}
if (unit == null) {
unit = cargoUnit;
} else if (!unit.equals(cargoUnit)) {
return;
}
total = total.add(cargo.quantity());
any = true;
}
if (unit == null) {
unit = childUnit;
} else if (!unit.equals(childUnit)) {
return;
}
total = total.add(child.getQuantity());
}
if (unit == null) {
if (!any || unit == null) {
return;
}
trip.setTotalQuantity(total);
@@ -648,7 +685,15 @@ public class TripQueryServiceImpl implements ITripQueryService {
child.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson()));
child.setProjectId(waybill.getProjectId());
child.setProjectName(waybill.getProjectName());
child.setCargoName(waybill.getCargoName());
// 货名按运单上实际录入的货物给全:主表 cargo_name 只是首行,多货物运单会漏掉其余货物
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
child.setCargoName(String.join("、", WaybillCargoSupport.cargoNames(cargos)));
child.setCargoNames(WaybillCargoSupport.cargoNames(cargos));
child.setCargoTypes(cargos.stream()
.map(WaybillCargoSupport.Cargo::cargoType)
.filter(Func::isNotEmpty)
.distinct()
.toList());
child.setCargoType(waybill.getCargoType());
child.setQuantity(waybill.getQuantity());
child.setQuantityUnit(waybill.getQuantityUnit());
@@ -27,6 +27,7 @@ import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.LoadingManage;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.vo.TripStationVO;
import org.springblade.transport.support.WaybillCargoSupport;
import java.util.ArrayList;
import java.util.LinkedHashMap;
@@ -238,26 +239,51 @@ final class TripStationAssembler {
return byId.get(waybillIds.get(order - 1));
}
/**
* 一条子运单在该站的货物条目:运单上录了几条货物就出几条,同一张运单的多条货物不能被折成一行,
* 否则站点上看不出这票到底装/卸了哪几种货。去重键含货名,站点标记重复出现时不会重复列出。
* <p>
* 一条货物都没录的运单仍出一行(货名为空):站点上的「这站有这张票」由这一行承载,
* 只按货名判断有没有货物会让没填货名的单在任务安排里凭空消失。
*/
private static void addCargo(List<TripStationVO.TripCargoVO> cargos, Waybill child, String operation) {
if (child == null) {
return;
}
String waybillId = child.getId() == null ? null : String.valueOf(child.getId());
boolean exists = cargos.stream().anyMatch(cargo ->
Objects.equals(cargo.getWaybillId(), waybillId) && Objects.equals(cargo.getOperation(), operation));
if (exists) {
List<WaybillCargoSupport.Cargo> items = WaybillCargoSupport.of(child);
if (items.isEmpty()) {
// 一条货物都没录:站点条目仍要有这一行,否则任务安排里这票凭空消失
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
cargo.setWaybillId(waybillId);
cargo.setWaybillNo(child.getWaybillNo());
cargo.setCargoName("");
cargo.setCustomerName(child.getCustomerName());
cargo.setOperation(operation);
cargo.setProjectName(child.getProjectName());
cargos.add(cargo);
return;
}
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
cargo.setWaybillId(waybillId);
cargo.setWaybillNo(child.getWaybillNo());
cargo.setCargoName(child.getCargoName());
cargo.setCustomerName(child.getCustomerName());
cargo.setQuantity(child.getQuantity());
cargo.setQuantityUnit(child.getQuantityUnit());
cargo.setOperation(operation);
cargo.setProjectName(child.getProjectName());
cargos.add(cargo);
for (WaybillCargoSupport.Cargo item : items) {
String cargoName = item.displayName();
boolean exists = cargos.stream().anyMatch(cargo ->
Objects.equals(cargo.getWaybillId(), waybillId)
&& Objects.equals(cargo.getOperation(), operation)
&& Objects.equals(cargo.getCargoName(), cargoName));
if (exists) {
continue;
}
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
cargo.setWaybillId(waybillId);
cargo.setWaybillNo(child.getWaybillNo());
cargo.setCargoName(cargoName);
cargo.setCustomerName(child.getCustomerName());
cargo.setQuantity(item.quantity());
cargo.setQuantityUnit(item.quantityUnit());
cargo.setOperation(operation);
cargo.setProjectName(child.getProjectName());
cargos.add(cargo);
}
}
private static void fillOperation(TripStationVO station, boolean load, boolean unload) {
@@ -0,0 +1,219 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.Waybill;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 运单货物明细的取法,只此一处。
* <p>
* 一张运单可以录多条货物,明细落在 {@code goods_json};主表的 {@code cargo_name} / {@code cargo_type} /
* {@code quantity} 只是给列表与报表用的首行汇总(数量是各行合计),拿它当货物清单会把其余货物丢掉。
* 所以「这张运单装了什么」一律以 goods_json 为准,主表字段只在 goods_json 缺失或解析不出任何货名时兜底。
* <p>
* 货名也可能是主表里一格多名的历史写法(顿号、逗号分隔),按同一口径摊平。
*/
public final class WaybillCargoSupport {
/** 货物没有填名称时的展示名:宁可让司机看到「货物」,也不要出现空行 */
private static final String FALLBACK_NAME = "货物";
/** 货名分隔符:历史数据里顿号、中英文逗号都出现过 */
private static final String NAME_SEPARATOR_REGEX = "[、,,]";
/** 明细里可能承载数量的字段,按优先级取第一个有值的 */
private static final String[] QUANTITY_KEYS = {"quantity", "cargoQuantity", "goodsQuantity"};
/** 明细里可能承载单位的字段,按优先级取第一个有值的 */
private static final String[] UNIT_KEYS = {"quantityUnit", "goodsQuantityUnit", "unit"};
/** 明细里可能承载名称的字段,按优先级取第一个有值的 */
private static final String[] NAME_KEYS = {"cargoName", "goodsName"};
/** 明细里可能承载类型的字段,按优先级取第一个有值的 */
private static final String[] TYPE_KEYS = {"cargoType", "goodsType"};
private WaybillCargoSupport() {
}
/**
* 一张运单的货物条目:goods_json 能解析出货物就用它,否则回退到主表的货物字段。
* <p>
* 每条明细至少出一条:货名、数量都为空的行也保留,让「这张票录了一条空货物」与「这条货物没填数量」可区分。
*/
public static List<Cargo> of(Waybill waybill) {
if (waybill == null) {
return List.of();
}
List<Cargo> detail = parseDetail(waybill.getGoodsJson());
if (!detail.isEmpty()) {
return detail;
}
return fallback(waybill);
}
/**
* 货名清单:同名的只留一次,保持录入顺序;档口与报表需要横向摊平时用它
*/
public static List<String> cargoNames(List<Cargo> cargos) {
List<String> names = new ArrayList<>();
for (Cargo cargo : cargos) {
String name = Func.toStr(cargo.cargoName(), "").trim();
if (Func.isNotEmpty(name) && !names.contains(name)) {
names.add(name);
}
}
return names;
}
/**
* 解析 goods_json 为货物条目;字段缺失、JSON 非法或解析不出任何条目都返回空集合,由调用方兜底
*/
private static List<Cargo> parseDetail(String goodsJson) {
if (Func.isEmpty(goodsJson)) {
return List.of();
}
List<Map> rows;
try {
rows = JsonUtil.parseArray(goodsJson, Map.class);
} catch (Exception exception) {
// 历史脏数据:JSON 坏了就当没有明细,回退主表字段,不让列表与详情因一条脏数据打不开
return List.of();
}
if (Func.isEmpty(rows)) {
return List.of();
}
List<Cargo> cargos = new ArrayList<>(rows.size());
for (Map row : rows) {
if (row == null) {
continue;
}
cargos.add(new Cargo(
firstText(row, NAME_KEYS),
firstText(row, TYPE_KEYS),
quantityOf(row),
firstText(row, UNIT_KEYS)));
}
return cargos;
}
/**
* 回退:主表货名按历史分隔符摊平,每一段配同一个数量与单位(主表数量是各行合计,摊到哪一段都不准,
* 故只给一段配数量;多段说明主表数量不可拆分,宁可只展示货名也不重复同一份合计)
*/
private static List<Cargo> fallback(Waybill waybill) {
List<String> names = splitCargoNames(waybill.getCargoName());
if (names.isEmpty()) {
return List.of();
}
List<Cargo> cargos = new ArrayList<>(names.size());
boolean first = true;
for (String name : names) {
cargos.add(new Cargo(
name,
Func.toStr(waybill.getCargoType(), ""),
first ? waybill.getQuantity() : null,
first ? Func.toStr(waybill.getQuantityUnit(), "") : ""));
first = false;
}
return cargos;
}
/**
* 主表货名按顿号、逗号摊平;空格与空段去掉,全空时给空集合
*/
public static List<String> splitCargoNames(String cargoName) {
if (Func.isEmpty(cargoName)) {
return List.of();
}
List<String> names = new ArrayList<>();
for (String part : cargoName.split(NAME_SEPARATOR_REGEX)) {
String name = part.trim();
if (Func.isNotEmpty(name)) {
names.add(name);
}
}
return names;
}
private static String firstText(Map row, String... keys) {
for (String key : keys) {
Object value = row.get(key);
if (value == null) {
continue;
}
String text = String.valueOf(value).trim();
if (Func.isNotEmpty(text)) {
return text;
}
}
return "";
}
private static BigDecimal quantityOf(Map row) {
for (String key : QUANTITY_KEYS) {
Object value = row.get(key);
if (value == null) {
continue;
}
String text = String.valueOf(value).trim();
if (Func.isEmpty(text)) {
continue;
}
try {
BigDecimal quantity = new BigDecimal(text);
// 录入端用 -1 表示「未填」,落到汇总里会把合计算成负数
if (quantity.compareTo(BigDecimal.ONE.negate()) == 0) {
continue;
}
return quantity;
} catch (NumberFormatException exception) {
// 这一字段不是数字:继续试下一个候选字段
}
}
return null;
}
/**
* 运单上的一条货物
* <p>
* 数量与单位可能为空:录货物时允许只填名称。展示端要把「没填数量」与「数量为 0」区分开。
*/
public record Cargo(String cargoName, String cargoType, BigDecimal quantity, String quantityUnit) {
/**
* 展示用货名:空名称回退成「货物」
*/
public String displayName() {
return Func.toStrWithEmpty(cargoName, FALLBACK_NAME);
}
}
}
@@ -116,7 +116,7 @@ class TripQueryServiceImplTest {
assertTrue(select.contains("estimated_start_time"), "未配载运次的计划时间取自运单,必须取:" + select);
assertTrue(select.contains("estimated_end_time"), select);
assertFalse(select.contains("route_json"), "运单表上的大 JSON 列不必拖回:" + select);
assertFalse(select.contains("goods_json"), select);
assertTrue(select.contains("goods_json"), "货物明细只能从 goods_json 拿,必须随行取回:" + select);
assertFalse(select.contains("task_info_json"), select);
assertFalse(select.contains(String.valueOf((Object) null)) && select.contains("*"), "不得整行拖回:" + select);
}
@@ -287,6 +287,28 @@ class TripReadServiceBoundaryTest {
assertEquals("吨", trip.getQuantityUnit());
}
@Test
@DisplayName("一张运单录了多条货物时,货名与汇总都要全带上,不能只取主表首行")
void multiCargoWaybillKeepsEveryCargo() {
Waybill multi = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L));
// 主表只落了首行:货名与单位都是第一行的,数量却是两行的合计
multi.setCargoName("阿什顿");
multi.setCargoType("服饰");
multi.setQuantity(new BigDecimal("70.00"));
multi.setQuantityUnit("堆");
multi.setGoodsJson("[{\"cargoName\":\"阿什顿\",\"cargoType\":\"服饰\",\"quantity\":\"20\",\"quantityUnit\":\"堆\"},"
+ "{\"cargoName\":\"显卡050\",\"cargoType\":\"显卡\",\"quantity\":\"50\",\"quantityUnit\":\"方\"}]");
stubLoadedTrip(multi);
TripVO trip = onlyTrip();
assertEquals(List.of("阿什顿", "显卡050"), trip.getCargoNames(), "两种货物都要在,不能只剩主表首行");
assertEquals(List.of("阿什顿", "显卡050"), trip.getWaybills().get(0).getCargoNames(), "子单也要带上完整货名");
assertEquals("阿什顿 20堆、显卡050 50方", trip.getCargoSummary());
assertNull(trip.getTotalQuantity(), "两种货物单位不同,不能把 20堆+50方 说成 70堆");
assertNull(trip.getQuantityUnit());
}
// ------------------------------------------------------------ 夹具
@SuppressWarnings("unchecked")