feat(authorization-service): 正式市公司(CITY_COMPANY)阶梯考核及月度考核

- 添加 CITY_COMPANY_LADDER 阶梯目标配置 (30→60→120→240→480→960→1920→3840→2350)
- 修改 getCityAreaRewardDistribution 使用阶梯目标激活权益
- 添加 processExpiredCityCompanyBenefits 月度考核处理方法
- 添加月末23:59考核定时任务 (判断是否当月最后一天)
- 添加正式市公司月度数据存档定时任务

业务规则:
- 第一个月达标30棵树即可激活权益
- 每月最后一天23:59执行考核
- 达标续期并递增月份索引
- 不达标停用权益

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-13 23:34:55 -08:00
parent 4d58d4be6c
commit 1896dd6b4c
3 changed files with 211 additions and 15 deletions

View File

@ -249,6 +249,9 @@ export class MonthlyAssessmentScheduler {
// 同时处理省团队授权
await this.archiveAndResetAuthProvinceMonthlyTreeCounts()
// 同时处理正式市公司
await this.archiveAndResetCityCompanyMonthlyTreeCounts()
} catch (error) {
this.logger.error('[archiveAndResetMonthlyTreeCounts] 月度树数存档失败', error)
}
@ -371,4 +374,74 @@ export class MonthlyAssessmentScheduler {
this.logger.error('[processExpiredAuthProvinceBenefits] 省团队授权权益过期检查失败', error)
}
}
/**
* 每天23:59检查是否是当月最后一天(CITY_COMPANY)
*
*
* - benefitValidUntil < benefitActive=true
* - 使130260...92350
* -
* - 1
*/
@Cron('59 23 * * *')
async processExpiredCityCompanyBenefits(): Promise<void> {
// 判断是否是当月最后一天
const now = new Date()
const tomorrow = new Date(now)
tomorrow.setDate(tomorrow.getDate() + 1)
// 如果明天是1号说明今天是当月最后一天
if (tomorrow.getDate() !== 1) {
return
}
this.logger.log('[processExpiredCityCompanyBenefits] 今天是月末,开始检查正式市公司权益过期情况...')
try {
const result = await this.authorizationAppService.processExpiredCityCompanyBenefits(100)
this.logger.log(
`[processExpiredCityCompanyBenefits] 处理完成: ` +
`已处理=${result.processedCount}, 已续期=${result.renewedCount}, 已停用=${result.deactivatedCount}`,
)
} catch (error) {
this.logger.error('[processExpiredCityCompanyBenefits] 正式市公司权益过期检查失败', error)
}
}
/**
*
*
*
* - lastMonthTreesAdded
* - monthlyTreesAdded 0
*/
private async archiveAndResetCityCompanyMonthlyTreeCounts(): Promise<void> {
this.logger.log('[archiveAndResetCityCompanyMonthlyTreeCounts] 开始存档正式市公司月度新增树数...')
try {
// 获取所有激活的正式市公司
const activeCityCompanies = await this.authorizationRepository.findAllActive(RoleType.CITY_COMPANY)
let archivedCount = 0
for (const cityCompany of activeCityCompanies) {
if (cityCompany.benefitActive) {
// 存档当月数据到 lastMonthTreesAdded然后重置 monthlyTreesAdded
cityCompany.archiveAndResetMonthlyTrees()
await this.authorizationRepository.save(cityCompany)
archivedCount++
this.logger.debug(
`[archiveAndResetCityCompanyMonthlyTreeCounts] 正式市公司 ${cityCompany.userId.accountSequence}: ` +
`存档=${cityCompany.lastMonthTreesAdded}, 当月已重置=0`,
)
}
}
this.logger.log(`[archiveAndResetCityCompanyMonthlyTreeCounts] 存档完成: 已处理 ${archivedCount} 个正式市公司`)
} catch (error) {
this.logger.error('[archiveAndResetCityCompanyMonthlyTreeCounts] 正式市公司月度树数存档失败', error)
}
}
}

View File

