Commit 0ac1d533 by jianan

转介人门户:OIDC 登录与行级隔离,新增仪表盘/保单/在途佣金/月结单/待办接口

parent b81c107f
package com.yd.csf.api.controller;
import com.yd.common.result.Result;
import com.yd.csf.api.security.ReferrerIdentityContext;
import com.yd.csf.service.service.IReferrerPortalService;
import com.yd.csf.service.vo.ReferrerDashboardVO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* 转介人门户 - 首页指标。
*
* <p>容器内路径 {@code /c/dashboard/**},对外经 context-path 暴露为
* {@code /csf/api/c/dashboard/**},走转介人门户独立安全链。</p>
*/
@RestController
@RequestMapping("/c/dashboard")
public class ReferrerDashboardController {
@Resource
private IReferrerPortalService referrerPortalService;
/**
* 首页指标:本月已发 / 本课税年度累计已发 / 在途佣金 / 生效件数 / 首年 FYP。
*/
@GetMapping("/summary")
public Result<ReferrerDashboardVO> summary() {
String brokerBizId = ReferrerIdentityContext.requireBrokerBizId();
return Result.success(referrerPortalService.getDashboard(brokerBizId));
}
}
package com.yd.csf.api.controller;
import com.yd.common.result.Result;
import com.yd.csf.api.security.ReferrerIdentityContext;
import com.yd.csf.service.service.IReferrerPortalService;
import com.yd.csf.service.vo.ReferrerPipelineVO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
/**
* 转介人门户 - 在途佣金。
*
* <p>容器内路径 {@code /c/pipeline/**},对外经 context-path 暴露为
* {@code /csf/api/c/pipeline/**},走转介人门户独立安全链。
* 展示待出账的预计发佣及预计到账日;保留/失效翻译为可读文案。</p>
*/
@RestController
@RequestMapping("/c/pipeline")
public class ReferrerPipelineController {
@Resource
private IReferrerPortalService referrerPortalService;
/**
* 在途佣金列表:含预计到账日,按 payout_date 升序。
*/
@GetMapping("/list")
public Result<List<ReferrerPipelineVO>> list() {
String brokerBizId = ReferrerIdentityContext.requireBrokerBizId();
return Result.success(referrerPortalService.listPipeline(brokerBizId));
}
}
package com.yd.csf.api.controller;
import com.yd.common.result.Result;
import com.yd.csf.api.security.ReferrerIdentityContext;
import com.yd.csf.service.service.IReferrerPortalService;
import com.yd.csf.service.vo.ReferrerPolicyCommissionVO;
import com.yd.csf.service.vo.ReferrerPolicyListVO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
/**
* 转介人门户 - 我的保单。
*
* <p>容器内路径 {@code /c/policy/**},对外经 context-path 暴露为
* {@code /csf/api/c/policy/**},走转介人门户独立安全链。
* 生效保单整单展示;退保/失效/拒保分组可见不计件。</p>
*/
@RestController
@RequestMapping("/c/policy")
public class ReferrerPolicyController {
@Resource
private IReferrerPortalService referrerPortalService;
/**
* 我的保单:生效 / 未生效失效两组。
*/
@GetMapping("/list")
public Result<ReferrerPolicyListVO> list() {
String brokerBizId = ReferrerIdentityContext.requireBrokerBizId();
return Result.success(referrerPortalService.listPolicies(brokerBizId));
}
/**
* 保单各年度佣金钻取:首年(FYP/FYC)与续期(RYP/RYC)分列。
*/
@GetMapping("/{policyNo}/commission")
public Result<ReferrerPolicyCommissionVO> commission(@PathVariable String policyNo) {
String brokerBizId = ReferrerIdentityContext.requireBrokerBizId();
return Result.success(referrerPortalService.getPolicyCommission(brokerBizId, policyNo));
}
}
package com.yd.csf.api.controller;
import com.yd.common.result.Result;
import com.yd.csf.api.security.ReferrerIdentityContext;
import com.yd.csf.service.service.IReferrerPortalService;
import com.yd.csf.service.vo.ReferrerStatementDetailVO;
import com.yd.csf.service.vo.ReferrerStatementVO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
/**
* 转介人门户 - 月结单。
*
* <p>容器内路径 {@code /c/statement/**},对外经 context-path 暴露为
* {@code /csf/api/c/statement/**},走转介人门户独立安全链。
* 仅展示已推送(salary.status=5)的薪资单;头部与逐笔明细各自忠实呈现,不强制二者相等。</p>
*/
@RestController
@RequestMapping("/c/statement")
public class ReferrerStatementController {
@Resource
private IReferrerPortalService referrerPortalService;
/**
* 月结单列表:仅已推送,按月份降序。
*/
@GetMapping("/list")
public Result<List<ReferrerStatementVO>> list() {
String brokerBizId = ReferrerIdentityContext.requireBrokerBizId();
return Result.success(referrerPortalService.listStatements(brokerBizId));
}
/**
* 月结单详情:头部应发/MPF/其他调整/实发/发放日/银行尾号 + 逐笔保单发佣明细。
*/
@GetMapping("/{salaryBizId}")
public Result<ReferrerStatementDetailVO> detail(@PathVariable String salaryBizId) {
String brokerBizId = ReferrerIdentityContext.requireBrokerBizId();
return Result.success(referrerPortalService.getStatementDetail(brokerBizId, salaryBizId));
}
}
package com.yd.csf.api.controller;
import com.yd.common.result.Result;
import com.yd.csf.api.dto.ReferrerTodoNotifyRequest;
import com.yd.csf.api.security.ReferrerIdentityContext;
import com.yd.csf.service.service.IReferrerTodoService;
import org.springframework.validation.annotation.Validated;
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;
import javax.annotation.Resource;
/**
* 转介人待办通知留痕接口
*/
@RestController
@RequestMapping("/c/todo")
public class ReferrerTodoController {
@Resource
private IReferrerTodoService referrerTodoService;
/**
* 已通知 / 处理留痕
*/
@PostMapping("/notify")
public Result<Boolean> notify(@Validated @RequestBody ReferrerTodoNotifyRequest request) {
String clientUserBizId = ReferrerIdentityContext.requireClientUserBizId();
referrerTodoService.recordNotify(clientUserBizId, request.getPolicyNo(), request.getTodoType());
return Result.success(Boolean.TRUE);
}
}
\ No newline at end of file
package com.yd.csf.api.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 待办通知留痕请求
*/
@Data
public class ReferrerTodoNotifyRequest {
@NotBlank(message = "保单号不能为空")
private String policyNo;
@NotBlank(message = "待办类型不能为空")
private String todoType;
}
\ No newline at end of file
package com.yd.csf.api.security;
import com.yd.common.exception.BusinessException;
/**
* 当前转介人登录身份上下文。
*
* <p>业务接口只从本类取登录身份,不接收前端身份入参。</p>
*
* <p>身份说明:csf 库的 {@code broker_biz_id}(policy_broker / salary / fortune /
* expected_fortune 等行级归属字段)与 IAM 的 {@code clientUserBizId} 是<b>同一个 ID、
* 同一命名空间</b>,并非两套主键。依据:{@code policy_broker.broker_biz_id} 实际落值为
* {@code client_user_xxx};预计发佣生成逻辑中直接
* {@code resDto.setClientUserBizId(brokerDto.getBrokerBizId())},
* {@code ConditionConfigMapper} 亦以 {@code agent_id AS brokerBizId}。
* 因此行级数据查询直接用登录 {@code clientUserBizId} 作为 {@code brokerBizId},
* 无需任何映射表或手机号反查。</p>
*/
public final class ReferrerIdentityContext {
private static final ThreadLocal<String> CLIENT_USER_BIZ_ID = new ThreadLocal<>();
private ReferrerIdentityContext() {
}
public static void setClientUserBizId(String clientUserBizId) {
CLIENT_USER_BIZ_ID.set(clientUserBizId);
}
public static String getClientUserBizId() {
return CLIENT_USER_BIZ_ID.get();
}
public static String requireClientUserBizId() {
String clientUserBizId = CLIENT_USER_BIZ_ID.get();
if (clientUserBizId == null || clientUserBizId.trim().isEmpty()) {
throw new BusinessException("缺失转介人登录身份");
}
return clientUserBizId;
}
/**
* 当前登录转介人在 csf 行级数据上使用的 {@code brokerBizId}。
*
* <p>当前与 {@link #requireClientUserBizId()} 同值(恒等),语义化方法:
* Controller 取行级归属 ID 应调用本方法,而非直接拿 clientUserBizId。
* 若将来出现独立 broker 命名空间,只需在此桥接一处。</p>
*/
public static String requireBrokerBizId() {
return requireClientUserBizId();
}
public static void clear() {
CLIENT_USER_BIZ_ID.remove();
}
}
\ No newline at end of file
package com.yd.csf.api.security;
import com.yd.common.enums.CommonEnum;
import com.yd.common.result.Result;
import com.yd.user.feign.client.clientuser.ApiClientUserFeignClient;
import com.yd.user.feign.response.clientuser.ApiClientUserIdpLoginResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtException;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
import java.util.Objects;
/**
* 转介人门户身份过滤器。
*
* <p>两种身份来源,按优先级:</p>
* <ol>
* <li>请求携带 {@code Authorization: Bearer} 且配置了 JWKS:用 {@link JwtDecoder} 校验
* RS256 + iss + aud,取 {@code sub} 调 yd-user 解析客户端用户,
* 并强制 {@code status=1 && approval_status=CG} 门禁;</li>
* <li>未携带 token 但配置了 {@code referrer.oidc.mock-client-user-biz-id}:注入固定身份,
* 仅供开发期联调(生产不得配置)。</li>
* </ol>
*
* <p>无法建立身份时不写入 SecurityContext,由链内授权规则返回 401。</p>
*/
@Slf4j
public class ReferrerIdentityFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer ";
private final ReferrerOidcProperties properties;
private final ApiClientUserFeignClient clientUserFeignClient;
private final JwtDecoder jwtDecoder;
public ReferrerIdentityFilter(ReferrerOidcProperties properties,
ApiClientUserFeignClient clientUserFeignClient,
JwtDecoder jwtDecoder) {
this.properties = properties;
this.clientUserFeignClient = clientUserFeignClient;
this.jwtDecoder = jwtDecoder;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
boolean authenticated = false;
String token = resolveBearerToken(request);
if (StringUtils.hasText(token) && jwtDecoder != null) {
authenticated = authenticateWithJwt(token);
} else if (StringUtils.hasText(token)) {
log.warn("收到 Bearer token 但 JwtDecoder 未初始化:referrer.oidc.jwk-set-uri 是否配置?");
} else if (StringUtils.hasText(properties.getMockClientUserBizId())) {
applyIdentity(properties.getMockClientUserBizId());
authenticated = true;
} else {
log.debug("请求未携带 Bearer token,且未配置 mock 身份");
}
if (!authenticated) {
filterChain.doFilter(request, response);
return;
}
try {
filterChain.doFilter(request, response);
} finally {
ReferrerIdentityContext.clear();
}
}
/**
* 验签并按 sub 解析转介人身份;任何校验失败返回 false(不抛错、不泄露原因)。
*/
private boolean authenticateWithJwt(String token) {
Jwt jwt;
try {
jwt = jwtDecoder.decode(token);
} catch (JwtException | IllegalArgumentException e) {
log.warn("JWT 验签/校验失败:{}", e.getMessage());
return false;
}
String sub = jwt.getSubject();
if (!StringUtils.hasText(sub)) {
log.warn("JWT 缺少 sub claim");
return false;
}
Result<ApiClientUserIdpLoginResponse> result =
clientUserFeignClient.getByIdpSub(properties.getTenantBizId(), sub);
if (Objects.isNull(result)) {
log.warn("调用 yd-user getByIdpSub 失败(Feign 降级返回 null),tenantBizId={} sub={}",
properties.getTenantBizId(), sub);
return false;
}
if (Objects.isNull(result.getData())) {
log.warn("IdP 身份未绑定 client_user 或租户不匹配,tenantBizId={} sub={} code={} msg={}",
properties.getTenantBizId(), sub, result.getCode(), result.getMsg());
return false;
}
ApiClientUserIdpLoginResponse login = result.getData();
if (!Integer.valueOf(1).equals(login.getStatus())) {
log.warn("转介人账号已停用 status={} clientUserBizId={}", login.getStatus(), login.getClientUserBizId());
return false;
}
if (!CommonEnum.APPROVAL_STATUS_CG.getCode().equals(login.getApprovalStatus())) {
log.warn("转介人审批状态不可登录 approvalStatus={} clientUserBizId={}",
login.getApprovalStatus(), login.getClientUserBizId());
return false;
}
applyIdentity(login.getClientUserBizId());
return true;
}
private void applyIdentity(String clientUserBizId) {
ReferrerIdentityContext.setClientUserBizId(clientUserBizId);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(clientUserBizId, null, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
private String resolveBearerToken(HttpServletRequest request) {
String header = request.getHeader("Authorization");
if (StringUtils.hasText(header) && header.startsWith(BEARER_PREFIX)) {
return header.substring(BEARER_PREFIX.length()).trim();
}
return null;
}
}
package com.yd.csf.api.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.stereotype.Component;
/**
* 转介人门户 OIDC 配置占位。
*
* <p>真实值由 Nacos 提供,开发期可只配置 {@code mockClientUserBizId} 跑通数据接口。</p>
*/
@Configuration
public class ReferrerOidcProperties {
@Value("${referrer.oidc.jwk-set-uri:}")
private String jwkSetUri;
@Value("${referrer.oidc.issuer:}")
private String issuer;
@Value("${referrer.oidc.audience:}")
private String audience;
/**
* 转介人所属租户业务ID(csf 当前为单租户部署,认证时身份解析限定该租户)
*/
@Value("${referrer.oidc.tenant-biz-id:}")
private String tenantBizId;
@Value("${referrer.oidc.mock-client-user-biz-id:}")
private String mockClientUserBizId;
public String getJwkSetUri() {
return jwkSetUri;
}
public String getIssuer() {
return issuer;
}
public String getAudience() {
return audience;
}
public String getTenantBizId() {
return tenantBizId;
}
public String getMockClientUserBizId() {
return mockClientUserBizId;
}
}
\ No newline at end of file
package com.yd.csf.api.security;
import com.yd.user.feign.client.clientuser.ApiClientUserFeignClient;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
/**
* 转介人门户独立安全链。
*
* <p>必须沿用 {@link WebSecurityConfigurerAdapter} 老式风格:yd-auth-core 的后台链也是
* Adapter 风格,Spring Security 5.6 不允许 Adapter 与 {@code SecurityFilterChain} Bean 混用,
* 否则启动报 “Found WebSecurityConfigurerAdapter as well as SecurityFilterChain”。</p>
*
* <p>多个 Adapter 凭 {@link Order} 共存:本链 {@code @Order(1)} 优先匹配容器内
* {@code /c/**}(对外 {@code /csf/api/c/**}),其余请求落老链(兜底 {@code /**})。</p>
*/
@Configuration
@Order(1)
public class ReferrerPortalSecurityConfig extends WebSecurityConfigurerAdapter {
private final ReferrerOidcProperties properties;
private final ApiClientUserFeignClient clientUserFeignClient;
public ReferrerPortalSecurityConfig(ReferrerOidcProperties properties,
ApiClientUserFeignClient clientUserFeignClient) {
this.properties = properties;
this.clientUserFeignClient = clientUserFeignClient;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.antMatcher("/c/**")
.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.exceptionHandling()
// 未携带有效身份时直接 401,避免落到默认 403/登录页
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.and()
.authorizeRequests()
.anyRequest().authenticated()
.and()
// 链内手动 new:不加 @Component,避免被 Boot 注册成容器级 Filter 污染老后台 /** 链
.addFilterBefore(new ReferrerIdentityFilter(properties, clientUserFeignClient, buildJwtDecoder()),
UsernamePasswordAuthenticationFilter.class);
}
/**
* 构建 RS256 JWKS 解码器,校验时间戳 + iss + aud(aud 为数组时要求包含配置 audience)。
* 未配置 jwk-set-uri 时返回 null,过滤器退化为开发期 mock 身份。
*/
private JwtDecoder buildJwtDecoder() {
if (!StringUtils.hasText(properties.getJwkSetUri())) {
return null;
}
NimbusJwtDecoder decoder = NimbusJwtDecoder.withJwkSetUri(properties.getJwkSetUri())
.jwsAlgorithm(SignatureAlgorithm.RS256)
.build();
List<OAuth2TokenValidator<Jwt>> validators = new ArrayList<>();
validators.add(new JwtTimestampValidator());
if (StringUtils.hasText(properties.getIssuer())) {
validators.add(new JwtIssuerValidator(properties.getIssuer()));
}
if (StringUtils.hasText(properties.getAudience())) {
String audience = properties.getAudience();
validators.add(new JwtClaimValidator<Collection<?>>("aud",
aud -> aud != null && aud.contains(audience)));
}
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(validators));
return decoder;
}
}
package com.yd.csf.service.dao;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yd.csf.service.model.ReferrerTodo;
/**
* 转介人待办通知留痕表 Mapper
*/
public interface ReferrerTodoMapper extends BaseMapper<ReferrerTodo> {
}
\ No newline at end of file
package com.yd.csf.service.enums;
/**
* 转介人门户佣金四态(design D4.1)。
*
* <p>系统内部出账状态(expected_fortune/fortune 0–7、salary 1–6)不直接对外,
* 统一收敛为转介人语言的四个「钱的位置」。守恒式(互斥且闭合):
* {@code 应得 = 已发 + 在途 + 保留 + 失效}。</p>
*/
public enum ReferrerCommissionStage {
/** 已发:已完成出账、已到手。 */
PAID,
/** 在途:钱会来、尚未到(待出账、部分出账的未付段、分期未到期、未找到来佣 7)。 */
PIPELINE,
/** 保留:暂扣、未说不给。 */
RETAINED,
/** 失效:这笔没了。 */
LAPSED;
/**
* expected_fortune 整行状态 -> 四态(用于只展示剩余待发的在途列表)。
* 不产生 {@link #PAID}:全额出账无独立状态码,已发须按 paid/unpaid 金额判定。
*/
public static ReferrerCommissionStage fromExpectedStatus(String status) {
if ("4".equals(status)) {
return RETAINED;
}
if ("5".equals(status)) {
return LAPSED;
}
// 0/1/2/3/6/7 均按在途;7(未找到来佣)遵 D4.1 归在途,不得判失效。
return PIPELINE;
}
}
package com.yd.csf.service.model;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Getter;
import lombok.Setter;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 转介人待办通知留痕表
*/
@Getter
@Setter
@TableName("referrer_todo")
public class ReferrerTodo implements Serializable {
private static final long serialVersionUID = 1L;
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField("todo_biz_id")
private String todoBizId;
@TableField("broker_biz_id")
private String brokerBizId;
@TableField("policy_no")
private String policyNo;
@TableField("todo_type")
private String todoType;
@TableField("notify_time")
private LocalDateTime notifyTime;
@TableField("create_time")
private LocalDateTime createTime;
@TableField("update_time")
private LocalDateTime updateTime;
}
\ No newline at end of file
package com.yd.csf.service.service;
import com.yd.csf.service.vo.ReferrerDashboardVO;
import com.yd.csf.service.vo.ReferrerPipelineVO;
import com.yd.csf.service.vo.ReferrerPolicyCommissionVO;
import com.yd.csf.service.vo.ReferrerPolicyListVO;
import com.yd.csf.service.vo.ReferrerStatementDetailVO;
import com.yd.csf.service.vo.ReferrerStatementVO;
import java.util.List;
/**
* 转介人门户只读数据服务。
*
* <p>所有方法均要求调用方传入已解析的转介人 {@code brokerBizId},
* 服务层不接收任何前端身份入参。</p>
*/
public interface IReferrerPortalService {
/**
* 首页指标。
*/
ReferrerDashboardVO getDashboard(String brokerBizId);
/**
* 月结单列表(仅已推送,按月份降序)。
*/
List<ReferrerStatementVO> listStatements(String brokerBizId);
/**
* 月结单详情。
*/
ReferrerStatementDetailVO getStatementDetail(String brokerBizId, String salaryBizId);
/**
* 我的保单(生效 / 未生效失效分组)。
*/
ReferrerPolicyListVO listPolicies(String brokerBizId);
/**
* 保单各年度佣金钻取。
*/
ReferrerPolicyCommissionVO getPolicyCommission(String brokerBizId, String policyNo);
/**
* 在途佣金列表。
*/
List<ReferrerPipelineVO> listPipeline(String brokerBizId);
}
\ No newline at end of file
package com.yd.csf.service.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yd.csf.service.model.ReferrerTodo;
/**
* 转介人待办通知留痕服务
*/
public interface IReferrerTodoService extends IService<ReferrerTodo> {
/**
* 记录一次「已通知」留痕。
*
* @param brokerBizId 登录态转介人业务 ID
* @param policyNo 保单号
* @param todoType 待办类型
*/
void recordNotify(String brokerBizId, String policyNo, String todoType);
}
\ No newline at end of file
package com.yd.csf.service.service.impl;
import com.yd.common.exception.BusinessException;
import com.yd.csf.feign.enums.PaymentFrequencyEnum;
import com.yd.csf.feign.enums.SalaryStatusEnum;
import com.yd.csf.service.enums.FortuneAccountStatusEnum;
import com.yd.csf.service.enums.PolicyStatusEnum;
import com.yd.csf.service.enums.ReferrerCommissionStage;
import com.yd.csf.service.model.ExpectedFortune;
import com.yd.csf.service.model.Fortune;
import com.yd.csf.service.model.FortuneAccount;
import com.yd.csf.service.model.Policy;
import com.yd.csf.service.model.PolicyBroker;
import com.yd.csf.service.model.Salary;
import com.yd.csf.service.model.SalaryRemittance;
import com.yd.csf.service.service.FortuneAccountService;
import com.yd.csf.service.service.FortuneService;
import com.yd.csf.service.service.IExpectedFortuneService;
import com.yd.csf.service.service.IReferrerPortalService;
import com.yd.csf.service.service.ISalaryRemittanceService;
import com.yd.csf.service.service.ISalaryService;
import com.yd.csf.service.service.PolicyBrokerService;
import com.yd.csf.service.service.PolicyService;
import com.yd.csf.service.vo.ReferrerDashboardVO;
import com.yd.csf.service.vo.ReferrerPipelineVO;
import com.yd.csf.service.vo.ReferrerPolicyCommissionItemVO;
import com.yd.csf.service.vo.ReferrerPolicyCommissionVO;
import com.yd.csf.service.vo.ReferrerPolicyListVO;
import com.yd.csf.service.vo.ReferrerPolicyVO;
import com.yd.csf.service.vo.ReferrerStatementDetailVO;
import com.yd.csf.service.vo.ReferrerStatementItemVO;
import com.yd.csf.service.vo.ReferrerStatementVO;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 转介人门户只读数据服务实现。
*
* <p>行级归属:所有查询均强制 {@code broker_biz_id = 当前登录转介人},
* 不接收任何前端身份入参。</p>
*/
@Service
public class ReferrerPortalServiceImpl implements IReferrerPortalService {
private static final DateTimeFormatter MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyyMM");
/**
* 在途视图可见 / 在途金额计入状态:除失效(5)外全部,
* 含 4=保留、7=未找到来佣(遵 D4.1 归在途)、3=部分出账。
*/
private static final List<String> ACTIVE_STATUSES =
Arrays.asList("0", "1", "2", "3", "4", "6", "7");
@Resource
private ISalaryService salaryService;
@Resource
private IExpectedFortuneService expectedFortuneService;
@Resource
private FortuneService fortuneService;
@Resource
private FortuneAccountService fortuneAccountService;
@Resource
private ISalaryRemittanceService salaryRemittanceService;
@Resource
private PolicyService policyService;
@Resource
private PolicyBrokerService policyBrokerService;
@Override
public ReferrerDashboardVO getDashboard(String brokerBizId) {
ReferrerDashboardVO vo = new ReferrerDashboardVO();
vo.setMonthPaidCommission(BigDecimal.ZERO);
vo.setTaxYearPaidCommission(BigDecimal.ZERO);
vo.setPipelineCommission(BigDecimal.ZERO);
vo.setEffectivePolicyCount(0L);
vo.setFirstYearFyp(BigDecimal.ZERO);
YearMonth now = YearMonth.now();
YearMonth taxStart = now.getMonthValue() >= 4
? YearMonth.of(now.getYear(), 4)
: YearMonth.of(now.getYear() - 1, 4);
YearMonth taxEnd = taxStart.plusYears(1).withMonth(3);
List<Salary> taxYearSalaries = listPushedSalaries(brokerBizId, taxStart, taxEnd);
String currentMonth = now.format(MONTH_FORMAT);
BigDecimal monthPaid = BigDecimal.ZERO;
BigDecimal taxPaid = BigDecimal.ZERO;
for (Salary salary : taxYearSalaries) {
BigDecimal paidAmount = defaultZero(salary.getPaidAmount());
taxPaid = taxPaid.add(paidAmount);
if (StringUtils.equals(currentMonth, salary.getMonth())) {
monthPaid = monthPaid.add(paidAmount);
}
}
vo.setMonthPaidCommission(n2(monthPaid));
vo.setTaxYearPaidCommission(n2(taxPaid));
// listPipelineRows 已限定 ACTIVE_STATUSES(除失效外,含保留/未找到来佣)
List<ExpectedFortune> pipelineRows = listPipelineRows(brokerBizId);
BigDecimal pipelineAmount = pipelineRows.stream()
.map(r -> defaultZero(r.getUnpaidRuleAmountHkd()))
.reduce(BigDecimal.ZERO, BigDecimal::add);
vo.setPipelineCommission(n2(pipelineAmount));
Map<String, PolicyBroker> brokerMap = loadMyBrokerMap(brokerBizId);
List<Policy> myPolicies = loadMyPolicies(brokerMap.keySet());
List<Policy> inforcePolicies = myPolicies.stream()
.filter(p -> PolicyStatusEnum.INFORCE.getItemValue().equals(p.getStatus()))
.collect(Collectors.toList());
vo.setEffectivePolicyCount((long) inforcePolicies.size());
BigDecimal firstYearFyp = BigDecimal.ZERO;
for (Policy policy : inforcePolicies) {
PolicyBroker policyBroker = brokerMap.get(policy.getPolicyNo());
if (policyBroker == null) {
continue;
}
firstYearFyp = firstYearFyp.add(personalFirstYearPremiumHkd(policy, policyBroker));
}
vo.setFirstYearFyp(n2(firstYearFyp));
return vo;
}
@Override
public List<ReferrerStatementVO> listStatements(String brokerBizId) {
YearMonth start = YearMonth.of(2026, 4);
List<Salary> salaries = listPushedSalaries(brokerBizId, start, YearMonth.now());
salaries.sort(Comparator.comparing(Salary::getMonth, Comparator.nullsLast(Comparator.reverseOrder())));
return salaries.stream().map(salary -> {
ReferrerStatementVO vo = new ReferrerStatementVO();
vo.setSalaryBizId(salary.getSalaryBizId());
vo.setMonth(salary.getMonth());
vo.setSalaryNo(salary.getSalaryNo());
vo.setGrossAmount(n2(salary.getGrossAmount()));
vo.setMpfAmount(n2(salary.getMpfAmount()));
vo.setOtherAmount(n2(salary.getOtherAmount()));
vo.setPaidAmount(n2(salary.getPaidAmount()));
List<FortuneAccount> accounts = fortuneAccountService.queryList(
brokerBizId, salary.getMonth(), FortuneAccountStatusEnum.SENT.getItemValue());
if (accounts != null && !accounts.isEmpty()) {
vo.setPayoutDate(toLocalDate(accounts.get(0).getFortuneAccountDate()));
}
return vo;
}).collect(Collectors.toList());
}
@Override
public ReferrerStatementDetailVO getStatementDetail(String brokerBizId, String salaryBizId) {
Salary salary = salaryService.lambdaQuery()
.eq(Salary::getSalaryBizId, salaryBizId)
.eq(Salary::getIsDeleted, 0)
.one();
if (salary == null) {
throw new BusinessException("月结单不存在");
}
if (!StringUtils.equals(brokerBizId, salary.getBrokerBizId())) {
throw new BusinessException("无权查看该月结单");
}
if (!SalaryStatusEnum.YTS.getItemValue().equals(salary.getStatus())) {
throw new BusinessException("月结单尚未推送");
}
ReferrerStatementDetailVO vo = new ReferrerStatementDetailVO();
vo.setSalaryBizId(salary.getSalaryBizId());
vo.setMonth(salary.getMonth());
vo.setSalaryNo(salary.getSalaryNo());
vo.setGrossAmount(n2(salary.getGrossAmount()));
vo.setMpfAmount(n2(salary.getMpfAmount()));
vo.setOtherAmount(n2(salary.getOtherAmount()));
vo.setPaidAmount(n2(salary.getPaidAmount()));
// 发放日 / 银行尾号
List<FortuneAccount> accounts = fortuneAccountService.queryList(
brokerBizId, salary.getMonth(), FortuneAccountStatusEnum.SENT.getItemValue());
if (accounts != null && !accounts.isEmpty()) {
vo.setPayoutDate(toLocalDate(accounts.get(0).getFortuneAccountDate()));
}
List<SalaryRemittance> remittances = salaryRemittanceService.queryList(salaryBizId);
if (remittances != null && !remittances.isEmpty()) {
SalaryRemittance remittance = remittances.get(0);
vo.setBankName(remittance.getBankName());
vo.setAccountEndNo(remittance.getAccountEndNo());
}
vo.setItems(buildStatementItems(brokerBizId, salary.getMonth()));
return vo;
}
@Override
public ReferrerPolicyListVO listPolicies(String brokerBizId) {
Map<String, PolicyBroker> brokerMap = loadMyBrokerMap(brokerBizId);
List<Policy> myPolicies = loadMyPolicies(brokerMap.keySet());
List<ReferrerPolicyVO> effective = new ArrayList<>();
List<ReferrerPolicyVO> inactive = new ArrayList<>();
for (Policy policy : myPolicies) {
PolicyBroker policyBroker = brokerMap.get(policy.getPolicyNo());
ReferrerPolicyVO item = toPolicyVO(policy, policyBroker);
if (PolicyStatusEnum.INFORCE.getItemValue().equals(policy.getStatus())) {
effective.add(item);
} else {
inactive.add(item);
}
}
ReferrerPolicyListVO vo = new ReferrerPolicyListVO();
vo.setEffectivePolicies(effective);
vo.setInactivePolicies(inactive);
return vo;
}
@Override
public ReferrerPolicyCommissionVO getPolicyCommission(String brokerBizId, String policyNo) {
Long ownCount = policyBrokerService.lambdaQuery()
.eq(PolicyBroker::getBrokerBizId, brokerBizId)
.eq(PolicyBroker::getPolicyNo, policyNo)
.eq(PolicyBroker::getIsDeleted, 0)
.count();
if (ownCount == null || ownCount == 0L) {
throw new BusinessException("无权查看该保单");
}
List<ExpectedFortune> rows = expectedFortuneService.lambdaQuery()
.eq(ExpectedFortune::getBrokerBizId, brokerBizId)
.eq(ExpectedFortune::getPolicyNo, policyNo)
.in(ExpectedFortune::getIsPart, 0, 1)
.eq(ExpectedFortune::getIsDeleted, 0)
.orderByAsc(ExpectedFortune::getFortunePeriod)
.list();
List<ReferrerPolicyCommissionItemVO> items = new ArrayList<>();
for (ExpectedFortune row : rows) {
appendCommissionSegments(items, row);
}
ReferrerPolicyCommissionVO vo = new ReferrerPolicyCommissionVO();
vo.setPolicyNo(policyNo);
vo.setItems(items);
vo.setFirstYearCommission(n2(items.stream()
.filter(it -> Integer.valueOf(1).equals(it.getFortunePeriod()))
.filter(it -> !ReferrerCommissionStage.LAPSED.name().equals(it.getStage()))
.map(ReferrerPolicyCommissionItemVO::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add)));
vo.setRenewalCommission(n2(items.stream()
.filter(it -> it.getFortunePeriod() != null && it.getFortunePeriod() > 1)
.filter(it -> !ReferrerCommissionStage.LAPSED.name().equals(it.getStage()))
.map(ReferrerPolicyCommissionItemVO::getAmount)
.reduce(BigDecimal.ZERO, BigDecimal::add)));
return vo;
}
@Override
public List<ReferrerPipelineVO> listPipeline(String brokerBizId) {
List<ExpectedFortune> rows = listPipelineRows(brokerBizId);
Set<String> policyNos = rows.stream()
.map(ExpectedFortune::getPolicyNo)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toSet());
Map<String, Policy> policyMap = policyNos.isEmpty()
? Collections.emptyMap()
: policyService.lambdaQuery()
.in(Policy::getPolicyNo, policyNos)
.eq(Policy::getIsDeleted, 0)
.list()
.stream()
.collect(Collectors.toMap(Policy::getPolicyNo, p -> p, (a, b) -> a));
return rows.stream()
.sorted(Comparator.comparing(ExpectedFortune::getPayoutDate,
Comparator.nullsLast(Comparator.naturalOrder())))
.map(row -> {
ReferrerPipelineVO item = new ReferrerPipelineVO();
item.setExpectedFortuneBizId(row.getExpectedFortuneBizId());
item.setPolicyNo(row.getPolicyNo());
Policy policy = policyMap.get(row.getPolicyNo());
item.setProductName(policy == null ? null : policy.getProductName());
item.setFortunePeriod(row.getFortunePeriod());
item.setFortuneName(row.getFortuneName());
item.setPayoutDate(row.getPayoutDate());
item.setStage(ReferrerCommissionStage.fromExpectedStatus(row.getStatus()).name());
item.setHkdAmount(n4(row.getUnpaidRuleAmountHkd()));
return item;
}).collect(Collectors.toList());
}
// ==================== 私有组装方法 ====================
private List<Salary> listPushedSalaries(String brokerBizId, YearMonth start, YearMonth end) {
return salaryService.lambdaQuery()
.eq(Salary::getBrokerBizId, brokerBizId)
.eq(Salary::getStatus, SalaryStatusEnum.YTS.getItemValue())
.eq(Salary::getIsDeleted, 0)
.ge(Salary::getMonth, start.format(MONTH_FORMAT))
.le(Salary::getMonth, end.format(MONTH_FORMAT))
.list();
}
private List<ExpectedFortune> listPipelineRows(String brokerBizId) {
return expectedFortuneService.lambdaQuery()
.eq(ExpectedFortune::getBrokerBizId, brokerBizId)
.in(ExpectedFortune::getStatus, ACTIVE_STATUSES)
.in(ExpectedFortune::getIsPart, 0, 1)
.eq(ExpectedFortune::getIsDeleted, 0)
.list();
}
private Map<String, PolicyBroker> loadMyBrokerMap(String brokerBizId) {
List<PolicyBroker> brokers = policyBrokerService.lambdaQuery()
.eq(PolicyBroker::getBrokerBizId, brokerBizId)
.eq(PolicyBroker::getIsDeleted, 0)
.list();
Map<String, PolicyBroker> map = new HashMap<>();
for (PolicyBroker broker : brokers) {
if (StringUtils.isBlank(broker.getPolicyNo())) {
continue;
}
map.putIfAbsent(broker.getPolicyNo(), broker);
}
return map;
}
private List<Policy> loadMyPolicies(Set<String> policyNos) {
if (policyNos == null || policyNos.isEmpty()) {
return new ArrayList<>();
}
return policyService.lambdaQuery()
.in(Policy::getPolicyNo, policyNos)
.eq(Policy::getIsDeleted, 0)
.list();
}
private ReferrerPolicyVO toPolicyVO(Policy policy, PolicyBroker policyBroker) {
ReferrerPolicyVO vo = new ReferrerPolicyVO();
vo.setPolicyNo(policy.getPolicyNo());
vo.setPolicyHolder(policy.getPolicyHolder());
vo.setInsured(policy.getInsured());
vo.setProductName(policy.getProductName());
vo.setInsuranceCompany(policy.getInsuranceCompany());
vo.setCurrency(policy.getCurrency());
vo.setFullPremium(n4(policy.getTotalPaymentPremium()));
vo.setFullPremiumHkd(n4(policy.getTotalPaymentPremiumHkd()));
vo.setMyRatio(policyBroker == null ? null : policyBroker.getBrokerRatio());
vo.setStatus(policy.getStatus());
return vo;
}
private List<ReferrerStatementItemVO> buildStatementItems(String brokerBizId, String salaryMonth) {
if (StringUtils.length(salaryMonth) < 6) {
return new ArrayList<>();
}
String reconciliationMonth = salaryMonth.substring(0, 4) + "-" + salaryMonth.substring(4, 6);
List<Fortune> fortunes = fortuneService.lambdaQuery()
.eq(Fortune::getBrokerBizId, brokerBizId)
.eq(Fortune::getReconciliationYearMonth, reconciliationMonth)
.eq(Fortune::getStatus, "2")
.in(Fortune::getIsPart, 0, 1)
.orderByAsc(Fortune::getId)
.list();
if (fortunes.isEmpty()) {
return new ArrayList<>();
}
Set<String> policyNos = fortunes.stream().map(Fortune::getPolicyNo).collect(Collectors.toSet());
Map<String, Policy> policyMap = policyService.lambdaQuery()
.in(Policy::getPolicyNo, policyNos)
.eq(Policy::getIsDeleted, 0)
.list()
.stream()
.collect(Collectors.toMap(Policy::getPolicyNo, p -> p, (a, b) -> a));
Set<String> expectedBizIds = fortunes.stream()
.map(Fortune::getExpectedFortuneBizId)
.filter(StringUtils::isNotBlank)
.collect(Collectors.toSet());
// 不能用 Collectors.toMap:broker_ratio 允许为 null,toMap 会抛 NPE
Map<String, String> ratioMap = new HashMap<>();
if (!expectedBizIds.isEmpty()) {
List<ExpectedFortune> expectedList = expectedFortuneService.lambdaQuery()
.in(ExpectedFortune::getExpectedFortuneBizId, expectedBizIds)
.list();
for (ExpectedFortune expected : expectedList) {
if (expected.getBrokerRatio() != null) {
ratioMap.putIfAbsent(expected.getExpectedFortuneBizId(), expected.getBrokerRatio());
}
}
}
return fortunes.stream().map(fortune -> {
ReferrerStatementItemVO item = new ReferrerStatementItemVO();
item.setPolicyNo(fortune.getPolicyNo());
Policy policy = policyMap.get(fortune.getPolicyNo());
if (policy != null) {
item.setPolicyHolder(policy.getPolicyHolder());
item.setInsuranceCompany(policy.getInsuranceCompany());
item.setProductName(policy.getProductName());
}
item.setFortunePeriod(fortune.getFortunePeriod());
item.setFortuneName(fortune.getFortuneName());
item.setBrokerRatio(ratioMap.get(fortune.getExpectedFortuneBizId()));
item.setOriginalCurrency(fortune.getOriginalCurrency());
item.setOriginalAmount(n4(fortune.getOriginalAmount()));
item.setOriginalToHkdRate(n4(fortune.getOriginalToHkdRate()));
item.setHkdAmount(n4(fortune.getHkdAmount()));
return item;
}).collect(Collectors.toList());
}
/**
* 个人首年保费(HKD)= 期交保费按付款频率年化 × 保单快照港币汇率 × 介绍费占比。
*/
private BigDecimal personalFirstYearPremiumHkd(Policy policy, PolicyBroker policyBroker) {
BigDecimal periodPremium = defaultZero(policy.getPaymentPremium());
BigDecimal annualPremium = periodPremium.multiply(annualFactor(policy.getPaymentFrequency()));
BigDecimal hkdRate = BigDecimal.ONE;
BigDecimal totalOriginal = defaultZero(policy.getTotalPaymentPremium());
BigDecimal totalHkd = defaultZero(policy.getTotalPaymentPremiumHkd());
if (totalOriginal.compareTo(BigDecimal.ZERO) > 0) {
hkdRate = totalHkd.divide(totalOriginal, 8, RoundingMode.HALF_UP);
}
BigDecimal annualHkd = annualPremium.multiply(hkdRate);
return annualHkd.multiply(ratioMultiplier(policyBroker.getBrokerRatio()));
}
private BigDecimal annualFactor(String paymentFrequency) {
if (StringUtils.isBlank(paymentFrequency)) {
return BigDecimal.ONE;
}
if (PaymentFrequencyEnum.MONTH.getItemValue().equals(paymentFrequency)) {
return BigDecimal.valueOf(12);
}
if (PaymentFrequencyEnum.SEASON.getItemValue().equals(paymentFrequency)) {
return BigDecimal.valueOf(4);
}
// YEAR / FULL_PAYMENT / 未知频率均按 1 处理
return BigDecimal.ONE;
}
/**
* 介绍费占比:兼容 "60" / "60%" / "0.6" 三种存储形式。
*/
private BigDecimal ratioMultiplier(String ratio) {
if (StringUtils.isBlank(ratio)) {
return BigDecimal.ZERO;
}
try {
BigDecimal value = new BigDecimal(ratio.trim().replace("%", "").trim());
if (value.compareTo(BigDecimal.ONE) > 0) {
value = value.divide(BigDecimal.valueOf(100), 6, RoundingMode.HALF_UP);
}
return value;
} catch (NumberFormatException e) {
return BigDecimal.ZERO;
}
}
private String periodLabel(Integer period) {
if (period == null) {
return "";
}
if (period == 1) {
return "首年";
}
return "第" + period + "年";
}
/**
* 把一个保单年度(expected_fortune 行)按 D4.1 拆成 1~2 个互斥金额段。
*
* <p>保留(4)/失效(5)各一段、金额取全额应得;其余(0/1/2/3/6/7)按
* {@code paid_amount / unpaid_amount} 拆 {@code PAID + PIPELINE},
* 7(未找到来佣)随 PIPELINE;金额未回填时以应得兜底,不丢钱。</p>
*/
private void appendCommissionSegments(List<ReferrerPolicyCommissionItemVO> items, ExpectedFortune row) {
ReferrerCommissionStage whole = ReferrerCommissionStage.fromExpectedStatus(row.getStatus());
if (whole == ReferrerCommissionStage.RETAINED || whole == ReferrerCommissionStage.LAPSED) {
items.add(buildCommissionSegment(whole, row, n4(row.getHkdAmount())));
return;
}
BigDecimal paid = defaultZero(row.getPaidAmount());
BigDecimal unpaid = defaultZero(row.getUnpaidAmount());
if (paid.signum() == 0 && unpaid.signum() == 0) {
unpaid = defaultZero(row.getHkdAmount());
}
if (paid.signum() > 0) {
items.add(buildCommissionSegment(ReferrerCommissionStage.PAID, row, n4(paid)));
}
if (unpaid.signum() > 0) {
items.add(buildCommissionSegment(ReferrerCommissionStage.PIPELINE, row, n4(unpaid)));
}
}
private ReferrerPolicyCommissionItemVO buildCommissionSegment(
ReferrerCommissionStage stage, ExpectedFortune row, BigDecimal amount) {
ReferrerPolicyCommissionItemVO it = new ReferrerPolicyCommissionItemVO();
it.setStage(stage.name());
it.setFortunePeriod(row.getFortunePeriod());
it.setPeriodLabel(periodLabel(row.getFortunePeriod()));
it.setFortuneName(row.getFortuneName());
it.setStatusDesc(row.getStatusDesc());
it.setAmount(amount);
it.setPayoutDate(row.getPayoutDate());
it.setActualPayoutDate(row.getActualPayoutDate());
return it;
}
private LocalDate toLocalDate(Date date) {
if (date == null) {
return null;
}
return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
}
private BigDecimal defaultZero(BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
private BigDecimal n2(BigDecimal value) {
return defaultZero(value).setScale(2, RoundingMode.HALF_UP);
}
private BigDecimal n4(BigDecimal value) {
return defaultZero(value).setScale(4, RoundingMode.HALF_UP);
}
}
\ No newline at end of file
package com.yd.csf.service.service.impl;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yd.csf.service.dao.ReferrerTodoMapper;
import com.yd.csf.service.model.ReferrerTodo;
import com.yd.csf.service.service.IReferrerTodoService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* 转介人待办通知留痕服务实现
*/
@Service
public class ReferrerTodoServiceImpl extends ServiceImpl<ReferrerTodoMapper, ReferrerTodo>
implements IReferrerTodoService {
@Override
@Transactional(rollbackFor = Exception.class)
public void recordNotify(String brokerBizId, String policyNo, String todoType) {
LocalDateTime now = LocalDateTime.now();
ReferrerTodo todo = new ReferrerTodo();
todo.setTodoBizId(UUID.randomUUID().toString().replace("-", ""));
todo.setBrokerBizId(brokerBizId);
todo.setPolicyNo(policyNo);
todo.setTodoType(todoType);
todo.setNotifyTime(now);
todo.setCreateTime(now);
todo.setUpdateTime(now);
this.save(todo);
}
}
\ No newline at end of file
package com.yd.csf.service.vo;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 转介人首页指标。
*/
@Data
public class ReferrerDashboardVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 本月已发佣金(HKD)
*/
private BigDecimal monthPaidCommission;
/**
* 本课税年度累计已发佣金(HKD)
*/
private BigDecimal taxYearPaidCommission;
/**
* 在途佣金(HKD,不含失效/未找到来佣)
*/
private BigDecimal pipelineCommission;
/**
* 生效件数
*/
private Long effectivePolicyCount;
/**
* 首年业绩 FYP(HKD,按本人占比折算)
*/
private BigDecimal firstYearFyp;
}
\ No newline at end of file
package com.yd.csf.service.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 转介人在途佣金项。
*/
@Data
public class ReferrerPipelineVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 预计发佣业务ID
*/
private String expectedFortuneBizId;
/**
* 保单号
*/
private String policyNo;
/**
* 产品名称
*/
private String productName;
/**
* 发佣期数(1=第一年...)
*/
private Integer fortunePeriod;
/**
* 发佣名称
*/
private String fortuneName;
/**
* 预计出账日期
*/
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private LocalDate payoutDate;
/**
* 语义状态码:PAID / PIPELINE / RETAINED / LAPSED(D4.1)
*/
private String stage;
/**
* 在途金额(剩余发放折合港币)
*/
private BigDecimal hkdAmount;
}
\ No newline at end of file
package com.yd.csf.service.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 转介人保单佣金段:一个保单年度的钱按「钱的位置」(D4.1) 拆分后的一段。
*
* <p>部分出账的年度会产出两段:{@code PAID(已出账金额)} + {@code PIPELINE(待出账金额)},
* 保证各段金额相加恒等于该年度应得。</p>
*/
@Data
public class ReferrerPolicyCommissionItemVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 语义状态码:PAID / PIPELINE / RETAINED / LAPSED
*/
private String stage;
/**
* 发佣期数(1=第一年...)
*/
private Integer fortunePeriod;
/**
* 年度文案:首年 / 第2年 / 第3年...
*/
private String periodLabel;
/**
* 发佣名称
*/
private String fortuneName;
/**
* 保留/失效原因(财务登记,未登记时为空,前端不臆造)
*/
private String statusDesc;
/**
* 该段金额(HKD)
*/
private BigDecimal amount;
/**
* 预计出账日
*/
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private LocalDate payoutDate;
/**
* 实际出账日
*/
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private LocalDate actualPayoutDate;
}
package com.yd.csf.service.vo;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 转介人保单佣金钻取结果。
*/
@Data
public class ReferrerPolicyCommissionVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 保单号
*/
private String policyNo;
/**
* 首年佣金(FYC,HKD)
*/
private java.math.BigDecimal firstYearCommission;
/**
* 续期佣金(RYC,HKD)
*/
private java.math.BigDecimal renewalCommission;
/**
* 各年度佣金明细
*/
private List<ReferrerPolicyCommissionItemVO> items;
}
\ No newline at end of file
package com.yd.csf.service.vo;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 转介人名下保单分组。
*/
@Data
public class ReferrerPolicyListVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 生效保单(计件计业绩)
*/
private List<ReferrerPolicyVO> effectivePolicies;
/**
* 未生效/已失效保单(可见不计件计业绩)
*/
private List<ReferrerPolicyVO> inactivePolicies;
}
\ No newline at end of file
package com.yd.csf.service.vo;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 转介人名下保单。
*/
@Data
public class ReferrerPolicyVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 保单号
*/
private String policyNo;
/**
* 保單持有人
*/
private String policyHolder;
/**
* 受保人
*/
private String insured;
/**
* 产品名称
*/
private String productName;
/**
* 保险公司
*/
private String insuranceCompany;
/**
* 保单币种
*/
private String currency;
/**
* 全额保费(承保规模,原币)
*/
private BigDecimal fullPremium;
/**
* 全额保费(HKD)
*/
private BigDecimal fullPremiumHkd;
/**
* 我的介绍费占比
*/
private String myRatio;
/**
* 保单状态
*/
private String status;
}
\ No newline at end of file
package com.yd.csf.service.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* 转介人月结单详情。
*/
@Data
public class ReferrerStatementDetailVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 薪资单业务ID
*/
private String salaryBizId;
/**
* 薪资月份 yyyyMM
*/
private String month;
/**
* 发放编号
*/
private String salaryNo;
/**
* 应发总金额(HKD)
*/
private BigDecimal grossAmount;
/**
* MPF事项总金额(HKD)
*/
private BigDecimal mpfAmount;
/**
* 其他调整总金额(HKD)
*/
private BigDecimal otherAmount;
/**
* 实发总金额(HKD)
*/
private BigDecimal paidAmount;
/**
* 发放日
*/
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private LocalDate payoutDate;
/**
* 收款银行名称
*/
private String bankName;
/**
* 账户尾号
*/
private String accountEndNo;
/**
* 逐笔保单明细
*/
private List<ReferrerStatementItemVO> items;
}
\ No newline at end of file
package com.yd.csf.service.vo;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
/**
* 转介人月结单逐笔明细项。
*/
@Data
public class ReferrerStatementItemVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 保单号
*/
private String policyNo;
/**
* 保單持有人
*/
private String policyHolder;
/**
* 保险公司
*/
private String insuranceCompany;
/**
* 产品名称
*/
private String productName;
/**
* 发佣期数(1=第一年...)
*/
private Integer fortunePeriod;
/**
* 发佣名称
*/
private String fortuneName;
/**
* 转介人介绍费占比
*/
private String brokerRatio;
/**
* 原币种
*/
private String originalCurrency;
/**
* 原币种金额
*/
private BigDecimal originalAmount;
/**
* 原币种→港币汇率
*/
private BigDecimal originalToHkdRate;
/**
* 折算港币金额
*/
private BigDecimal hkdAmount;
}
\ No newline at end of file
package com.yd.csf.service.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 转介人月结单列表项。
*/
@Data
public class ReferrerStatementVO implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 薪资单业务ID
*/
private String salaryBizId;
/**
* 薪资月份 yyyyMM
*/
private String month;
/**
* 发放编号
*/
private String salaryNo;
/**
* 应发总金额(HKD)
*/
private BigDecimal grossAmount;
/**
* MPF事项总金额(HKD)
*/
private BigDecimal mpfAmount;
/**
* 其他调整总金额(HKD)
*/
private BigDecimal otherAmount;
/**
* 实发总金额(HKD)
*/
private BigDecimal paidAmount;
/**
* 发放日(取当月已出账头日期)
*/
@JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
private LocalDate payoutDate;
}
\ No newline at end of file
-- 转介人待办通知留痕表 DDL
-- 执行前确认数据库名称
CREATE TABLE IF NOT EXISTS `referrer_todo` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`todo_biz_id` VARCHAR(64) NOT NULL COMMENT '待办留痕业务ID',
`broker_biz_id` VARCHAR(64) NOT NULL COMMENT '转介人业务ID(登录态注入)',
`policy_no` VARCHAR(64) NOT NULL COMMENT '保单号快照',
`todo_type` VARCHAR(32) NOT NULL COMMENT '待办类型:续期/失效/生日',
`notify_time` DATETIME COMMENT '通知时间',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
INDEX `idx_todo_biz_id` (`todo_biz_id`),
INDEX `idx_broker_biz_id` (`broker_biz_id`),
INDEX `idx_policy_no` (`policy_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='转介人待办通知留痕表';
\ No newline at end of file
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment