feat(ticket): 优化套票发放服务支持多商品并实现起始送水自动核销

- 增加对多个商品/模板的支持,修改issueTodayOrders方法接受商品ID列表
- 实现起始送水自动核销功能,按模板配置自动消耗初始水票数量
- 添加起始送水订单生成功能,用于配送端跟踪和后台管理
- 新增GltTicketOrder相关服务和地址快照构建逻辑
- 修改用户水票统计方法,从统计总数量改为统计可用数量
- 增加根据商品ID查询水票模板的API接口
- 优化订单状态更新逻辑,移除发货状态设置
- 添加数据安全检查,防止空值导致的异常处理
This commit is contained in:
2026-02-13 02:22:15 +08:00
parent 6f1503e89f
commit 90965b6d0a
7 changed files with 199 additions and 24 deletions

View File

@@ -1,5 +1,6 @@
package com.gxwebsoft.glt.controller;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.gxwebsoft.common.core.web.BaseController;
import com.gxwebsoft.common.system.entity.User;
import com.gxwebsoft.glt.service.GltTicketTemplateService;
@@ -54,7 +55,23 @@ public class GltTicketTemplateController extends BaseController {
return success(gltTicketTemplateService.getByIdRel(id));
}
@PreAuthorize("hasAuthority('glt:gltTicketTemplate:save')")
@Operation(summary = "根据商品ID查询水票")
@GetMapping("/getByGoodsId/{goodsId}")
public ApiResult<GltTicketTemplate> getByGoodsId(@PathVariable("goodsId") Integer goodsId) {
GltTicketTemplate template = gltTicketTemplateService.getOne(
new LambdaQueryWrapper<GltTicketTemplate>()
.eq(GltTicketTemplate::getGoodsId, goodsId)
.eq(GltTicketTemplate::getDeleted, 0)
.orderByAsc(GltTicketTemplate::getSortNumber)
.orderByDesc(GltTicketTemplate::getCreateTime)
.last("limit 1")
);
return success(template);
}
@PreAuthorize("hasAuthority('glt:gltTicketTemplate:update')")
@OperationLog
@Operation(summary = "添加水票")
@PostMapping()
@@ -92,7 +109,7 @@ public class GltTicketTemplateController extends BaseController {
return fail("删除失败");
}
@PreAuthorize("hasAuthority('glt:gltTicketTemplate:save')")
@PreAuthorize("hasAuthority('glt:gltTicketTemplate:update')")
@OperationLog
@Operation(summary = "批量添加水票")
@PostMapping("/batch")

View File

@@ -38,17 +38,23 @@ public class GltUserTicketController extends BaseController {
return success(gltUserTicketService.pageRel(param));
}
@Operation(summary = "我的水票总数")
@Operation(summary = "可用水票总数")
@GetMapping("/my-total")
public ApiResult<?> myTotal() {
Integer userId = getLoginUserId();
if (userId == null) {
return fail("未登录");
}
Integer totalQty = gltUserTicketService.sumTotalQtyByUserId(userId);
Integer tenantId = getTenantId();
if (tenantId == null) {
return fail("租户信息缺失");
}
Integer availableQty = gltUserTicketService.sumAvailableQtyByUserId(userId, tenantId);
Map<String, Object> data = new HashMap<>();
data.put("userId", userId);
data.put("totalQty", totalQty);
// 兼容旧字段totalQty 表示“可用水票总数”
data.put("totalQty", availableQty);
data.put("availableQty", availableQty);
return success(data);
}

View File

@@ -55,8 +55,6 @@ public class GltUserTicketLogController extends BaseController {
return success(gltUserTicketLogService.getByIdRel(id));
}
@PreAuthorize("hasAuthority('glt:gltUserTicketLog:save')")
@OperationLog
@Operation(summary = "添加消费日志")
@PostMapping()
public ApiResult<?> save(@RequestBody GltUserTicketLog gltUserTicketLog) {

View File

@@ -55,6 +55,9 @@ public class GltUserTicket implements Serializable {
@Schema(description = "订单商品ID")
private Integer orderGoodsId;
@Schema(description = "订单商品数量")
private Integer orderGoodsQty;
@Schema(description = "总数量")
private Integer totalQty;

View File

@@ -2,25 +2,32 @@ package com.gxwebsoft.glt.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.gxwebsoft.glt.entity.GltTicketOrder;
import com.gxwebsoft.glt.entity.GltTicketTemplate;
import com.gxwebsoft.glt.entity.GltUserTicket;
import com.gxwebsoft.glt.entity.GltUserTicketLog;
import com.gxwebsoft.glt.entity.GltUserTicketRelease;
import com.gxwebsoft.shop.entity.ShopOrder;
import com.gxwebsoft.shop.entity.ShopOrderGoods;
import com.gxwebsoft.shop.entity.ShopUserAddress;
import com.gxwebsoft.shop.service.ShopOrderGoodsService;
import com.gxwebsoft.shop.service.ShopOrderService;
import com.gxwebsoft.shop.service.ShopUserAddressService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionTemplate;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* 套票发放(从订单生成用户套票 + 释放计划)的业务逻辑。
@@ -36,6 +43,8 @@ import java.util.Objects;
public class GltTicketIssueService {
public static final int CHANGE_TYPE_ISSUE = 10;
/** 变更类型:起始送水自动核销(按模板 startSendQty 在发放时自动消耗) */
public static final int CHANGE_TYPE_START_SEND_WRITE_OFF = 12;
private enum IssueOutcome {
ISSUED,
@@ -51,19 +60,43 @@ public class GltTicketIssueService {
private final GltUserTicketService gltUserTicketService;
private final GltUserTicketReleaseService gltUserTicketReleaseService;
private final GltUserTicketLogService gltUserTicketLogService;
private final GltTicketOrderService gltTicketOrderService;
private final ShopUserAddressService shopUserAddressService;
private final TransactionTemplate transactionTemplate;
/**
* 扫描“今日订单”,执行套票发放。
*/
public void issueTodayOrders(Integer tenantId, Integer formId) {
if (formId == null) {
return;
}
issueTodayOrders(tenantId, List.of(formId));
}
/**
* 扫描“今日订单”,执行套票发放(支持多个商品/模板)。
*/
public void issueTodayOrders(Integer tenantId, List<Integer> goodsIds) {
if (tenantId == null || goodsIds == null || goodsIds.isEmpty()) {
return;
}
List<Integer> uniqueGoodsIds = goodsIds.stream()
.filter(Objects::nonNull)
.distinct()
.toList();
if (uniqueGoodsIds.isEmpty()) {
return;
}
Set<Integer> goodsIdSet = new HashSet<>(uniqueGoodsIds);
LocalDateTime todayStart = LocalDate.now().atStartOfDay();
LocalDateTime tomorrowStart = todayStart.plusDays(1);
List<ShopOrder> orders = shopOrderService.list(
new LambdaQueryWrapper<ShopOrder>()
.eq(ShopOrder::getTenantId, tenantId)
.eq(ShopOrder::getFormId, formId)
.in(ShopOrder::getFormId, uniqueGoodsIds)
.eq(ShopOrder::getPayStatus, true)
.eq(ShopOrder::getOrderStatus, 0)
// 今日订单(兼容:以 create_time 或 pay_time 任一落在今日即可)
@@ -77,7 +110,7 @@ public class GltTicketIssueService {
);
if (orders.isEmpty()) {
log.debug("套票发放扫描:今日无符合条件的订单 tenantId={}, formId={}", tenantId, formId);
log.debug("套票发放扫描:今日无符合条件的订单 tenantId={}, goodsIds={}", tenantId, uniqueGoodsIds);
return;
}
@@ -87,7 +120,7 @@ public class GltTicketIssueService {
for (ShopOrder order : orders) {
try {
int issuedCount = issueForOrder(tenantId, formId, order);
int issuedCount = issueForOrder(tenantId, goodsIdSet, order);
if (issuedCount > 0) {
success += issuedCount;
} else {
@@ -100,11 +133,11 @@ public class GltTicketIssueService {
}
}
log.info("套票发放扫描完成 - tenantId={}, formId={}, 订单数={}, 发放成功={}, 跳过={}, 失败={}",
tenantId, formId, orders.size(), success, skipped, failed);
log.info("套票发放扫描完成 - tenantId={}, goodsIds={}, 订单数={}, 发放成功={}, 跳过={}, 失败={}",
tenantId, uniqueGoodsIds, orders.size(), success, skipped, failed);
}
private int issueForOrder(Integer tenantId, Integer formId, ShopOrder order) {
private int issueForOrder(Integer tenantId, Set<Integer> goodsIds, ShopOrder order) {
List<ShopOrderGoods> goodsList = shopOrderGoodsService.getListByOrderIdIgnoreTenant(order.getOrderId());
if (goodsList == null || goodsList.isEmpty()) {
return 0;
@@ -114,7 +147,7 @@ public class GltTicketIssueService {
boolean shouldCompleteOrder = false;
for (ShopOrderGoods og : goodsList) {
if (!Objects.equals(og.getGoodsId(), formId)) {
if (og.getGoodsId() == null || !goodsIds.contains(og.getGoodsId())) {
continue;
}
@@ -135,9 +168,6 @@ public class GltTicketIssueService {
new LambdaUpdateWrapper<ShopOrder>()
.eq(ShopOrder::getOrderId, order.getOrderId())
.eq(ShopOrder::getTenantId, tenantId)
.set(ShopOrder::getOrderStatus, 1)
// 同步更新发货状态为“已发货”
.set(ShopOrder::getDeliveryStatus, 20)
.set(ShopOrder::getHasTakeGift, true)
.set(ShopOrder::getUpdateTime, now)
);
@@ -231,6 +261,7 @@ public class GltTicketIssueService {
userTicket.setUsedQty(0);
// 初始可用量来自“购买量”,视为已释放
userTicket.setReleasedQty(buyQty);
userTicket.setOrderGoodsQty(og.getTotalNum());
userTicket.setUserId(order.getUserId());
userTicket.setSortNumber(0);
userTicket.setComments("订单发放套票");
@@ -274,6 +305,51 @@ public class GltTicketIssueService {
issueLog.setUpdateTime(now);
gltUserTicketLogService.save(issueLog);
// 按模板配置:自动“使用掉第一次水票”(起始送水数量)
Integer startSendQtyObj = template.getStartSendQty();
int startSendQty = startSendQtyObj != null ? startSendQtyObj : 0;
if (startSendQty > 0) {
int availableBefore = userTicket.getAvailableQty() != null ? userTicket.getAvailableQty() : 0;
int usedBefore = userTicket.getUsedQty() != null ? userTicket.getUsedQty() : 0;
int toUse = Math.min(startSendQty, availableBefore);
if (toUse > 0) {
userTicket.setAvailableQty(availableBefore - toUse);
userTicket.setUsedQty(usedBefore + toUse);
userTicket.setUpdateTime(now);
if (!gltUserTicketService.updateById(userTicket)) {
throw new IllegalStateException("起始送水自动核销:更新用户水票失败 userTicketId=" + userTicket.getId());
}
// 起始送水:自动核销成功后,生成一条送水订单(用于配送端/后台跟踪)
GltTicketOrder ticketOrder = buildStartSendTicketOrder(tenantId, order, userTicket, toUse, now);
if (!gltTicketOrderService.save(ticketOrder)) {
throw new IllegalStateException("起始送水自动核销:创建送水订单失败 userTicketId=" + userTicket.getId());
}
GltUserTicketLog writeOffLog = new GltUserTicketLog();
writeOffLog.setUserTicketId(userTicket.getId());
writeOffLog.setChangeType(CHANGE_TYPE_START_SEND_WRITE_OFF);
writeOffLog.setChangeAvailable(-toUse);
writeOffLog.setChangeFrozen(0);
writeOffLog.setChangeUsed(toUse);
writeOffLog.setAvailableAfter(userTicket.getAvailableQty());
writeOffLog.setFrozenAfter(userTicket.getFrozenQty() != null ? userTicket.getFrozenQty() : 0);
writeOffLog.setUsedAfter(userTicket.getUsedQty());
// 关联送水订单(保持与“用户下单核销”的日志一致),并在备注里保留来源商城订单号便于追溯
writeOffLog.setOrderId(ticketOrder.getId());
writeOffLog.setOrderNo(ticketOrder.getId() == null ? null : String.valueOf(ticketOrder.getId()));
writeOffLog.setUserId(order.getUserId());
writeOffLog.setSortNumber(0);
writeOffLog.setComments("起始送水自动核销(来源商城订单:" + safe(order.getOrderNo()) + ")");
writeOffLog.setStatus(0);
writeOffLog.setDeleted(0);
writeOffLog.setTenantId(tenantId);
writeOffLog.setCreateTime(now);
writeOffLog.setUpdateTime(now);
gltUserTicketLogService.save(writeOffLog);
}
}
log.info("套票发放成功 - tenantId={}, orderNo={}, orderGoodsId={}, templateId={}, userTicketId={}, totalQty={}",
tenantId, order.getOrderNo(), og.getId(), template.getId(), userTicket.getId(), totalQty);
@@ -359,4 +435,78 @@ public class GltTicketIssueService {
LocalDate adjusted = nextMonth.withDayOfMonth(day);
return LocalDateTime.of(adjusted, time);
}
private static final DateTimeFormatter DATETIME_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private GltTicketOrder buildStartSendTicketOrder(Integer tenantId,
ShopOrder shopOrder,
GltUserTicket userTicket,
int totalNum,
LocalDateTime now) {
GltTicketOrder o = new GltTicketOrder();
o.setUserTicketId(userTicket.getId());
o.setUserId(shopOrder.getUserId());
o.setStoreId(shopOrder.getStoreId());
o.setWarehouseId(shopOrder.getWarehouseId());
o.setRiderId(shopOrder.getRiderId());
o.setTotalNum(totalNum);
o.setPrice(BigDecimal.ZERO);
o.setBuyerRemarks(shopOrder.getBuyerRemarks());
// 地址快照:优先使用地址表快照;兜底使用商城订单上的 address 字段
Integer addressId = shopOrder.getAddressId();
o.setAddressId(addressId);
String addressSnapshot = null;
if (addressId != null) {
ShopUserAddress addr = shopUserAddressService.getOne(new LambdaQueryWrapper<ShopUserAddress>()
.eq(ShopUserAddress::getId, addressId)
.eq(ShopUserAddress::getUserId, shopOrder.getUserId())
.eq(ShopUserAddress::getTenantId, tenantId)
.last("limit 1"));
addressSnapshot = buildAddressSnapshot(addr);
}
if (addressSnapshot == null || addressSnapshot.isBlank()) {
addressSnapshot = shopOrder.getAddress();
}
o.setAddress(addressSnapshot);
String preferredSendTime = shopOrder.getSendStartTime();
if (preferredSendTime == null || preferredSendTime.isBlank()) {
preferredSendTime = now.format(DATETIME_FMT);
}
o.setSendTime(preferredSendTime);
o.setDeliveryStatus(GltTicketOrderService.DELIVERY_STATUS_WAITING);
o.setSortNumber(0);
o.setComments("起始送水自动下单(来源商城订单:" + safe(shopOrder.getOrderNo()) + ")");
o.setStatus(0);
o.setDeleted(0);
o.setTenantId(tenantId);
o.setCreateTime(now);
o.setUpdateTime(now);
return o;
}
private static String buildAddressSnapshot(ShopUserAddress addr) {
if (addr == null) {
return null;
}
if (addr.getFullAddress() != null && !addr.getFullAddress().isBlank()) {
return addr.getFullAddress();
}
// 兼容旧数据fullAddress 为空时,拼接省市区 + 详细地址
StringBuilder sb = new StringBuilder();
if (addr.getProvince() != null) sb.append(addr.getProvince());
if (addr.getCity() != null) sb.append(addr.getCity());
if (addr.getRegion() != null) sb.append(addr.getRegion());
if (addr.getAddress() != null) sb.append(addr.getAddress());
String s = sb.toString();
if (!s.isBlank()) {
return s;
}
return addr.getAddress();
}
private static String safe(String s) {
return s == null ? "" : s;
}
}

View File

@@ -40,11 +40,12 @@ public interface GltUserTicketService extends IService<GltUserTicket> {
GltUserTicket getByIdRel(Integer id);
/**
* 统计指定用户水票总数sum(total_qty)
* 统计指定用户可用水票总数sum(available_qty)
*
* @param userId 用户ID
* @return 总数量无记录返回0
* @param tenantId 租户ID
* @return 可用总数无记录返回0
*/
Integer sumTotalQtyByUserId(Integer userId);
Integer sumAvailableQtyByUserId(Integer userId, Integer tenantId);
}

View File

@@ -790,7 +790,7 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
// 幂等:同一送水订单同一配送员只结算一次
boolean already = shopDealerCapitalService.count(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ShopDealerCapital>()
new LambdaQueryWrapper<ShopDealerCapital>()
.eq(ShopDealerCapital::getTenantId, tenantId)
.eq(ShopDealerCapital::getFlowType, 10)
.eq(ShopDealerCapital::getUserId, riderId)
@@ -804,7 +804,7 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
// 送水订单提成:先入冻结金额 freeze_money与分销订单佣金一致
LocalDateTime now = LocalDateTime.now();
boolean updated = shopDealerUserService.update(
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ShopDealerUser>()
new LambdaUpdateWrapper<ShopDealerUser>()
.eq(ShopDealerUser::getTenantId, tenantId)
.eq(ShopDealerUser::getUserId, riderId)
.setSql("freeze_money = IFNULL(freeze_money,0) + " + money.toPlainString())
@@ -815,7 +815,7 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
if (!updated) {
// 配送员可能未开通分销账户:创建后再尝试入账一次(与分销结算逻辑保持一致)
ShopDealerUser existed = shopDealerUserService.getOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<ShopDealerUser>()
new LambdaQueryWrapper<ShopDealerUser>()
.eq(ShopDealerUser::getTenantId, tenantId)
.eq(ShopDealerUser::getUserId, riderId)
.last("limit 1")
@@ -848,7 +848,7 @@ public class GltTicketOrderServiceImpl extends ServiceImpl<GltTicketOrderMapper,
}
updated = shopDealerUserService.update(
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<ShopDealerUser>()
new LambdaUpdateWrapper<ShopDealerUser>()
.eq(ShopDealerUser::getTenantId, tenantId)
.eq(ShopDealerUser::getUserId, riderId)
.setSql("freeze_money = IFNULL(freeze_money,0) + " + money.toPlainString())