@ -2307,9 +2307,10 @@ export class AuthorizationApplicationService {
*
*
* 1. CITY_COMPANY
* 2. benefitActive=true
* 2. benefitActive=true monthlyTreesAdded
* 3. benefitActive=false
* - (1)
* - 使30
* -
* -
* -
* 4.
@ -2351,6 +2352,10 @@ export class AuthorizationApplicationService {
if (cityCompany.benefitActive) {
// 正式市公司权益已激活,进该市公司账户
// 累加月度新增树数(用于后续月度考核)
cityCompany.addMonthlyTrees(treeCount)
await this.authorizationRepository.save(cityCompany)
return {
distributions: [
{
@ -2363,15 +2368,19 @@ export class AuthorizationApplicationService {
}
}
// 权益未激活,计算考核分配 - 使用下级团队认种数(不含自己)
const stats = await this.statsRepository.findByAccountSequence(cityCompany.userId.accountSequence)
const rawSubordinateCount = stats?.subordinateTeamPlantingCount ?? 0
// 修复竞态条件:减去本次认种数量来还原"认种前"的下级团队数
const currentTeamCount = Math.max(0, rawSubordinateCount - treeCount)
const initialTarget = cityCompany.getInitialTarget() // 1万棵
// 权益未激活,计算考核分配 - 使用阶梯目标
// 对于未激活的正式市公司,使用第一个月的目标(30棵)进行初始考核
const monthIndex = cityCompany.currentMonthIndex || 1
const ladderTarget = LadderTargetRule.getTarget(RoleType.CITY_COMPANY, monthIndex)
const initialTarget = ladderTarget.monthlyTarget // 第一个月30棵
// 获取当前月度新增树数
const rawMonthlyCount = cityCompany.monthlyTreesAdded ?? 0
// 修复竞态条件:减去本次认种数量来还原"认种前"的月度数
const currentMonthlyCount = Math.max(0, rawMonthlyCount - treeCount)
this.logger.debug(
`[getCityAreaRewardDistribution] rawSubordinateCount=${rawSubordinateCount}, treeCount=${treeCount}, currentTeamCount(before)=${currentTeamCount}, initialTarget=${initialTarget}`,
`[getCityAreaRewardDistribution] rawMonthlyCount=${rawMonthlyCount}, treeCount=${treeCount}, currentMonthlyCount(before)=${currentMonthlyCount}, initialTarget=${initialTarget}, monthIndex=${monthIndex}`,
)
const distributions: Array<{
@ -2381,7 +2390,7 @@ export class AuthorizationApplicationService {
isSystemAccount: boolean
}> = []
if (currentTeamCount >= initialTarget) {
if (currentMonthlyCount >= initialTarget) {
// 已达标但权益未激活,全部给该市公司
distributions.push({
accountSequence: cityCompany.userId.accountSequence,
@ -2394,18 +2403,22 @@ export class AuthorizationApplicationService {
} else {
// toReachTarget: 还差多少棵达到考核目标(包括达标那一棵)
// 业务规则:达标前的全部进系统市账户,超过达标点后才给该市公司
const toReachTarget = Math.max(0, initialTarget - currentTeamCount)
const afterPlantingCount = currentTeamCount + treeCount
const toReachTarget = Math.max(0, initialTarget - currentMonthlyCount)
const afterPlantingCount = currentMonthlyCount + treeCount
if (afterPlantingCount <= initialTarget) {
// 本次认种后仍未超过目标(包括刚好达标),全部进系统市账户
distributions.push({
accountSequence: systemCityAccountId,
treeCount,
reason: `初始考核中(${currentTeamCount}+${treeCount}=${afterPlantingCount}/${initialTarget}),进系统市账户`,
reason: `初始考核中(${currentMonthlyCount}+${treeCount}=${afterPlantingCount}/${initialTarget}),进系统市账户`,
isSystemAccount: true,
})
// 累加月度新增树数(用于月底考核)
cityCompany.addMonthlyTrees(treeCount)
await this.authorizationRepository.save(cityCompany)
// 如果刚好达标,激活权益(但本批次树全部进系统市账户)
if (afterPlantingCount === initialTarget) {
await this.tryActivateBenefit(cityCompany)
@ -2417,7 +2430,7 @@ export class AuthorizationApplicationService {
distributions.push({
accountSequence: systemCityAccountId,
treeCount: toReachTarget,
reason: `初始考核(${currentTeamCount}+${toReachTarget}=${initialTarget}/${initialTarget}),进系统市账户`,
reason: `初始考核(${currentMonthlyCount}+${toReachTarget}=${initialTarget}/${initialTarget}),进系统市账户`,
isSystemAccount: true,
})
}
@ -2431,6 +2444,11 @@ export class AuthorizationApplicationService {
isSystemAccount: false,
})
}
// 累加月度新增树数
cityCompany.addMonthlyTrees(treeCount)
await this.authorizationRepository.save(cityCompany)
// 自动激活权益(本次认种使其达标)
await this.tryActivateBenefit(cityCompany)
}
@ -2439,4 +2457,87 @@ export class AuthorizationApplicationService {
this.logger.debug(`[getCityAreaRewardDistribution] Result: ${JSON.stringify(distributions)}`)
return { distributions }
}
/**
*
*
*
* - benefitValidUntil < benefitActive=true
* -
* - >=
* - 0
*/
async processExpiredCityCompanyBenefits(
limit: number,
): Promise<{ processedCount: number; renewedCount: number; deactivatedCount: number }> {
const now = new Date()
const expiredCityCompanies = await this.authorizationRepository.findExpiredActiveByRoleType(
RoleType.CITY_COMPANY,
now,
limit,
)
let renewedCount = 0
let deactivatedCount = 0
for (const cityCompany of expiredCityCompanies) {
// 获取当前月份索引对应的阶梯目标
const monthIndex = cityCompany.currentMonthIndex || 1
const ladderTarget = LadderTargetRule.getTarget(RoleType.CITY_COMPANY, monthIndex)
const monthlyTarget = ladderTarget.monthlyTarget
// 获取用于考核的树数
const treesForAssessment = cityCompany.getTreesForAssessment(now)
this.logger.debug(
`[processExpiredCityCompanyBenefits] ${cityCompany.userId.accountSequence}: ` +
`monthIndex=${monthIndex}, target=${monthlyTarget}, trees=${treesForAssessment}`,
)
if (treesForAssessment >= monthlyTarget) {
// 达标:续期权益并递增月份索引
cityCompany.renewBenefit(treesForAssessment)
cityCompany.incrementMonthIndex()
await this.authorizationRepository.save(cityCompany)
renewedCount++
this.logger.log(
`[processExpiredCityCompanyBenefits] ${cityCompany.userId.accountSequence} 考核达标,续期成功`,
)
} else {
// 不达标:停用权益
cityCompany.deactivateBenefit(`月度考核不达标(${treesForAssessment}/${monthlyTarget})`)
await this.authorizationRepository.save(cityCompany)
await this.eventPublisher.publishAll(cityCompany.domainEvents)
cityCompany.clearDomainEvents()
deactivatedCount++
this.logger.log(
`[processExpiredCityCompanyBenefits] ${cityCompany.userId.accountSequence} 考核不达标,权益已停用`,
)
}
}
return {
processedCount: expiredCityCompanies.length,
renewedCount,
deactivatedCount,
}
}
/**
*
*/
async findExpiredCityCompanyBenefits(
checkDate: Date,
limit: number,
): Promise<AuthorizationRole[]> {
return this.authorizationRepository.findExpiredActiveByRoleType(
RoleType.CITY_COMPANY,
checkDate,
limit,
)
}
}

View File

@ -22,7 +22,7 @@ export class LadderTargetRule {
new LadderTargetRule(RoleType.AUTH_PROVINCE_COMPANY, 9, 11750, 50000),
]
// 市代阶梯目标表
// 市团队授权阶梯目标表 (AUTH_CITY_COMPANY)
static readonly CITY_LADDER: LadderTargetRule[] = [
new LadderTargetRule(RoleType.AUTH_CITY_COMPANY, 1, 30, 30),
new LadderTargetRule(RoleType.AUTH_CITY_COMPANY, 2, 60, 90),
@ -35,6 +35,20 @@ export class LadderTargetRule {
new LadderTargetRule(RoleType.AUTH_CITY_COMPANY, 9, 2350, 10000),
]
// 正式市公司/市区域权益阶梯目标表 (CITY_COMPANY)
// 与市团队授权使用相同的阶梯目标
static readonly CITY_COMPANY_LADDER: LadderTargetRule[] = [
new LadderTargetRule(RoleType.CITY_COMPANY, 1, 30, 30),
new LadderTargetRule(RoleType.CITY_COMPANY, 2, 60, 90),
new LadderTargetRule(RoleType.CITY_COMPANY, 3, 120, 210),
new LadderTargetRule(RoleType.CITY_COMPANY, 4, 240, 450),
new LadderTargetRule(RoleType.CITY_COMPANY, 5, 480, 930),
new LadderTargetRule(RoleType.CITY_COMPANY, 6, 960, 1890),
new LadderTargetRule(RoleType.CITY_COMPANY, 7, 1920, 3810),
new LadderTargetRule(RoleType.CITY_COMPANY, 8, 3840, 7650),
new LadderTargetRule(RoleType.CITY_COMPANY, 9, 2350, 10000),
]
// 社区固定目标
static readonly COMMUNITY_FIXED: LadderTargetRule = new LadderTargetRule(
RoleType.COMMUNITY,
@ -57,6 +71,10 @@ export class LadderTargetRule {
const cityIndex = Math.min(monthIndex, 9) - 1
return this.CITY_LADDER[cityIndex >= 0 ? cityIndex : 0]
case RoleType.CITY_COMPANY:
const cityCompanyIndex = Math.min(monthIndex, 9) - 1
return this.CITY_COMPANY_LADDER[cityCompanyIndex >= 0 ? cityCompanyIndex : 0]
case RoleType.COMMUNITY:
return this.COMMUNITY_FIXED
@ -74,6 +92,8 @@ export class LadderTargetRule {
return 50000
case RoleType.AUTH_CITY_COMPANY:
return 10000
case RoleType.CITY_COMPANY:
return 10000
case RoleType.COMMUNITY:
return 10
default:
@ -90,6 +110,8 @@ export class LadderTargetRule {
return this.PROVINCE_LADDER
case RoleType.AUTH_CITY_COMPANY:
return this.CITY_LADDER
case RoleType.CITY_COMPANY:
return this.CITY_COMPANY_LADDER
case RoleType.COMMUNITY:
return [this.COMMUNITY_FIXED]
default: