视角:技术开发者
一、运费的复杂性
做反向海淘业务,最让技术头疼的就是运费计算。不同的物流渠道(EMS、云途、DHL、USPS)有不同的计费规则:首重续重、体积重系数、不同国家不同价格、甚至不同季节价格还会变。而且客户提交代购集运时预付的运费,和仓库实际打包后的运费往往有差异,需要支持补差。
在Taocarts系统中,我们设计了一个灵活的运费计算引擎,支持多渠道配置、实时计算、自动补差。这套引擎也被很多跨境代购独立站采用。今天分享核心代码。
二、运费模板数据结构
CREATE TABLE `freight_template` (
`id` bigint PRIMARY KEY AUTO_INCREMENT,
`channel_code` varchar(32) NOT NULL COMMENT '物流渠道代码: EMS, YUNTO, DHL',
`channel_name` varchar(64) NOT NULL,
`country_code` varchar(8) NOT NULL COMMENT '目标国家',
`country_name` varchar(64),
`first_weight` decimal(6,2) NOT NULL COMMENT '首重(kg)',
`first_price` decimal(10,2) NOT NULL COMMENT '首重价格(元)',
`additional_weight` decimal(6,2) NOT NULL COMMENT '续重单位(kg)',
`additional_price` decimal(10,2) NOT NULL COMMENT '续重价格(元)',
`volume_factor` int DEFAULT 5000 COMMENT '体积重系数(cm³/5000)',
`use_volume_weight` tinyint DEFAULT 0 COMMENT '是否取体积重大值',
`min_weight` decimal(6,2) DEFAULT 0 COMMENT '最小计费重量',
`max_weight` decimal(6,2) DEFAULT NULL COMMENT '最大限重',
`status` tinyint DEFAULT 1
);
三、核心计算逻辑
@Service
public class FreightCalculator {
// 计算预估运费(提交集运时)
public BigDecimal estimateFreight(Long templateId, List<OrderItem> items) {
FreightTemplate template = templateMapper.selectById(templateId);
double totalWeight = items.stream().mapToDouble(OrderItem::getWeight).sum();
double totalVolume = items.stream()
.mapToDouble(item -> item.getLength() * item.getWidth() * item.getHeight())
.sum();
double volumeWeight = totalVolume / template.getVolumeFactor();
double finalWeight = template.isUseVolumeWeight() ? Math.max(totalWeight, volumeWeight) : totalWeight;
finalWeight = Math.max(finalWeight, template.getMinWeight());
if (template.getMaxWeight() != null && finalWeight > template.getMaxWeight()) {
throw new BusinessException("总重量超过渠道限重,请拆分包裹或选择其他渠道");
}
return calculateByWeight(finalWeight, template);
}
private BigDecimal calculateByWeight(double weight, FreightTemplate template) {
if (weight <= template.getFirstWeight()) {
return template.getFirstPrice();
}
double additional = weight - template.getFirstWeight();
int units = (int) Math.ceil(additional / template.getAdditionalWeight());
return template.getFirstPrice().add(template.getAdditionalPrice().multiply(BigDecimal.valueOf(units)));
}
}
四、多渠道比价逻辑
客户在提交代购集运时,我们希望展示多个渠道的运费供选择。
@RestController
public class FreightController {
@PostMapping("/api/freight/compare")
public List<ChannelQuote> compareChannels(@RequestBody List<OrderItem> items, @RequestParam String countryCode) {
List<FreightTemplate> templates = templateMapper.selectByCountry(countryCode);
return templates.stream()
.map(t -> {
try {
BigDecimal price = freightCalculator.estimateFreight(t.getId(), items);
return new ChannelQuote(t.getChannelName(), price, t.getEstimatedDays());
} catch (Exception e) {
return null;
}
})
.filter(Objects::nonNull)
.sorted(Comparator.comparing(ChannelQuote::getPrice))
.collect(Collectors.toList());
}
}
五、实际打包后的运费补差
仓库打包完成后,实际重量可能与预估不同。我们设计了补差逻辑。
@Service
public class PackageService {
@Transactional
public void completePacking(Long packageId, Double actualWeight, Double actualLength, Double actualWidth, Double actualHeight) {
Package pkg = packageMapper.selectById(packageId);
// 重新计算实际运费
BigDecimal actualFreight = freightCalculator.calculateByWeight(actualWeight, pkg.getFreightTemplate());
BigDecimal prepaidFreight = pkg.getPrepaidFreight();
pkg.setActualWeight(actualWeight);
pkg.setActualFreight(actualFreight);
if (actualFreight.compareTo(prepaidFreight) > 0) {
// 需要补款
BigDecimal diff = actualFreight.subtract(prepaidFreight);
pkg.setStatus(PackageStatus.WAITING_DIFF);
pkg.setDiffAmount(diff);
// 生成补款订单
createDiffOrder(pkg.getUserId(), pkg.getId(), diff);
} else if (actualFreight.compareTo(prepaidFreight) < 0) {
// 需要退款(可选)
BigDecimal diff = prepaidFreight.subtract(actualFreight);
pkg.setStatus(PackageStatus.WAITING_REFUND);
pkg.setRefundAmount(diff);
// 自动退款到余额
userService.refundBalance(pkg.getUserId(), diff);
pkg.setStatus(PackageStatus.PACKED);
} else {
pkg.setStatus(PackageStatus.PACKED);
}
packageMapper.updateById(pkg);
}
}
六、体积重计算的坑
实际业务中,体积重的计算容易出错。因为商品入库时录入的长宽高可能不准。我们增加了“复核”流程,打包员可以修正尺寸。
另外,有些渠道的体积重系数是6000而不是5000(比如DHL)。我们把系数放到模板里,每个渠道单独配置。
// 体积重计算
public double calcVolumeWeight(double length, double width, double height, int factor) {
// 单位:cm,返回kg
return length * width * height / factor;
}
七、缓存优化
运费模板不常变,但每次计算都要查询数据库。我们用Caffeine做本地缓存,5分钟过期。
@Cacheable(value = "freightTemplate", key = "#templateId")
public FreightTemplate getTemplate(Long templateId) {
return templateMapper.selectById(templateId);
}
八、与Taocarts系统的集成
这套运费计算引擎是Taocarts系统的核心模块之一。Taocarts系统作为成熟的代购源码,已经对接了多家国际物流API,支持实时获取渠道价格、电子面单打印。如果你正在开发反向海淘独立站,可以直接复用这套逻辑,省去对接多个物流渠道的麻烦。