|
|
@@ -18,6 +18,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|
|
import org.springframework.util.CollectionUtils;
|
|
|
|
|
|
import java.math.BigDecimal;
|
|
|
+import java.math.RoundingMode;
|
|
|
import java.time.LocalDate;
|
|
|
import java.time.temporal.ChronoUnit;
|
|
|
import java.util.*;
|
|
|
@@ -122,6 +123,11 @@ public class ResourceServiceImpl implements ResourceService {
|
|
|
Map<Long, List<Long>> resourceProjectIdsMap = relations.stream()
|
|
|
.collect(Collectors.groupingBy(ProjectResource::getResourceId,
|
|
|
Collectors.mapping(ProjectResource::getProjectId, Collectors.toList())));
|
|
|
+ // 资源 -> (项目 -> 分摊占比)
|
|
|
+ Map<Long, Map<Long, BigDecimal>> resourceProjectPercentMap = relations.stream()
|
|
|
+ .collect(Collectors.groupingBy(ProjectResource::getResourceId,
|
|
|
+ Collectors.toMap(ProjectResource::getProjectId, ProjectResource::getAllocationPercent,
|
|
|
+ (a, b) -> a)));
|
|
|
|
|
|
Set<Long> allProjectIds = relations.stream().map(ProjectResource::getProjectId).collect(Collectors.toSet());
|
|
|
Map<Long, String> projectNameMap = allProjectIds.isEmpty() ? Map.of() :
|
|
|
@@ -154,10 +160,12 @@ public class ResourceServiceImpl implements ResourceService {
|
|
|
vo.setPurchaseDurationText(PurchaseDuration.getTextByCode(r.getPurchaseDuration()));
|
|
|
|
|
|
List<Long> projectIds = resourceProjectIdsMap.getOrDefault(r.getId(), List.of());
|
|
|
+ Map<Long, BigDecimal> percentMap = resourceProjectPercentMap.getOrDefault(r.getId(), Map.of());
|
|
|
vo.setProjects(projectIds.stream().map(pid -> {
|
|
|
ResourceVO.SimpleProject sp = new ResourceVO.SimpleProject();
|
|
|
sp.setId(pid);
|
|
|
sp.setName(projectNameMap.get(pid));
|
|
|
+ sp.setAllocationPercent(percentMap.get(pid));
|
|
|
return sp;
|
|
|
}).toList());
|
|
|
|
|
|
@@ -205,11 +213,23 @@ public class ResourceServiceImpl implements ResourceService {
|
|
|
// 重新维护项目关联:先清空旧关联,再插入新关联,逻辑简单且不容易出现脏数据
|
|
|
projectResourceMapper.delete(
|
|
|
new LambdaQueryWrapper<ProjectResource>().eq(ProjectResource::getResourceId, resource.getId()));
|
|
|
- if (dto.getProjectIds() != null) {
|
|
|
- for (Long projectId : dto.getProjectIds()) {
|
|
|
+ List<Long> ids = dto.getProjectIds();
|
|
|
+ if (ids != null && !ids.isEmpty()) {
|
|
|
+ // 优先使用前端传入的分摊占比, 否则对该资源的所有关联项目均分
|
|
|
+ Map<Long, BigDecimal> percentMap = new java.util.HashMap<>();
|
|
|
+ if (dto.getProjectAllocations() != null) {
|
|
|
+ for (ProjectAllocationDTO a : dto.getProjectAllocations()) {
|
|
|
+ if (a.getProjectId() != null) {
|
|
|
+ percentMap.put(a.getProjectId(), a.getPercent());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ BigDecimal even = BigDecimal.valueOf(100).divide(BigDecimal.valueOf(ids.size()), 4, RoundingMode.HALF_UP);
|
|
|
+ for (Long projectId : ids) {
|
|
|
ProjectResource pr = new ProjectResource();
|
|
|
pr.setProjectId(projectId);
|
|
|
pr.setResourceId(resource.getId());
|
|
|
+ pr.setAllocationPercent(percentMap.getOrDefault(projectId, even));
|
|
|
projectResourceMapper.insert(pr);
|
|
|
}
|
|
|
}
|
|
|
@@ -240,6 +260,8 @@ public class ResourceServiceImpl implements ResourceService {
|
|
|
pr.setResourceId(resourceId);
|
|
|
projectResourceMapper.insert(pr);
|
|
|
}
|
|
|
+ // 该资源现在关联了多个项目 -> 将其各关联占比归一为均分, 避免占比合计>100导致统计重复计入
|
|
|
+ normalizeEqualSplit(resourceId);
|
|
|
}
|
|
|
operationLogService.record("项目", "关联资源变更", dto.getProjectId(), "新增关联资源数: " + dto.getResourceIds().size());
|
|
|
}
|
|
|
@@ -321,7 +343,21 @@ public class ResourceServiceImpl implements ResourceService {
|
|
|
return rows.stream().map(ProcurementCostExportVO::from).toList();
|
|
|
}
|
|
|
|
|
|
- /** 合并初始采购与续费记录为统一台账, 按发生日期倒序; month(非空)时仅保留该月发生的记录 */
|
|
|
+ /** 同一资源被关联到多个项目时, 将其各关联占比归一为均分, 保证占比合计=100 */
|
|
|
+ private void normalizeEqualSplit(Long resourceId) {
|
|
|
+ List<ProjectResource> links = projectResourceMapper.selectList(
|
|
|
+ new LambdaQueryWrapper<ProjectResource>().eq(ProjectResource::getResourceId, resourceId));
|
|
|
+ if (links.size() <= 1) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ BigDecimal even = BigDecimal.valueOf(100).divide(BigDecimal.valueOf(links.size()), 4, RoundingMode.HALF_UP);
|
|
|
+ for (ProjectResource pr : links) {
|
|
|
+ pr.setAllocationPercent(even);
|
|
|
+ projectResourceMapper.updateById(pr);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 合并初始采购与续费记录为统一台账, 按发生日期倒序; 每条费用事件按资源所属项目拆分成多行(含分摊金额); month(非空)时仅保留该月发生的记录 */
|
|
|
private List<ProcurementCostVO> buildProcurementRows(ProcurementCostQuery query) {
|
|
|
// 1) 取满足过滤条件的资源(不翻页), 既用于生成采购行, 也作为续费记录的资源范围
|
|
|
List<Resource> resources = resourceMapper.selectList(buildWrapper(query));
|
|
|
@@ -335,7 +371,8 @@ public class ResourceServiceImpl implements ResourceService {
|
|
|
List<ProcurementCostVO> rows = new ArrayList<>();
|
|
|
if (includePurchase) {
|
|
|
for (ResourceVO vo : voMap.values()) {
|
|
|
- rows.add(toPurchaseRow(vo));
|
|
|
+ rows.addAll(expandByProject(vo, vo.getCost(), vo.getPurchaseDurationText(),
|
|
|
+ vo.getStartDate(), vo.getExpireDate(), false, "p-" + vo.getId()));
|
|
|
}
|
|
|
}
|
|
|
if (includeRenew && !matchedIds.isEmpty()) {
|
|
|
@@ -343,7 +380,10 @@ public class ResourceServiceImpl implements ResourceService {
|
|
|
new LambdaQueryWrapper<ResourceRenewal>()
|
|
|
.in(ResourceRenewal::getResourceId, matchedIds));
|
|
|
for (ResourceRenewal rn : renewals) {
|
|
|
- rows.add(toRenewalRow(rn, voMap.get(rn.getResourceId())));
|
|
|
+ ResourceVO vo = voMap.get(rn.getResourceId());
|
|
|
+ rows.addAll(expandByProject(vo, rn.getCost(),
|
|
|
+ PurchaseDuration.getTextByCode(rn.getDurationCode()) + "(续费)",
|
|
|
+ rn.getRenewDate(), rn.getNewExpireDate(), true, "r-" + rn.getId()));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -368,39 +408,86 @@ public class ResourceServiceImpl implements ResourceService {
|
|
|
return rows;
|
|
|
}
|
|
|
|
|
|
- private ProcurementCostVO toPurchaseRow(ResourceVO vo) {
|
|
|
- ProcurementCostVO v = new ProcurementCostVO();
|
|
|
- v.setRowId("p-" + vo.getId());
|
|
|
- v.setResourceId(vo.getId());
|
|
|
- v.setResourceName(vo.getName());
|
|
|
- v.setTypeName(vo.getTypeName());
|
|
|
- v.setProviderName(vo.getProviderName());
|
|
|
- v.setProjectNames(joinProjects(vo.getProjects()));
|
|
|
- v.setCost(vo.getCost());
|
|
|
- v.setCostCycleText(costCycleText(vo.getCostCycle()));
|
|
|
- v.setDurationText(vo.getPurchaseDurationText());
|
|
|
- v.setOccurDate(vo.getStartDate());
|
|
|
- v.setExpireDate(vo.getExpireDate());
|
|
|
- v.setIsRenewal(false);
|
|
|
- v.setRemark(vo.getRemark());
|
|
|
- return v;
|
|
|
+ /** 把一笔费用事件(采购或续费)按资源所属项目拆分成多行, 每行带该项目的分摊金额 */
|
|
|
+ private List<ProcurementCostVO> expandByProject(ResourceVO vo, BigDecimal eventCost,
|
|
|
+ String durationText, LocalDate occur,
|
|
|
+ LocalDate expire, boolean isRenewal, String rowIdPrefix) {
|
|
|
+ List<ProcurementCostVO> rows = new ArrayList<>();
|
|
|
+ List<ResourceVO.SimpleProject> projects = vo == null ? List.of() : (vo.getProjects() == null ? List.of() : vo.getProjects());
|
|
|
+ if (projects.isEmpty()) {
|
|
|
+ ProcurementCostVO v = baseCostRow(vo, eventCost, durationText, occur, expire, isRenewal, rowIdPrefix + "-na");
|
|
|
+ v.setProjectName("未分配");
|
|
|
+ v.setAllocationPercent(null);
|
|
|
+ v.setAllocatedCost(eventCost);
|
|
|
+ v.setTotalCost(eventCost);
|
|
|
+ rows.add(v);
|
|
|
+ return rows;
|
|
|
+ }
|
|
|
+ int n = projects.size();
|
|
|
+ BigDecimal totalPercent = projects.stream()
|
|
|
+ .map(p -> p.getAllocationPercent() == null ? BigDecimal.ZERO : p.getAllocationPercent())
|
|
|
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
|
|
|
+ boolean useEqual = totalPercent.compareTo(BigDecimal.ZERO) == 0;
|
|
|
+
|
|
|
+ // 各项目分摊比例
|
|
|
+ List<BigDecimal> ratios = new ArrayList<>();
|
|
|
+ for (ResourceVO.SimpleProject p : projects) {
|
|
|
+ BigDecimal perc = p.getAllocationPercent() == null ? BigDecimal.ZERO : p.getAllocationPercent();
|
|
|
+ ratios.add(useEqual
|
|
|
+ ? BigDecimal.ONE.divide(BigDecimal.valueOf(n), 10, RoundingMode.HALF_UP)
|
|
|
+ : perc.divide(totalPercent, 10, RoundingMode.HALF_UP));
|
|
|
+ }
|
|
|
+ // 原始分摊金额(scale 6) -> 四舍五入 2 位 -> 把尾差补到最大的一行, 保证合计=总费用
|
|
|
+ List<BigDecimal> raw = new ArrayList<>();
|
|
|
+ for (BigDecimal r : ratios) {
|
|
|
+ raw.add(eventCost.multiply(r).setScale(6, RoundingMode.HALF_UP));
|
|
|
+ }
|
|
|
+ List<BigDecimal> alloc = new ArrayList<>();
|
|
|
+ BigDecimal sum = BigDecimal.ZERO;
|
|
|
+ for (BigDecimal x : raw) {
|
|
|
+ BigDecimal a = x.setScale(2, RoundingMode.HALF_UP);
|
|
|
+ alloc.add(a);
|
|
|
+ sum = sum.add(a);
|
|
|
+ }
|
|
|
+ BigDecimal diff = eventCost.setScale(2, RoundingMode.HALF_UP).subtract(sum);
|
|
|
+ if (diff.compareTo(BigDecimal.ZERO) != 0) {
|
|
|
+ int idx = 0;
|
|
|
+ BigDecimal max = raw.get(0);
|
|
|
+ for (int i = 1; i < n; i++) {
|
|
|
+ if (raw.get(i).compareTo(max) > 0) {
|
|
|
+ max = raw.get(i);
|
|
|
+ idx = i;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ alloc.set(idx, alloc.get(idx).add(diff));
|
|
|
+ }
|
|
|
+
|
|
|
+ for (int i = 0; i < n; i++) {
|
|
|
+ ResourceVO.SimpleProject p = projects.get(i);
|
|
|
+ ProcurementCostVO v = baseCostRow(vo, eventCost, durationText, occur, expire, isRenewal, rowIdPrefix + "-" + p.getId());
|
|
|
+ v.setProjectName(p.getName());
|
|
|
+ v.setAllocationPercent(p.getAllocationPercent());
|
|
|
+ v.setAllocatedCost(alloc.get(i));
|
|
|
+ v.setTotalCost(eventCost);
|
|
|
+ rows.add(v);
|
|
|
+ }
|
|
|
+ return rows;
|
|
|
}
|
|
|
|
|
|
- private ProcurementCostVO toRenewalRow(ResourceRenewal rn, ResourceVO vo) {
|
|
|
+ private ProcurementCostVO baseCostRow(ResourceVO vo, BigDecimal eventCost, String durationText,
|
|
|
+ LocalDate occur, LocalDate expire, boolean isRenewal, String rowId) {
|
|
|
ProcurementCostVO v = new ProcurementCostVO();
|
|
|
- v.setRowId("r-" + rn.getId());
|
|
|
- v.setResourceId(rn.getResourceId());
|
|
|
+ v.setRowId(rowId);
|
|
|
+ v.setResourceId(vo != null ? vo.getId() : null);
|
|
|
v.setResourceName(vo != null ? vo.getName() : null);
|
|
|
v.setTypeName(vo != null ? vo.getTypeName() : null);
|
|
|
v.setProviderName(vo != null ? vo.getProviderName() : null);
|
|
|
- v.setProjectNames(vo != null ? joinProjects(vo.getProjects()) : null);
|
|
|
- v.setCost(rn.getCost());
|
|
|
v.setCostCycleText(vo != null ? costCycleText(vo.getCostCycle()) : null);
|
|
|
- v.setDurationText(PurchaseDuration.getTextByCode(rn.getDurationCode()) + "(续费)");
|
|
|
- v.setOccurDate(rn.getRenewDate());
|
|
|
- v.setExpireDate(rn.getNewExpireDate());
|
|
|
- v.setIsRenewal(true);
|
|
|
- v.setRemark(rn.getRemark());
|
|
|
+ v.setDurationText(durationText);
|
|
|
+ v.setOccurDate(occur);
|
|
|
+ v.setExpireDate(expire);
|
|
|
+ v.setIsRenewal(isRenewal);
|
|
|
+ v.setRemark(vo != null ? vo.getRemark() : null);
|
|
|
return v;
|
|
|
}
|
|
|
|