|
@@ -1194,6 +1194,15 @@ public class ProductOrderService {
|
|
|
} catch (Exception e) {
|
|
} catch (Exception e) {
|
|
|
log.error("刷新通关解锁进度失败: orderNo={}", orderNo, e);
|
|
log.error("刷新通关解锁进度失败: orderNo={}", orderNo, e);
|
|
|
}
|
|
}
|
|
|
|
|
+ // 虚拟商品自动发货(规格 §3.3):支付成功即置 completed + 同步微信 logistics_type=3(fail-open 幂等)
|
|
|
|
|
+ try {
|
|
|
|
|
+ if (isVirtualProduct(order)) {
|
|
|
|
|
+ Result<String> r = autoShipVirtualOrder(order);
|
|
|
|
|
+ log.info("虚拟商品订单支付成功自动发货: orderNo={}, result={}", orderNo, r != null ? r.getMessage() : "null");
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("虚拟商品自动发货失败(不阻塞支付): orderNo={}, error={}", orderNo, e.getMessage(), e);
|
|
|
|
|
+ }
|
|
|
return Result.success("支付成功");
|
|
return Result.success("支付成功");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1268,29 +1277,95 @@ public class ProductOrderService {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
public Result<String> ship(String orderNo, String trackingNumber, String expressCompany, Integer shippingCost) {
|
|
public Result<String> ship(String orderNo, String trackingNumber, String expressCompany, Integer shippingCost) {
|
|
|
|
|
+ return ship(orderNo, null, trackingNumber, expressCompany, shippingCost, null);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 多渠道履约发货(自提 / 快递 / 同城 / 虚拟商品),行为矩阵见
|
|
|
|
|
+ * docs/superpowers/specs/2026-10-22-fulfillment-multi-mode-design.md §3.2。
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param orderNo 订单号
|
|
|
|
|
+ * @param shipType 发货方式:express/local/selfPickup/virtual;null 时按订单推导(见 resolveShipType)
|
|
|
|
|
+ * @param trackingNumber 物流单号(express/local 必填;selfPickup/virtual 拒收)
|
|
|
|
|
+ * @param expressCompany 物流公司/同城运力商(express/local 必填;selfPickup/virtual 拒收)
|
|
|
|
|
+ * @param shippingCost 快递费(分)
|
|
|
|
|
+ * @param receiverContact 收货人电话(快递/同城同步微信时透传,自提/虚拟不传)
|
|
|
|
|
+ */
|
|
|
|
|
+ public Result<String> ship(String orderNo, String shipType,
|
|
|
|
|
+ String trackingNumber, String expressCompany,
|
|
|
|
|
+ Integer shippingCost, String receiverContact) {
|
|
|
ProductOrder order = orderMapper.selectOne(
|
|
ProductOrder order = orderMapper.selectOne(
|
|
|
new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
|
|
new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
|
|
|
if (order == null) {
|
|
if (order == null) {
|
|
|
return Result.error("订单不存在");
|
|
return Result.error("订单不存在");
|
|
|
}
|
|
}
|
|
|
|
|
+ boolean isVirtual = isVirtualProduct(order);
|
|
|
|
|
+ // 虚拟商品:已终态(completed)或无实体配送 → 补发货走幂等自动发货;其余分支校验
|
|
|
|
|
+ if ("completed".equals(order.getStatus()) && isVirtual) {
|
|
|
|
|
+ return autoShipVirtualOrder(order);
|
|
|
|
|
+ }
|
|
|
if (!"paid".equals(order.getStatus())) {
|
|
if (!"paid".equals(order.getStatus())) {
|
|
|
return Result.error("仅已支付订单可发货");
|
|
return Result.error("仅已支付订单可发货");
|
|
|
}
|
|
}
|
|
|
- // For self-pickup orders, skip tracking info
|
|
|
|
|
- if (order.getDeliveryMethod() != null && order.getDeliveryMethod() == 2) {
|
|
|
|
|
- order.setStatus("pending_receipt");
|
|
|
|
|
- } else {
|
|
|
|
|
- order.setLogisticsNo(trackingNumber);
|
|
|
|
|
- order.setLogisticsCompany(expressCompany);
|
|
|
|
|
- order.setStatus("pending_receipt");
|
|
|
|
|
|
|
+ // 虚拟商品支付成功即自动发货(规范 §3.3),商家后台补发货复用幂等方法;
|
|
|
|
|
+ // 与微信协议一致:无实体配送无单号,传入单号/公司 → 拒绝
|
|
|
|
|
+ if (isVirtual) {
|
|
|
|
|
+ if ((trackingNumber != null && !trackingNumber.trim().isEmpty())
|
|
|
|
|
+ || (expressCompany != null && !expressCompany.trim().isEmpty())) {
|
|
|
|
|
+ return Result.error("虚拟商品无需填写物流信息");
|
|
|
|
|
+ }
|
|
|
|
|
+ return autoShipVirtualOrder(order);
|
|
|
|
|
+ }
|
|
|
|
|
+ // 非虚拟商品:推导/校验发货类型
|
|
|
|
|
+ ShipType st = resolveShipType(order, shipType);
|
|
|
|
|
+ if (st == null) {
|
|
|
|
|
+ return Result.error("无法判断发货方式,请选择:快递发货或自提发货");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (st == ShipType.VIRTUAL) {
|
|
|
|
|
+ // 显式船传 shipType=virtual 但订单不是虚拟商品 → 拒绝
|
|
|
|
|
+ return Result.error("该订单不是虚拟商品,无法按虚拟商品发货");
|
|
|
|
|
+ }
|
|
|
|
|
+ switch (st) {
|
|
|
|
|
+ case EXPRESS:
|
|
|
|
|
+ if (trackingNumber == null || trackingNumber.trim().isEmpty()) {
|
|
|
|
|
+ return Result.error("请填写物流单号");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (expressCompany == null || expressCompany.trim().isEmpty()) {
|
|
|
|
|
+ return Result.error("请填写物流公司");
|
|
|
|
|
+ }
|
|
|
|
|
+ order.setLogisticsNo(trackingNumber);
|
|
|
|
|
+ order.setLogisticsCompany(expressCompany);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case LOCAL:
|
|
|
|
|
+ if (trackingNumber == null || trackingNumber.trim().isEmpty()) {
|
|
|
|
|
+ return Result.error("请填写同城配送单号");
|
|
|
|
|
+ }
|
|
|
|
|
+ if (expressCompany == null || expressCompany.trim().isEmpty()) {
|
|
|
|
|
+ return Result.error("请填写同城配送公司");
|
|
|
|
|
+ }
|
|
|
|
|
+ order.setLogisticsNo(trackingNumber);
|
|
|
|
|
+ order.setLogisticsCompany(expressCompany);
|
|
|
|
|
+ break;
|
|
|
|
|
+ case SELF_PICKUP:
|
|
|
|
|
+ default:
|
|
|
|
|
+ if ((trackingNumber != null && !trackingNumber.trim().isEmpty())
|
|
|
|
|
+ || (expressCompany != null && !expressCompany.trim().isEmpty())) {
|
|
|
|
|
+ return Result.error("自提订单无需填写物流信息");
|
|
|
|
|
+ }
|
|
|
|
|
+ break;
|
|
|
}
|
|
}
|
|
|
if (shippingCost != null) {
|
|
if (shippingCost != null) {
|
|
|
order.setShippingCost(shippingCost);
|
|
order.setShippingCost(shippingCost);
|
|
|
}
|
|
}
|
|
|
|
|
+ order.setStatus("pending_receipt");
|
|
|
|
|
+ if (st == ShipType.SELF_PICKUP) {
|
|
|
|
|
+ // 履约扩展:自取/线上订单必须供应商先确认(confirmReceive 有校验),此处统一置为已确认
|
|
|
|
|
+ order.setFulfillStatus("confirmed_by_vendor");
|
|
|
|
|
+ }
|
|
|
order.setUpdatedAt(new Date());
|
|
order.setUpdatedAt(new Date());
|
|
|
orderMapper.updateById(order);
|
|
orderMapper.updateById(order);
|
|
|
// 发货成功后自动同步物流信息到微信小程序交易管理服务(fail-open:失败只记日志不阻塞)
|
|
// 发货成功后自动同步物流信息到微信小程序交易管理服务(fail-open:失败只记日志不阻塞)
|
|
|
- syncShippingInfoToWechat(order, trackingNumber, expressCompany);
|
|
|
|
|
|
|
+ syncShippingInfoToWechat(order, st, trackingNumber, expressCompany, receiverContact);
|
|
|
return Result.success("已发货");
|
|
return Result.success("已发货");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1308,29 +1383,130 @@ public class ProductOrderService {
|
|
|
if (shippingCost != null) order.setShippingCost(shippingCost);
|
|
if (shippingCost != null) order.setShippingCost(shippingCost);
|
|
|
order.setUpdatedAt(new Date());
|
|
order.setUpdatedAt(new Date());
|
|
|
orderMapper.updateById(order);
|
|
orderMapper.updateById(order);
|
|
|
- // 快递信息更新后自动同步微信(部分更新时回退订单已存值)
|
|
|
|
|
- syncShippingInfoToWechat(order,
|
|
|
|
|
|
|
+ // 快递信息更新后自动同步微信(部分更新时回退订单已存值);updateExpress 仅用于快递/同城
|
|
|
|
|
+ ShipType st = resolveShipTypeFromOrder(order);
|
|
|
|
|
+ syncShippingInfoToWechat(order, st,
|
|
|
trackingNumber != null ? trackingNumber : order.getLogisticsNo(),
|
|
trackingNumber != null ? trackingNumber : order.getLogisticsNo(),
|
|
|
- expressCompany != null ? expressCompany : order.getLogisticsCompany());
|
|
|
|
|
|
|
+ expressCompany != null ? expressCompany : order.getLogisticsCompany(),
|
|
|
|
|
+ null);
|
|
|
return Result.success("快递信息已更新");
|
|
return Result.success("快递信息已更新");
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 发货方式类型:express=快递(微信logistics_type 1)、local=同城配送(2)、
|
|
|
|
|
+ * virtual=虚拟商品(3)、selfPickup=用户自提(4)。
|
|
|
|
|
+ */
|
|
|
|
|
+ private enum ShipType {
|
|
|
|
|
+ EXPRESS, LOCAL, SELF_PICKUP, VIRTUAL
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 虚拟商品:productType == "virtual" */
|
|
|
|
|
+ private boolean isVirtualProduct(ProductOrder order) {
|
|
|
|
|
+ return order != null && "virtual".equals(order.getProductType());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 由订单推导发货类型(规格 §3.1)。
|
|
|
|
|
+ * 优先显式 shipTypeParam(express/local/selfPickup/virtual),其次按 productType/deliveryMethod 推导;
|
|
|
|
|
+ * deliveryMethod=3/4/5 或 NULL 订单层面无法判定 → 返回 null(调用方报业务错误,要求前端显式传入 shipType)。
|
|
|
|
|
+ */
|
|
|
|
|
+ private ShipType resolveShipType(ProductOrder order, String shipTypeParam) {
|
|
|
|
|
+ if (shipTypeParam != null && !shipTypeParam.trim().isEmpty()) {
|
|
|
|
|
+ String s = shipTypeParam.trim().toLowerCase();
|
|
|
|
|
+ if ("express".equals(s)) return ShipType.EXPRESS;
|
|
|
|
|
+ if ("local".equals(s)) return ShipType.LOCAL;
|
|
|
|
|
+ if ("selfpickup".equals(s)) return ShipType.SELF_PICKUP;
|
|
|
|
|
+ if ("virtual".equals(s)) return ShipType.VIRTUAL;
|
|
|
|
|
+ return null; // 未知类型
|
|
|
|
|
+ }
|
|
|
|
|
+ if (isVirtualProduct(order)) {
|
|
|
|
|
+ return ShipType.VIRTUAL;
|
|
|
|
|
+ }
|
|
|
|
|
+ Integer dm = order.getDeliveryMethod();
|
|
|
|
|
+ if (dm == null) {
|
|
|
|
|
+ // NULL:订单层面无法判定,要求前端显式传入 shipType
|
|
|
|
|
+ return null;
|
|
|
|
|
+ }
|
|
|
|
|
+ switch (dm) {
|
|
|
|
|
+ case 1: return ShipType.EXPRESS;
|
|
|
|
|
+ case 2: return ShipType.SELF_PICKUP;
|
|
|
|
|
+ case 6: return ShipType.LOCAL;
|
|
|
|
|
+ default: return null; // 3 两者皆可(应已被 validateFulfill 转成 1/2,出现属老数据);4/5 线上服务履约
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 按订单 deliveryMethod 推导(updateExpress 改快递/同城场景复用):1快递/2自提/6同城;其余按 EXPRESS 兜底 */
|
|
|
|
|
+ private ShipType resolveShipTypeFromOrder(ProductOrder order) {
|
|
|
|
|
+ Integer dm = order.getDeliveryMethod();
|
|
|
|
|
+ if (dm == null) {
|
|
|
|
|
+ return ShipType.EXPRESS;
|
|
|
|
|
+ }
|
|
|
|
|
+ switch (dm) {
|
|
|
|
|
+ case 1: return ShipType.EXPRESS;
|
|
|
|
|
+ case 2: return ShipType.SELF_PICKUP;
|
|
|
|
|
+ case 6: return ShipType.LOCAL;
|
|
|
|
|
+ default: return ShipType.EXPRESS;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 虚拟商品自动发货(规格 §3.3):支付成功即置 completed + 调微信 upload_shipping_info(logistics_type=3)。
|
|
|
|
|
+ * fail-open + 幂等:状态已 completed 时直接返回提示不再重复同步。
|
|
|
|
|
+ */
|
|
|
|
|
+ private Result<String> autoShipVirtualOrder(ProductOrder order) {
|
|
|
|
|
+ if (isVirtualProduct(order) && "completed".equals(order.getStatus())) {
|
|
|
|
|
+ log.info("虚拟商品订单已交付(补发货幂等跳过): orderNo={}", order.getOrderNo());
|
|
|
|
|
+ return Result.success("该订单已自动交付");
|
|
|
|
|
+ }
|
|
|
|
|
+ order.setStatus("completed");
|
|
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
|
|
+ orderMapper.updateById(order);
|
|
|
|
|
+ // 同步微信(fail-open),买家无 openid 时微信侧跳过
|
|
|
|
|
+ try {
|
|
|
|
|
+ User buyer = userMapper.selectById(order.getBuyerId());
|
|
|
|
|
+ if (buyer == null || buyer.getOpenid() == null || buyer.getOpenid().isEmpty()) {
|
|
|
|
|
+ log.warn("虚拟商品买家无 openid,跳过微信发货同步: orderNo={}", order.getOrderNo());
|
|
|
|
|
+ } else {
|
|
|
|
|
+ String itemDesc = (order.getProductName() == null ? "" : order.getProductName())
|
|
|
|
|
+ + " x" + (order.getQuantity() == null ? 1 : order.getQuantity());
|
|
|
|
|
+ if (itemDesc.length() > 120) {
|
|
|
|
|
+ itemDesc = itemDesc.substring(0, 120);
|
|
|
|
|
+ }
|
|
|
|
|
+ String err = wechatService.uploadShippingInfo(order.getOrderNo(), buyer.getOpenid(),
|
|
|
|
|
+ null, null, itemDesc, 3, null);
|
|
|
|
|
+ if (err != null) {
|
|
|
|
|
+ log.warn("虚拟商品微信同步未成功(不阻塞): orderNo={}, reason={}", order.getOrderNo(), err);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (Exception e) {
|
|
|
|
|
+ log.error("虚拟商品微信发货同步异常(不阻塞): orderNo={}, error={}", order.getOrderNo(), e.getMessage(), e);
|
|
|
|
|
+ }
|
|
|
|
|
+ log.info("虚拟商品自动发货完成: orderNo={}", order.getOrderNo());
|
|
|
|
|
+ return Result.success("自动发货成功");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* 管理端发货/改快递成功后,自动把物流信息同步到微信「小程序交易管理服务」
|
|
* 管理端发货/改快递成功后,自动把物流信息同步到微信「小程序交易管理服务」
|
|
|
* (upload_shipping_info)。fail-open:失败只记日志,不阻塞本地发货。
|
|
* (upload_shipping_info)。fail-open:失败只记日志,不阻塞本地发货。
|
|
|
- * 自提订单(deliveryMethod=2)或无物流单号时跳过。
|
|
|
|
|
|
|
+ * 自提(4)/虚拟(3)订单:无单号不带;买家无 openid / 无单号时跳过。
|
|
|
*/
|
|
*/
|
|
|
- private void syncShippingInfoToWechat(ProductOrder order, String trackingNo, String expressCompany) {
|
|
|
|
|
|
|
+ private void syncShippingInfoToWechat(ProductOrder order, ShipType shipType,
|
|
|
|
|
+ String trackingNo, String expressCompany, String receiverContact) {
|
|
|
try {
|
|
try {
|
|
|
if (order == null) {
|
|
if (order == null) {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
- // 仅实体物流同步:自提订单跳过
|
|
|
|
|
- if (order.getDeliveryMethod() != null && order.getDeliveryMethod() == 2) {
|
|
|
|
|
- log.info("自提订单跳过微信发货同步: orderNo={}", order.getOrderNo());
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+ int logisticsType;
|
|
|
|
|
+ switch (shipType) {
|
|
|
|
|
+ case LOCAL: logisticsType = 2; break;
|
|
|
|
|
+ case SELF_PICKUP: logisticsType = 4; break;
|
|
|
|
|
+ case VIRTUAL: logisticsType = 3; break;
|
|
|
|
|
+ case EXPRESS:
|
|
|
|
|
+ default: logisticsType = 1; break;
|
|
|
}
|
|
}
|
|
|
- if (trackingNo == null || trackingNo.trim().isEmpty()) {
|
|
|
|
|
|
|
+ // 无单号跳过(自提/虚拟本身无单号,属正常,不警告)
|
|
|
|
|
+ if ((logisticsType == 1 || logisticsType == 2)
|
|
|
|
|
+ && (trackingNo == null || trackingNo.trim().isEmpty())) {
|
|
|
log.info("无物流单号,跳过微信发货同步: orderNo={}", order.getOrderNo());
|
|
log.info("无物流单号,跳过微信发货同步: orderNo={}", order.getOrderNo());
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
@@ -1346,7 +1522,7 @@ public class ProductOrderService {
|
|
|
itemDesc = itemDesc.substring(0, 120);
|
|
itemDesc = itemDesc.substring(0, 120);
|
|
|
}
|
|
}
|
|
|
String err = wechatService.uploadShippingInfo(order.getOrderNo(), buyer.getOpenid(),
|
|
String err = wechatService.uploadShippingInfo(order.getOrderNo(), buyer.getOpenid(),
|
|
|
- trackingNo, expressCompany, itemDesc);
|
|
|
|
|
|
|
+ trackingNo, expressCompany, itemDesc, logisticsType, receiverContact);
|
|
|
if (err != null) {
|
|
if (err != null) {
|
|
|
log.warn("微信发货同步未成功(不阻塞发货): orderNo={}, reason={}", order.getOrderNo(), err);
|
|
log.warn("微信发货同步未成功(不阻塞发货): orderNo={}, reason={}", order.getOrderNo(), err);
|
|
|
}
|
|
}
|