diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminWithdrawalController.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminWithdrawalController.java index d81f965..3d4a383 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminWithdrawalController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/AdminWithdrawalController.java @@ -3,15 +3,20 @@ package com.starry.admin.modules.withdraw.controller; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity; import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalLogEntity; import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; +import com.starry.admin.modules.withdraw.service.IEarningsBackfillLogService; +import com.starry.admin.modules.withdraw.service.IEarningsBackfillService; import com.starry.admin.modules.withdraw.service.IEarningsService; import com.starry.admin.modules.withdraw.service.ITenantAlipayConfigService; import com.starry.admin.modules.withdraw.service.IWithdrawalLogService; import com.starry.admin.modules.withdraw.service.IWithdrawalService; import com.starry.admin.modules.withdraw.vo.EarningsAdminQueryVo; import com.starry.admin.modules.withdraw.vo.EarningsAdminSummaryVo; +import com.starry.admin.modules.withdraw.vo.EarningsBackfillRequest; +import com.starry.admin.modules.withdraw.vo.EarningsBackfillResponse; import com.starry.admin.modules.withdraw.vo.WithdrawalRequestQueryVo; import com.starry.admin.utils.SecurityUtils; import com.starry.common.result.TypedR; @@ -40,6 +45,10 @@ public class AdminWithdrawalController { private ITenantAlipayConfigService tenantAlipayConfigService; @Resource private IEarningsService earningsService; + @Resource + private IEarningsBackfillService earningsBackfillService; + @Resource + private IEarningsBackfillLogService backfillLogService; @ApiOperation("分页查询提现请求") @PostMapping("/requests/listByPage") @@ -114,6 +123,23 @@ public class AdminWithdrawalController { return TypedR.ok(summary); } + @ApiOperation("收益补算(试运行或执行)") + @PostMapping("/earnings/backfill") + public TypedR backfill(@RequestBody EarningsBackfillRequest request) { + return TypedR.ok(earningsBackfillService.backfill(request)); + } + + @ApiOperation("收益补算历史记录") + @GetMapping("/earnings/backfill/logs") + public TypedR> listBackfillLogs() { + List logs = backfillLogService.lambdaQuery() + .eq(EarningsBackfillLogEntity::getTenantId, SecurityUtils.getTenantId()) + .orderByDesc(EarningsBackfillLogEntity::getCreatedTime) + .last("limit 20") + .list(); + return TypedR.ok(logs); + } + @ApiOperation("查询当前租户是否已配置支付宝") @GetMapping("/alipay/config/present") public TypedR hasAlipayConfig() { diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java index 97bfe34..6164735 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawController.java @@ -36,7 +36,6 @@ public class WxWithdrawController { @Data public static class CreateWithdrawRequest { private BigDecimal amount; - private String destAccount; // 临时:支付宝登录号/账号 } @ClerkUserLogin @@ -73,8 +72,7 @@ public class WxWithdrawController { throw new CustomException("提现金额必须大于0"); } String clerkId = ThreadLocalRequestDetail.getClerkUserInfo().getId(); - WithdrawalRequestEntity req = withdrawalService.createWithdrawaRequest(clerkId, body.getDestAccount(), - body.getAmount()); + WithdrawalRequestEntity req = withdrawalService.createWithdrawaRequest(clerkId, body.getAmount()); return TypedR.ok(req); } diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawPayeeController.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawPayeeController.java new file mode 100644 index 0000000..a212688 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/controller/WxWithdrawPayeeController.java @@ -0,0 +1,88 @@ +package com.starry.admin.modules.withdraw.controller; + +import com.starry.admin.common.aspect.ClerkUserLogin; +import com.starry.admin.common.conf.ThreadLocalRequestDetail; +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.clerk.module.entity.PlayClerkUserInfoEntity; +import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity; +import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService; +import com.starry.common.result.TypedR; +import javax.annotation.Resource; +import lombok.Data; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/wx/withdraw/payee") +public class WxWithdrawPayeeController { + + private static final String DEFAULT_CHANNEL = "ALIPAY_QR"; + + @Resource + private IClerkPayeeProfileService clerkPayeeProfileService; + + @ClerkUserLogin + @GetMapping + public TypedR getProfile() { + PlayClerkUserInfoEntity clerk = ThreadLocalRequestDetail.getClerkUserInfo(); + ClerkPayeeProfileEntity profile = clerkPayeeProfileService.getByClerk(clerk.getTenantId(), clerk.getId()); + return TypedR.ok(toResponse(profile)); + } + + @ClerkUserLogin + @PostMapping + public TypedR upsert(@RequestBody PayeeProfileRequest body) { + if (body == null || !StringUtils.hasText(body.getQrCodeUrl())) { + throw new CustomException("请上传支付宝收款二维码"); + } + PlayClerkUserInfoEntity clerk = ThreadLocalRequestDetail.getClerkUserInfo(); + String channel = StringUtils.hasText(body.getChannel()) ? body.getChannel() : DEFAULT_CHANNEL; + String displayName = StringUtils.hasText(body.getDisplayName()) ? body.getDisplayName() : clerk.getNickname(); + ClerkPayeeProfileEntity profile = clerkPayeeProfileService.upsertProfile( + clerk.getTenantId(), clerk.getId(), channel, body.getQrCodeUrl(), displayName, clerk.getId()); + return TypedR.ok(toResponse(profile)); + } + + @ClerkUserLogin + @PostMapping("/confirm") + public TypedR confirm() { + PlayClerkUserInfoEntity clerk = ThreadLocalRequestDetail.getClerkUserInfo(); + ClerkPayeeProfileEntity profile = clerkPayeeProfileService.confirmProfile( + clerk.getTenantId(), clerk.getId(), clerk.getId()); + if (profile == null || !StringUtils.hasText(profile.getQrCodeUrl())) { + throw new CustomException("请先上传支付宝收款二维码"); + } + return TypedR.ok(toResponse(profile)); + } + + private PayeeProfileResponse toResponse(ClerkPayeeProfileEntity profile) { + if (profile == null) { + return null; + } + PayeeProfileResponse resp = new PayeeProfileResponse(); + resp.setChannel(profile.getChannel()); + resp.setQrCodeUrl(profile.getQrCodeUrl()); + resp.setDisplayName(profile.getDisplayName()); + resp.setLastConfirmedAt(profile.getLastConfirmedAt()); + return resp; + } + + @Data + public static class PayeeProfileRequest { + private String channel; + private String qrCodeUrl; + private String displayName; + } + + @Data + public static class PayeeProfileResponse { + private String channel; + private String qrCodeUrl; + private String displayName; + private java.time.LocalDateTime lastConfirmedAt; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/ClerkPayeeProfileEntity.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/ClerkPayeeProfileEntity.java new file mode 100644 index 0000000..d0cf3a9 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/ClerkPayeeProfileEntity.java @@ -0,0 +1,23 @@ +package com.starry.admin.modules.withdraw.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.starry.common.domain.BaseEntity; +import java.time.LocalDateTime; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("play_clerk_payee_profile") +public class ClerkPayeeProfileEntity extends BaseEntity { + private String id; + private String tenantId; + private String clerkId; + /** + * 收款渠道(目前固定 ALIPAY_QR) + */ + private String channel; + private String qrCodeUrl; + private String displayName; + private LocalDateTime lastConfirmedAt; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsBackfillLogEntity.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsBackfillLogEntity.java new file mode 100644 index 0000000..f9eda1f --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/EarningsBackfillLogEntity.java @@ -0,0 +1,27 @@ +package com.starry.admin.modules.withdraw.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import com.starry.common.domain.BaseEntity; +import java.time.LocalDateTime; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = false) +@TableName("play_earnings_backfill_log") +public class EarningsBackfillLogEntity extends BaseEntity { + private String id; + private String tenantId; + private String operatorId; + private String operatorName; + private LocalDateTime backfillBeginTime; + private LocalDateTime backfillEndTime; + private String clerkIds; + private Boolean dryRun; + private Integer ordersChecked; + private Integer createdCount; + private Integer skippedExisting; + private Integer warningCount; + private String warnings; + private String comment; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/WithdrawalRequestEntity.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/WithdrawalRequestEntity.java index 3840561..9cd9a22 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/WithdrawalRequestEntity.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/entity/WithdrawalRequestEntity.java @@ -24,4 +24,5 @@ public class WithdrawalRequestEntity extends BaseEntity private String outBizNo; private String providerRef; private String failureReason; + private String payeeSnapshot; } diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/ClerkPayeeProfileMapper.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/ClerkPayeeProfileMapper.java new file mode 100644 index 0000000..90dd056 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/ClerkPayeeProfileMapper.java @@ -0,0 +1,8 @@ +package com.starry.admin.modules.withdraw.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface ClerkPayeeProfileMapper extends BaseMapper {} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsBackfillLogMapper.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsBackfillLogMapper.java new file mode 100644 index 0000000..c431b3e --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/mapper/EarningsBackfillLogMapper.java @@ -0,0 +1,8 @@ +package com.starry.admin.modules.withdraw.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface EarningsBackfillLogMapper extends BaseMapper {} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IClerkPayeeProfileService.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IClerkPayeeProfileService.java new file mode 100644 index 0000000..7878449 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IClerkPayeeProfileService.java @@ -0,0 +1,12 @@ +package com.starry.admin.modules.withdraw.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity; + +public interface IClerkPayeeProfileService extends IService { + ClerkPayeeProfileEntity getByClerk(String tenantId, String clerkId); + + ClerkPayeeProfileEntity upsertProfile(String tenantId, String clerkId, String channel, String qrCodeUrl, String displayName, String operator); + + ClerkPayeeProfileEntity confirmProfile(String tenantId, String clerkId, String operator); +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsBackfillLogService.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsBackfillLogService.java new file mode 100644 index 0000000..4378002 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsBackfillLogService.java @@ -0,0 +1,6 @@ +package com.starry.admin.modules.withdraw.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity; + +public interface IEarningsBackfillLogService extends IService {} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsBackfillService.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsBackfillService.java new file mode 100644 index 0000000..89ba7e4 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IEarningsBackfillService.java @@ -0,0 +1,8 @@ +package com.starry.admin.modules.withdraw.service; + +import com.starry.admin.modules.withdraw.vo.EarningsBackfillRequest; +import com.starry.admin.modules.withdraw.vo.EarningsBackfillResponse; + +public interface IEarningsBackfillService { + EarningsBackfillResponse backfill(EarningsBackfillRequest request); +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IWithdrawalService.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IWithdrawalService.java index 933264b..3a00d3b 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IWithdrawalService.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/IWithdrawalService.java @@ -5,7 +5,7 @@ import com.starry.admin.modules.withdraw.entity.WithdrawalRequestEntity; import java.math.BigDecimal; public interface IWithdrawalService extends IService { - WithdrawalRequestEntity createWithdrawaRequest(String clerkId, String destAccount, BigDecimal amount); + WithdrawalRequestEntity createWithdrawaRequest(String clerkId, BigDecimal amount); void markManualSuccess(String requestId, String operatorBy); diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/ClerkPayeeProfileServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/ClerkPayeeProfileServiceImpl.java new file mode 100644 index 0000000..9d5de98 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/ClerkPayeeProfileServiceImpl.java @@ -0,0 +1,61 @@ +package com.starry.admin.modules.withdraw.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.starry.admin.modules.withdraw.entity.ClerkPayeeProfileEntity; +import com.starry.admin.modules.withdraw.mapper.ClerkPayeeProfileMapper; +import com.starry.admin.modules.withdraw.service.IClerkPayeeProfileService; +import com.starry.common.utils.IdUtils; +import java.time.LocalDateTime; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +public class ClerkPayeeProfileServiceImpl extends ServiceImpl + implements IClerkPayeeProfileService { + + private static final String DEFAULT_CHANNEL = "ALIPAY_QR"; + + @Override + public ClerkPayeeProfileEntity getByClerk(String tenantId, String clerkId) { + return lambdaQuery() + .eq(ClerkPayeeProfileEntity::getTenantId, tenantId) + .eq(ClerkPayeeProfileEntity::getClerkId, clerkId) + .last("limit 1") + .one(); + } + + @Override + public ClerkPayeeProfileEntity upsertProfile(String tenantId, String clerkId, String channel, + String qrCodeUrl, String displayName, String operator) { + String resolvedChannel = StringUtils.hasText(channel) ? channel : DEFAULT_CHANNEL; + ClerkPayeeProfileEntity profile = getByClerk(tenantId, clerkId); + if (profile == null) { + profile = new ClerkPayeeProfileEntity(); + profile.setId(IdUtils.getUuid()); + profile.setTenantId(tenantId); + profile.setClerkId(clerkId); + } + profile.setChannel(resolvedChannel); + profile.setQrCodeUrl(qrCodeUrl); + profile.setDisplayName(displayName); + profile.setLastConfirmedAt(null); // force confirmation before next withdrawal + profile.setUpdatedBy(operator); + if (profile.getCreatedBy() == null) { + profile.setCreatedBy(operator); + } + this.saveOrUpdate(profile); + return profile; + } + + @Override + public ClerkPayeeProfileEntity confirmProfile(String tenantId, String clerkId, String operator) { + ClerkPayeeProfileEntity profile = getByClerk(tenantId, clerkId); + if (profile == null) { + return null; + } + profile.setLastConfirmedAt(LocalDateTime.now()); + profile.setUpdatedBy(operator); + updateById(profile); + return profile; + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsBackfillLogServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsBackfillLogServiceImpl.java new file mode 100644 index 0000000..f8938ae --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsBackfillLogServiceImpl.java @@ -0,0 +1,11 @@ +package com.starry.admin.modules.withdraw.service.impl; + +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity; +import com.starry.admin.modules.withdraw.mapper.EarningsBackfillLogMapper; +import com.starry.admin.modules.withdraw.service.IEarningsBackfillLogService; +import org.springframework.stereotype.Service; + +@Service +public class EarningsBackfillLogServiceImpl extends ServiceImpl + implements IEarningsBackfillLogService {} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsBackfillServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsBackfillServiceImpl.java new file mode 100644 index 0000000..9ea70b1 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsBackfillServiceImpl.java @@ -0,0 +1,212 @@ +package com.starry.admin.modules.withdraw.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.starry.admin.common.exception.CustomException; +import com.starry.admin.modules.order.module.entity.PlayOrderInfoEntity; +import com.starry.admin.modules.order.service.IPlayOrderInfoService; +import com.starry.admin.modules.withdraw.entity.EarningsBackfillLogEntity; +import com.starry.admin.modules.withdraw.entity.EarningsLineEntity; +import com.starry.admin.modules.withdraw.service.IEarningsBackfillLogService; +import com.starry.admin.modules.withdraw.service.IEarningsBackfillService; +import com.starry.admin.modules.withdraw.service.IEarningsService; +import com.starry.admin.modules.withdraw.vo.BackfillOrderPreview; +import com.starry.admin.modules.withdraw.vo.EarningsBackfillRequest; +import com.starry.admin.modules.withdraw.vo.EarningsBackfillResponse; +import com.starry.admin.utils.SecurityUtils; +import com.starry.common.utils.IdUtils; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.List; +import javax.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; + +@Service +public class EarningsBackfillServiceImpl implements IEarningsBackfillService { + + private static final DateTimeFormatter ISO_DATE_TIME = DateTimeFormatter.ISO_DATE_TIME; + private static final DateTimeFormatter SIMPLE_DATE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + @Resource + private IPlayOrderInfoService orderInfoService; + @Resource + private IEarningsService earningsService; + @Resource + private IEarningsBackfillLogService backfillLogService; + + @Override + @Transactional(rollbackFor = Exception.class) + public EarningsBackfillResponse backfill(EarningsBackfillRequest request) { + if (request == null) { + throw new CustomException("参数不能为空"); + } + String tenantId = SecurityUtils.getTenantId(); + if (tenantId == null) { + throw new CustomException("租户上下文为空"); + } + LocalDateTime begin = parseDate(request.getBeginTime()); + LocalDateTime end = parseDate(request.getEndTime()); + if (begin != null && end != null && begin.isAfter(end)) { + throw new CustomException("起始时间不能晚于结束时间"); + } + boolean dryRun = Boolean.TRUE.equals(request.getDryRun()); + + LambdaQueryWrapper query = new LambdaQueryWrapper<>(); + query.eq(PlayOrderInfoEntity::getTenantId, tenantId) + .eq(PlayOrderInfoEntity::getOrderStatus, "3"); + if (begin != null) { + query.ge(PlayOrderInfoEntity::getOrderEndTime, begin); + } + if (end != null) { + query.le(PlayOrderInfoEntity::getOrderEndTime, end); + } + if (!CollectionUtils.isEmpty(request.getClerkIds())) { + query.in(PlayOrderInfoEntity::getAcceptBy, request.getClerkIds()); + } + List orders = orderInfoService.list(query); + if (orders.size() > 2000) { + throw new CustomException("符合条件的订单过多,请缩小时间范围或筛选店员后再试"); + } + + int ordersChecked = 0; + int createdCount = 0; + int skippedExisting = 0; + List warnings = new ArrayList<>(); + List previews = new ArrayList<>(); + final int previewLimit = 50; + + for (PlayOrderInfoEntity order : orders) { + ordersChecked++; + BigDecimal amount = resolveAmount(order, warnings); + if (amount == null) { + continue; + } + if (hasExistingEarnings(tenantId, order.getId())) { + skippedExisting++; + continue; + } + if (previews.size() < previewLimit) { + BackfillOrderPreview preview = new BackfillOrderPreview(); + preview.setOrderId(order.getId()); + preview.setClerkId(order.getAcceptBy()); + preview.setAmount(amount); + preview.setOrderEndTime(order.getOrderEndTime()); + previews.add(preview); + } + if (dryRun) { + createdCount++; + continue; + } + earningsService.createFromOrder(order); + if (hasExistingEarnings(tenantId, order.getId())) { + createdCount++; + } else { + addWarning(warnings, "订单 " + order.getId() + " 创建收益失败"); + } + } + + EarningsBackfillResponse response = new EarningsBackfillResponse(); + response.setDryRun(dryRun); + response.setOrdersChecked(ordersChecked); + response.setCreatedCount(createdCount); + response.setSkippedExisting(skippedExisting); + response.setWarnings(warnings); + response.setOrders(previews); + + // persist log + EarningsBackfillLogEntity log = new EarningsBackfillLogEntity(); + log.setId(IdUtils.getUuid()); + log.setTenantId(tenantId); + log.setOperatorId(safe(SecurityUtils::getUserId)); + log.setOperatorName(safe(SecurityUtils::getUsername)); + log.setBackfillBeginTime(begin); + log.setBackfillEndTime(end); + if (!CollectionUtils.isEmpty(request.getClerkIds())) { + log.setClerkIds(JSON.toJSONString(request.getClerkIds())); + } + log.setDryRun(dryRun); + log.setOrdersChecked(ordersChecked); + log.setCreatedCount(createdCount); + log.setSkippedExisting(skippedExisting); + log.setWarningCount(warnings.size()); + if (!warnings.isEmpty()) { + log.setWarnings(JSON.toJSONString(warnings)); + } + log.setComment(request.getComment()); + log.setCreatedBy(log.getOperatorId()); + log.setUpdatedBy(log.getOperatorId()); + backfillLogService.save(log); + response.setLogId(log.getId()); + return response; + } + + private boolean hasExistingEarnings(String tenantId, String orderId) { + Long count = earningsService.lambdaQuery() + .eq(EarningsLineEntity::getTenantId, tenantId) + .eq(EarningsLineEntity::getOrderId, orderId) + .count(); + return count != null && count > 0; + } + + private BigDecimal resolveAmount(PlayOrderInfoEntity order, List warnings) { + if (order == null) { + return null; + } + if (order.getAcceptBy() == null) { + addWarning(warnings, "订单 " + order.getId() + " 无接单人,跳过"); + return null; + } + BigDecimal amount = order.getEstimatedRevenue() != null ? order.getEstimatedRevenue() + : (order.getFinalAmount() != null ? order.getFinalAmount() : BigDecimal.ZERO); + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + addWarning(warnings, "订单 " + order.getId() + " 收益金额为0,跳过"); + return null; + } + if (order.getOrderEndTime() == null) { + addWarning(warnings, "订单 " + order.getId() + " 缺少结束时间,跳过"); + return null; + } + return amount; + } + + private void addWarning(List warnings, String message) { + if (warnings.size() >= 50) { + return; + } + warnings.add(message); + } + + private LocalDateTime parseDate(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + String trimmed = value.trim(); + List formatters = new ArrayList<>(); + formatters.add(ISO_DATE_TIME); + formatters.add(SIMPLE_DATE_TIME); + for (DateTimeFormatter formatter : formatters) { + try { + return LocalDateTime.parse(trimmed, formatter); + } catch (DateTimeParseException ignored) { + } + } + throw new CustomException("无法解析时间:" + value); + } + + private interface SupplierWithException { + T get() throws Exception; + } + + private String safe(SupplierWithException supplier) { + try { + return supplier.get(); + } catch (Exception ignored) { + return null; + } + } +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java index 5c1788f..f70eace 100644 --- a/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/service/impl/EarningsServiceImpl.java @@ -36,6 +36,7 @@ public class EarningsServiceImpl extends ServiceImpl implements IWithdrawalService { + private static final long PAYEE_CONFIRMATION_MAX_MINUTES = 10L; + @Resource private IEarningsService earningsService; @Resource private IWithdrawalLogService withdrawalLogService; + @Resource + private IClerkPayeeProfileService clerkPayeeProfileService; @Override @Transactional(rollbackFor = Exception.class) - public WithdrawalRequestEntity createWithdrawaRequest(String clerkId, String destAccount, BigDecimal amount) { + public WithdrawalRequestEntity createWithdrawaRequest(String clerkId, BigDecimal amount) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw new CustomException("提现金额必须大于0"); } - BigDecimal available = earningsService.getAvailableAmount(clerkId, LocalDateTime.now()); + LocalDateTime now = LocalDateTime.now(); + String tenantId = SecurityUtils.getTenantId(); + ClerkPayeeProfileEntity payeeProfile = clerkPayeeProfileService.getByClerk(tenantId, clerkId); + if (payeeProfile == null || !StringUtils.hasText(payeeProfile.getQrCodeUrl())) { + throw new CustomException("请先上传支付宝收款码"); + } + if (payeeProfile.getLastConfirmedAt() == null) { + throw new CustomException("请确认本次使用的收款码"); + } + Duration sinceConfirm = Duration.between(payeeProfile.getLastConfirmedAt(), now); + if (sinceConfirm.isNegative() || sinceConfirm.toMinutes() > PAYEE_CONFIRMATION_MAX_MINUTES) { + throw new CustomException("收款码确认已过期,请重新确认"); + } + + BigDecimal available = earningsService.getAvailableAmount(clerkId, now); if (available.compareTo(amount) < 0) { throw new CustomException("可提现余额不足"); } // pick and reserve lines - List lines = earningsService.findWithdrawable(clerkId, amount, LocalDateTime.now()); + List lines = earningsService.findWithdrawable(clerkId, amount, now); if (lines.isEmpty()) throw new CustomException("可提现余额不足"); + LocalDateTime confirmedAt = payeeProfile.getLastConfirmedAt(); + String snapshotJson = buildPayeeSnapshot(payeeProfile, confirmedAt); + WithdrawalRequestEntity req = new WithdrawalRequestEntity(); req.setId(IdUtils.getUuid()); req.setClerkId(clerkId); - req.setTenantId(SecurityUtils.getTenantId()); + req.setTenantId(tenantId); req.setAmount(amount); req.setFee(BigDecimal.ZERO); // fee on tenant; not deducted from clerk req.setNetAmount(amount); - req.setDestAccount(destAccount); + req.setDestAccount(payeeProfile.getDisplayName()); + req.setPayeeSnapshot(snapshotJson); req.setStatus("pending"); req.setOutBizNo(req.getId()); this.save(req); // log created withdrawalLogService.log(req.getTenantId(), clerkId, req.getId(), "CREATED", null, req.getStatus(), "提现申请创建", null); + withdrawalLogService.log(req.getTenantId(), clerkId, req.getId(), + "PAYEE_CONFIRMED", req.getStatus(), req.getStatus(), "店员确认收款码", snapshotJson); int reservedCount = 0; for (EarningsLineEntity line : lines) { @@ -68,6 +98,11 @@ public class WithdrawalServiceImpl extends ServiceImpl clerkIds; + private Boolean dryRun; + private String comment; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/EarningsBackfillResponse.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/EarningsBackfillResponse.java new file mode 100644 index 0000000..a66bfe6 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/EarningsBackfillResponse.java @@ -0,0 +1,15 @@ +package com.starry.admin.modules.withdraw.vo; + +import java.util.List; +import lombok.Data; + +@Data +public class EarningsBackfillResponse { + private boolean dryRun; + private int ordersChecked; + private int createdCount; + private int skippedExisting; + private List warnings; + private String logId; + private List orders; +} diff --git a/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/PayeeSnapshotVo.java b/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/PayeeSnapshotVo.java new file mode 100644 index 0000000..98730c8 --- /dev/null +++ b/play-admin/src/main/java/com/starry/admin/modules/withdraw/vo/PayeeSnapshotVo.java @@ -0,0 +1,12 @@ +package com.starry.admin.modules.withdraw.vo; + +import java.time.LocalDateTime; +import lombok.Data; + +@Data +public class PayeeSnapshotVo { + private String channel; + private String qrCodeUrl; + private String displayName; + private LocalDateTime confirmedAt; +} diff --git a/play-admin/src/main/resources/db/migration/V7__add_clerk_payee_profile.sql b/play-admin/src/main/resources/db/migration/V7__add_clerk_payee_profile.sql new file mode 100644 index 0000000..398b62e --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V7__add_clerk_payee_profile.sql @@ -0,0 +1,22 @@ +-- Clerk payee QR profile and withdrawal snapshot + +CREATE TABLE IF NOT EXISTS `play_clerk_payee_profile` ( + `id` varchar(32) NOT NULL COMMENT 'UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID', + `clerk_id` varchar(32) NOT NULL COMMENT '店员ID', + `channel` varchar(32) NOT NULL DEFAULT 'ALIPAY_QR' COMMENT '收款渠道(例如:ALIPAY_QR)', + `qr_code_url` varchar(512) NOT NULL COMMENT '收款二维码地址', + `display_name` varchar(64) DEFAULT NULL COMMENT '收款码显示名称', + `last_confirmed_at` datetime DEFAULT NULL COMMENT '最近确认时间', + `created_by` varchar(32) DEFAULT NULL, + `created_time` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_by` varchar(32) DEFAULT NULL, + `updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除 1已删除 0未删除', + `version` int NOT NULL DEFAULT '1' COMMENT '数据版本', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_clerk_payee` (`tenant_id`, `clerk_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='店员提现收款码档案'; + +ALTER TABLE `play_withdrawal_request` + ADD COLUMN `payee_snapshot` text COMMENT '提现时的收款码快照(JSON)'; diff --git a/play-admin/src/main/resources/db/migration/V8__populate_default_freeze_policy.sql b/play-admin/src/main/resources/db/migration/V8__populate_default_freeze_policy.sql new file mode 100644 index 0000000..fc595c2 --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V8__populate_default_freeze_policy.sql @@ -0,0 +1,23 @@ +-- Ensure each active tenant has a default freeze policy (7 days) + +INSERT INTO `play_freeze_policy` (`id`, `tenant_id`, `clerk_id`, `freeze_hours`, `created_by`, `created_time`, `updated_by`, `updated_time`, `deleted`, `version`) +SELECT + REPLACE(UUID(), '-', ''), + t.`tenant_id`, + NULL, + 168, + 'migration_v8_default_freeze', + NOW(), + 'migration_v8_default_freeze', + NOW(), + 0, + 1 +FROM `sys_tenant` t +WHERE (t.`deleted` IS NULL OR t.`deleted` = 0) + AND NOT EXISTS ( + SELECT 1 + FROM `play_freeze_policy` p + WHERE p.`tenant_id` = t.`tenant_id` + AND p.`clerk_id` IS NULL + AND (p.`deleted` IS NULL OR p.`deleted` = 0) + ); diff --git a/play-admin/src/main/resources/db/migration/V9__create_earnings_backfill_log.sql b/play-admin/src/main/resources/db/migration/V9__create_earnings_backfill_log.sql new file mode 100644 index 0000000..14fc853 --- /dev/null +++ b/play-admin/src/main/resources/db/migration/V9__create_earnings_backfill_log.sql @@ -0,0 +1,27 @@ +-- Audit log for earnings backfill executions + +CREATE TABLE IF NOT EXISTS `play_earnings_backfill_log` ( + `id` varchar(32) NOT NULL COMMENT 'UUID', + `tenant_id` varchar(32) NOT NULL COMMENT '租户ID', + `operator_id` varchar(32) DEFAULT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称', + `backfill_begin_time` datetime DEFAULT NULL COMMENT '查询起始时间', + `backfill_end_time` datetime DEFAULT NULL COMMENT '查询结束时间', + `clerk_ids` text COMMENT '指定店员ID集合(JSON)', + `dry_run` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否试运行', + `orders_checked` int NOT NULL DEFAULT '0' COMMENT '检查订单数', + `created_count` int NOT NULL DEFAULT '0' COMMENT '新增收益条目数', + `skipped_existing` int NOT NULL DEFAULT '0' COMMENT '跳过已存在收益条目数', + `warning_count` int NOT NULL DEFAULT '0' COMMENT '警告数量', + `warnings` text COMMENT '警告明细(JSON)', + `comment` varchar(255) DEFAULT NULL COMMENT '备注', + `created_time` datetime DEFAULT CURRENT_TIMESTAMP, + `created_by` varchar(32) DEFAULT NULL, + `updated_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `updated_by` varchar(32) DEFAULT NULL, + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除 1已删除 0未删除', + `version` int NOT NULL DEFAULT '1' COMMENT '数据版本', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_backfill_tenant_time` (`tenant_id`, `created_time`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='收益补算执行记录'; +