|
|
@@ -1695,6 +1695,92 @@ public class ProductOrderService {
|
|
|
return Result.success("已确认发出");
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 服务商订单到店核销(SC-S-04,能力键 {@code supplier.order.fulfill})。
|
|
|
+ * 契约签名:{@code public Result<String> redeem(String orderNo, String pickupCode, Long vendorId)}。
|
|
|
+ *
|
|
|
+ * <p>行为矩阵(见 {@code SupplierOrderFulfillContractTest} / {@code 应用场景总览.md} SC-S-04):
|
|
|
+ * <ul>
|
|
|
+ * <li>正常流:本服务商 + 自提码匹配 + 未超期 → 订单置「已完成」,返回成功;</li>
|
|
|
+ * <li>防冒用(TC-S-03-E):自提码不匹配 → 拒绝(提示核销码校验失败),不落库;</li>
|
|
|
+ * <li>重复核销幂等(TC-S-04):已 completed 再次核销 → 返回成功,不重复落库/副作用;</li>
|
|
|
+ * <li>超期订单(TC-S-04):自提时段已过 → 提示不可核销,状态不变更;</li>
|
|
|
+ * <li>非本服务商(TC-S-04-P):商品 vendorId ≠ vendorId → 无权操作,不落库。</li>
|
|
|
+ * </ul>
|
|
|
+ */
|
|
|
+ public Result<String> redeem(String orderNo, String pickupCode, Long vendorId) {
|
|
|
+ ProductOrder order = orderMapper.selectOne(
|
|
|
+ new LambdaQueryWrapper<ProductOrder>().eq(ProductOrder::getOrderNo, orderNo));
|
|
|
+ if (order == null) {
|
|
|
+ return Result.error("订单不存在");
|
|
|
+ }
|
|
|
+ // 幂等:已核销完成(重复扫码同订单)→ 返回成功,不重复落库/不重复副作用
|
|
|
+ if ("completed".equals(order.getStatus())) {
|
|
|
+ return Result.success("该订单已完成核销");
|
|
|
+ }
|
|
|
+ if (!"paid".equals(order.getStatus())) {
|
|
|
+ return Result.error("当前订单状态不可核销");
|
|
|
+ }
|
|
|
+ // 超期判定:自提时段快照已过 → 不可核销(状态不变更)
|
|
|
+ if (isPickupExpired(order)) {
|
|
|
+ return Result.error("订单已超期,不可核销");
|
|
|
+ }
|
|
|
+ // 权限:非本服务商商品订单 → 无权操作(不落库)
|
|
|
+ Product product = order.getProductId() != null ? productMapper.selectById(order.getProductId()) : null;
|
|
|
+ if (product == null || vendorId == null || !vendorId.equals(product.getVendorId())) {
|
|
|
+ return Result.error("无权操作此订单");
|
|
|
+ }
|
|
|
+ // 防冒用:自提码校验(TC-S-03-E)
|
|
|
+ if (pickupCode == null || order.getPickupCode() == null || !pickupCode.equals(order.getPickupCode())) {
|
|
|
+ return Result.error("核销码校验失败");
|
|
|
+ }
|
|
|
+ // 核销完成:到店核销无实体物流,置终态 + 履约扩展标记
|
|
|
+ order.setStatus("completed");
|
|
|
+ order.setFulfillStatus("received_by_user");
|
|
|
+ order.setReceivedAt(new Date());
|
|
|
+ order.setUpdatedAt(new Date());
|
|
|
+ orderMapper.updateById(order);
|
|
|
+ return Result.success("核销成功");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 超期判定:自提时段快照 {@code timeSlotSnapshot} 中 {@code endTime} 已过(早于当前时刻)。
|
|
|
+ * 快照 {@code endTime} 可能为 {@code java.sql.Time} 时间串(如 "09:00:00")或完整日期串;
|
|
|
+ * 仅当能解析为完整日期且早于 now 时判定超期——纯时间串无法定位具体日期,不在此简单判定(避免误伤当日时段)。
|
|
|
+ */
|
|
|
+ private boolean isPickupExpired(ProductOrder order) {
|
|
|
+ String snap = order.getTimeSlotSnapshot();
|
|
|
+ if (snap == null || snap.trim().isEmpty()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ com.alibaba.fastjson.JSONObject obj = JSON.parseObject(snap);
|
|
|
+ String endTime = obj.getString("endTime");
|
|
|
+ if (endTime == null || endTime.trim().isEmpty()) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ Date end = parseSlotEndTime(endTime.trim());
|
|
|
+ return end != null && end.before(new Date());
|
|
|
+ } catch (Exception e) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 解析快照 endTime:支持完整日期串 "yyyy-MM-dd HH:mm:ss" / "yyyy-MM-dd'T'HH:mm:ss";纯时间串返回 null */
|
|
|
+ private Date parseSlotEndTime(String s) {
|
|
|
+ String[] patterns = {"yyyy-MM-dd HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss"};
|
|
|
+ for (String p : patterns) {
|
|
|
+ try {
|
|
|
+ SimpleDateFormat sdf = new SimpleDateFormat(p);
|
|
|
+ sdf.setLenient(false);
|
|
|
+ return sdf.parse(s);
|
|
|
+ } catch (Exception ignored) {
|
|
|
+ // 尝试下一个模式
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
// ========== 退款流程 ==========
|
|
|
|
|
|
public Result<String> applyRefund(String orderNo, Long userId, String reason) {
|