diff --git a/backend/services/authorization-service/src/application/schedulers/monthly-assessment.scheduler.ts b/backend/services/authorization-service/src/application/schedulers/monthly-assessment.scheduler.ts index 612c8a2d..ca97e485 100644 --- a/backend/services/authorization-service/src/application/schedulers/monthly-assessment.scheduler.ts +++ b/backend/services/authorization-service/src/application/schedulers/monthly-assessment.scheduler.ts @@ -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 的正式市公司 + * - 使用阶梯目标(第1月30,第2月60,...,第9月2350) + * - 如果当月新增树数达标,续期并递增月份索引 + * - 如果不达标,停用权益并重置月份索引到1 + */ + @Cron('59 23 * * *') + async processExpiredCityCompanyBenefits(): Promise { + // 判断是否是当月最后一天 + 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 { + 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) + } + } } diff --git a/backend/services/authorization-service/src/application/services/authorization-application.service.ts b/backend/services/authorization-service/src/application/services/authorization-application.service.ts index 2856404c..d4cef8d0 100644 --- a/backend/services/authorization-service/src/application/services/authorization-application.service.ts +++ b/backend/services/authorization-service/src/application/services/authorization-application.service.ts @@ -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 { + return this.authorizationRepository.findExpiredActiveByRoleType( + RoleType.CITY_COMPANY, + checkDate, + limit, + ) + } } diff --git a/backend/services/authorization-service/src/domain/entities/ladder-target-rule.entity.ts b/backend/services/authorization-service/src/domain/entities/ladder-target-rule.entity.ts index e0a48381..0e1c3939 100644 --- a/backend/services/authorization-service/src/domain/entities/ladder-target-rule.entity.ts +++ b/backend/services/authorization-service/src/domain/entities/ladder-target-rule.entity.ts @@ -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: