浏览代码

feat(cost): 资源费用按项目分摊(自定义占比, 台账/导出拆行, 统计按项目分摊)

C 2 月之前
父节点
当前提交
febfcd2ef7

+ 131 - 0
backend/build_v10.py

@@ -0,0 +1,131 @@
+import zipfile, os, subprocess, glob, sys, shutil
+
+BASE = os.path.dirname(os.path.abspath(__file__))
+SRC = os.path.join(BASE, "src", "main", "java")
+RES = os.path.join(BASE, "src", "main", "resources")
+OUT = os.path.join(BASE, "build_tmp_classes")
+LIB = os.path.join(BASE, "lib")
+JAVAC = r"D:\Program Files\Java\jdk-17\bin\javac.exe"
+LOMBOK = r"C:\Users\BXD-XTYW\.m2\repository\org\projectlombok\lombok\1.18.32\lombok-1.18.32.jar"
+SRC_JAR = os.path.join(BASE, "target", "resource-platform-v9.jar")
+DST_JAR = os.path.join(BASE, "target", "resource-platform-v10.jar")
+
+# 1) 准备依赖 classpath: 从 fat jar 抽取 BOOT-INF/lib 到 lib/(若缺失), 并放入 lombok
+os.makedirs(LIB, exist_ok=True)
+if not glob.glob(os.path.join(LIB, "*.jar")):
+    with zipfile.ZipFile(SRC_JAR) as z:
+        for name in z.namelist():
+            if name.startswith("BOOT-INF/lib/") and name.endswith(".jar"):
+                out = os.path.join(LIB, os.path.basename(name))
+                with open(out, "wb") as f:
+                    f.write(z.read(name))
+if not os.path.exists(os.path.join(LIB, "lombok-1.18.32.jar")):
+    shutil.copy(LOMBOK, os.path.join(LIB, "lombok-1.18.32.jar"))
+
+dep_jars = sorted(glob.glob(os.path.join(LIB, "*.jar")))
+cp = ";".join(dep_jars)
+print("classpath entries:", len(dep_jars))
+
+# 2) 编译全部源码
+sources = []
+for root, _, files in os.walk(SRC):
+    for f in files:
+        if f.endswith(".java"):
+            sources.append(os.path.join(root, f))
+print("source files:", len(sources))
+
+os.makedirs(OUT, exist_ok=True)
+cmd = [JAVAC, "-parameters", "-encoding", "UTF-8", "-cp", cp, "-d", OUT] + sources
+r = subprocess.run(cmd, capture_output=True)
+print("javac returncode:", r.returncode)
+if r.returncode != 0:
+    err = (r.stderr or b"").decode("utf-8", "replace")
+    print("=== STDERR (first 60 lines) ===")
+    print("\n".join(err.splitlines()[:60]))
+    sys.exit(1)
+print("compiled classes:", len(glob.glob(os.path.join(OUT, "**", "*.class"), recursive=True)))
+
+# 3) 重打包: 以 v9 为基底, 替换最新编译的 class, 并注入 src/main/resources 中的资源(xml等)
+with zipfile.ZipFile(SRC_JAR) as zin:
+    infos = zin.infolist()
+
+    def local_for(entry_name):
+        if entry_name.startswith("BOOT-INF/classes/") and entry_name.endswith(".class"):
+            rel = entry_name[len("BOOT-INF/classes/"):]
+            local = os.path.join(OUT, rel)
+            if os.path.isfile(local):
+                return local
+            return None
+        if entry_name.startswith("BOOT-INF/classes/"):
+            rel = entry_name[len("BOOT-INF/classes/"):]
+            local = os.path.join(RES, rel)
+            if os.path.isfile(local):
+                return local
+        return None
+
+    manifest_name = "META-INF/MANIFEST.MF"
+    all_names = set(zin.namelist())
+    extra_resources = []
+    for root, _, files in os.walk(RES):
+        for f in files:
+            full = os.path.join(root, f)
+            rel = os.path.relpath(full, RES).replace("\\", "/")
+            entry = "BOOT-INF/classes/" + rel
+            if entry not in all_names:
+                extra_resources.append((entry, full))
+
+    # 源码中新增的类, 在基底 jar 里原本不存在, 需补打到新 jar
+    extra_classes = []
+    for root, _, files in os.walk(OUT):
+        for f in files:
+            if f.endswith(".class"):
+                full = os.path.join(root, f)
+                rel = os.path.relpath(full, OUT).replace("\\", "/")
+                entry = "BOOT-INF/classes/" + rel
+                if entry not in all_names:
+                    extra_classes.append((entry, full))
+
+    with zipfile.ZipFile(DST_JAR, "w", compression=zipfile.ZIP_DEFLATED) as zout:
+        mi = next(i for i in infos if i.filename == manifest_name)
+        zout.writestr(mi, zin.read(manifest_name))
+        for i in infos:
+            if i.filename == manifest_name:
+                continue
+            local = local_for(i.filename)
+            if local:
+                with open(local, "rb") as lf:
+                    data = lf.read()
+                zi = zipfile.ZipInfo(i.filename)
+                zi.external_attr = i.external_attr
+                zi.date_time = i.date_time
+                zout.writestr(zi, data)
+            else:
+                zout.writestr(i, zin.read(i.filename))
+        for entry, full in extra_resources:
+            with open(full, "rb") as lf:
+                data = lf.read()
+            zout.writestr(zipfile.ZipInfo(entry), data)
+        for entry, full in extra_classes:
+            with open(full, "rb") as lf:
+                data = lf.read()
+            zout.writestr(zipfile.ZipInfo(entry), data)
+
+print("built:", DST_JAR, "exists:", os.path.exists(DST_JAR))
+zm = zipfile.ZipFile(DST_JAR)
+print("manifest first:", zm.namelist()[0] == manifest_name)
+
+# 校验关键新类已打入
+for cls in ["ProjectAllocationDTO.class", "ProjectResource.class", "ResourceDTO.class",
+            "ResourceVO.class", "ProcurementCostVO.class", "ProcurementCostExportVO.class",
+            "ResourceServiceImpl.class", "ResourceMapper.xml"]:
+    if cls.endswith(".xml"):
+        entry = "BOOT-INF/classes/mapper/" + cls
+    elif cls == "ResourceMapper.xml":
+        entry = "BOOT-INF/classes/mapper/" + cls
+    elif cls in ("ProjectAllocationDTO.class", "ProcurementCostVO.class", "ProcurementCostExportVO.class", "ResourceDTO.class"):
+        entry = "BOOT-INF/classes/com/example/resourceplatform/dto/" + cls
+    elif cls == "ProjectResource.class":
+        entry = "BOOT-INF/classes/com/example/resourceplatform/entity/" + cls
+    else:
+        entry = "BOOT-INF/classes/com/example/resourceplatform/service/impl/" + cls
+    print("contains", entry, ":", entry in zm.namelist())

+ 11 - 7
backend/src/main/java/com/example/resourceplatform/dto/ProcurementCostExportVO.java

@@ -18,8 +18,14 @@ public class ProcurementCostExportVO {
     @ExcelProperty("服务商")
     private String providerName;
 
-    @ExcelProperty("关联项目")
-    private String projectNames;
+    @ExcelProperty("项目")
+    private String projectName;
+
+    @ExcelProperty("占比(%)")
+    private BigDecimal allocationPercent;
+
+    @ExcelProperty("分摊费用")
+    private BigDecimal allocatedCost;
 
     @ExcelProperty("计费周期")
     private String costCycleText;
@@ -27,9 +33,6 @@ public class ProcurementCostExportVO {
     @ExcelProperty("购买/续费时长")
     private String durationText;
 
-    @ExcelProperty("费用")
-    private BigDecimal cost;
-
     @ExcelProperty("费用类型")
     private String costTypeText;
 
@@ -44,10 +47,11 @@ public class ProcurementCostExportVO {
         e.setResourceName(v.getResourceName());
         e.setTypeName(v.getTypeName());
         e.setProviderName(v.getProviderName());
-        e.setProjectNames(v.getProjectNames());
+        e.setProjectName(v.getProjectName());
+        e.setAllocationPercent(v.getAllocationPercent());
+        e.setAllocatedCost(v.getAllocatedCost());
         e.setCostCycleText(v.getCostCycleText());
         e.setDurationText(v.getDurationText());
-        e.setCost(v.getCost());
         e.setCostTypeText(v.getIsRenewal() ? "续费" : "采购");
         e.setOccurDate(v.getOccurDate());
         e.setExpireDate(v.getExpireDate());

+ 11 - 3
backend/src/main/java/com/example/resourceplatform/dto/ProcurementCostVO.java

@@ -8,7 +8,7 @@ import java.time.LocalDate;
 @Data
 public class ProcurementCostVO {
 
-    /** 行唯一标识: 采购=p-{resourceId}, 续费=r-{renewalId} */
+    /** 行唯一标识: 采购=p-{resourceId}-{projectId}, 续费=r-{renewalId}-{projectId}, 未分配加-na */
     private String rowId;
 
     private Long resourceId;
@@ -19,9 +19,17 @@ public class ProcurementCostVO {
 
     private String providerName;
 
-    private String projectNames;
+    /** 该行对应的单个项目(费用事件按资源所属项目拆分成多行) */
+    private String projectName;
 
-    private BigDecimal cost;
+    /** 该资源在该项目间的费用分摊占比(0~100) */
+    private BigDecimal allocationPercent;
+
+    /** 本行分摊到的费用(资源总费用 × 占比, 已做尾差修正, 各行合计=总费用) */
+    private BigDecimal allocatedCost;
+
+    /** 该笔费用事件的总费用(采购=资源费用, 续费=续费金额), 便于核对 */
+    private BigDecimal totalCost;
 
     /** 计费周期文本: 月付/年付/一次性 */
     private String costCycleText;

+ 15 - 0
backend/src/main/java/com/example/resourceplatform/dto/ProjectAllocationDTO.java

@@ -0,0 +1,15 @@
+package com.example.resourceplatform.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+@Data
+public class ProjectAllocationDTO {
+
+    /** 关联的项目 id */
+    private Long projectId;
+
+    /** 费用分摊占比(0~100), 同一资源的各项目占比合计应为 100 */
+    private BigDecimal percent;
+}

+ 3 - 0
backend/src/main/java/com/example/resourceplatform/dto/ResourceDTO.java

@@ -41,4 +41,7 @@ public class ResourceDTO {
 
     /** 关联的项目id列表,支持一个资源同时关联多个项目 */
     private List<Long> projectIds;
+
+    /** 各关联项目的费用分摊占比; 为空时按 projectIds 均分 */
+    private List<ProjectAllocationDTO> projectAllocations;
 }

+ 2 - 0
backend/src/main/java/com/example/resourceplatform/dto/ResourceVO.java

@@ -60,5 +60,7 @@ public class ResourceVO {
     public static class SimpleProject {
         private Long id;
         private String name;
+        /** 该资源在所属项目间的费用分摊占比(0~100) */
+        private BigDecimal allocationPercent;
     }
 }

+ 4 - 0
backend/src/main/java/com/example/resourceplatform/entity/ProjectResource.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
 import com.baomidou.mybatisplus.annotation.TableName;
 import lombok.Data;
 
+import java.math.BigDecimal;
 import java.time.LocalDateTime;
 
 @Data
@@ -18,5 +19,8 @@ public class ProjectResource {
 
     private Long resourceId;
 
+    /** 费用分摊占比(0~100): 同一资源在各关联项目间的占比, 合计应为 100 */
+    private BigDecimal allocationPercent;
+
     private LocalDateTime createTime;
 }

+ 118 - 31
backend/src/main/java/com/example/resourceplatform/service/impl/ResourceServiceImpl.java

@@ -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;
     }
 

+ 14 - 5
backend/src/main/resources/mapper/ResourceMapper.xml

@@ -2,15 +2,24 @@
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.example.resourceplatform.mapper.ResourceMapper">
 
-    <!-- 按项目维度统计费用 -->
+    <!-- 按项目维度统计费用(按 allocation_percent 分摊, 同一资源在多个项目不会重复计入) -->
     <select id="costStatByProject" resultType="com.example.resourceplatform.dto.CostStatVO">
-        SELECT p.name          AS dimension_name,
-               COUNT(r.id)     AS resource_count,
-               IFNULL(SUM(r.cost), 0) AS total_cost
+        SELECT p.name AS dimension_name,
+               COUNT(DISTINCT r.id) AS resource_count,
+               IFNULL(SUM(
+                   r.cost * COALESCE(
+                       pr.allocation_percent / NULLIF(t.tot, 0),
+                       1.0 / NULLIF(t.cnt, 0)
+                   )
+               ), 0) AS total_cost
         FROM project_resource pr
                  JOIN project p ON p.id = pr.project_id AND p.deleted = 0
-                 LEFT JOIN resource r ON r.id = pr.resource_id AND r.deleted = 0
+                 JOIN resource r ON r.id = pr.resource_id AND r.deleted = 0
+                 JOIN (SELECT resource_id, SUM(allocation_percent) AS tot, COUNT(*) AS cnt
+                       FROM project_resource GROUP BY resource_id) t
+                      ON t.resource_id = pr.resource_id
         <where>
+            r.deleted = 0
             <if test="startDate != null and endDate != null">
                 AND r.start_date &gt;= #{startDate}
                 AND r.start_date &lt; #{endDate}

+ 9 - 0
backend/start_v10.sh

@@ -0,0 +1,9 @@
+#!/bin/bash
+cd /d/demo/resource-platform/backend
+"D:/Program Files/Java/jdk-17/bin/java.exe" \
+  -Dspring.profiles.active=dev \
+  -Dserver.port=8080 \
+  -DDB_URL='jdbc:mysql://rm-uf64611sxm4qe438g8o.mysql.rds.aliyuncs.com:33450/resource_platform?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useSSL=false' \
+  -DDB_USERNAME=root \
+  -DDB_PASSWORD=Cmt123456 \
+  -jar target/resource-platform-v10.jar

+ 13 - 7
frontend/src/views/ProcurementCost.vue

@@ -67,19 +67,25 @@
         </el-table-column>
         <el-table-column prop="typeName" label="类型" width="110" />
         <el-table-column prop="providerName" label="服务商" width="100" />
-        <el-table-column label="关联项目" min-width="150">
+        <el-table-column label="项目" min-width="140">
           <template #default="{ row }">
-            <span v-if="row.projectNames">{{ row.projectNames }}</span>
+            <span v-if="row.projectName">{{ row.projectName }}</span>
             <span v-else class="muted-tip">-</span>
           </template>
         </el-table-column>
-        <el-table-column prop="costCycleText" label="计费周期" width="90" />
-        <el-table-column prop="durationText" label="购买/续费时长" width="120" />
-        <el-table-column label="费用" width="120" header-align="right" align="right">
+        <el-table-column label="占比" width="80" header-align="right" align="right">
+          <template #default="{ row }">
+            <span v-if="row.allocationPercent != null" class="mono-num">{{ row.allocationPercent }}%</span>
+            <span v-else class="muted-tip">-</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="分摊费用" width="130" header-align="right" align="right">
           <template #default="{ row }">
-            <span class="mono-num">¥{{ fmtMoney(row.cost) }}</span>
+            <span class="mono-num">¥{{ fmtMoney(row.allocatedCost) }}</span>
           </template>
         </el-table-column>
+        <el-table-column prop="costCycleText" label="计费周期" width="90" />
+        <el-table-column prop="durationText" label="购买/续费时长" width="120" />
         <el-table-column label="类型" width="80">
           <template #default="{ row }">
             <el-tag :type="row.isRenewal ? 'warning' : 'success'" size="small">
@@ -135,7 +141,7 @@ async function loadList() {
     })
     list.value = res.data.records || []
     total.value = res.data.total || 0
-    const sum = (list.value || []).reduce((acc, r) => acc + Number(r.cost || 0), 0)
+    const sum = (list.value || []).reduce((acc, r) => acc + Number(r.allocatedCost || 0), 0)
     totalCost.value = sum.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
   } finally {
     loading.value = false

+ 3 - 1
frontend/src/views/resource/ResourceDetail.vue

@@ -30,7 +30,9 @@
         <el-descriptions-item label="计费周期">{{ costCycleText(detail.costCycle) }}</el-descriptions-item>
         <el-descriptions-item label="购买时长">{{ detail.purchaseDurationText || '-' }}</el-descriptions-item>
         <el-descriptions-item label="关联项目">
-          <el-tag v-for="p in detail.projects" :key="p.id" size="small" style="margin-right: 4px">{{ p.name }}</el-tag>
+          <el-tag v-for="p in detail.projects" :key="p.id" size="small" style="margin-right: 4px">
+            {{ p.name }} ({{ p.allocationPercent != null ? p.allocationPercent : 100 }}%)
+          </el-tag>
           <span v-if="!detail.projects?.length" class="muted-tip">-</span>
         </el-descriptions-item>
         <el-descriptions-item label="备注" :span="3">{{ detail.remark || '-' }}</el-descriptions-item>

+ 113 - 2
frontend/src/views/resource/ResourceForm.vue

@@ -90,6 +90,20 @@
         </el-select>
       </el-form-item>
 
+      <el-form-item label="费用分摊" v-if="form.projectIds.length > 1">
+        <div class="alloc-box">
+          <div class="alloc-row" v-for="a in allocations" :key="a.projectId">
+            <span class="alloc-name">{{ projectName(a.projectId) }}</span>
+            <el-input-number v-model="a.percent" :min="0" :max="100" :precision="2" :step="5" style="width: 130px" />
+            <span class="alloc-pct">%</span>
+          </div>
+          <div class="alloc-foot">
+            <span :class="['alloc-sum', sumPercent !== 100 ? 'warn' : 'ok']">合计: {{ sumPercent }}%</span>
+            <el-button link type="primary" @click="equalSplit">均分</el-button>
+          </div>
+        </div>
+      </el-form-item>
+
       <el-form-item label="备注">
         <el-input v-model="form.remark" type="textarea" :rows="2" />
       </el-form-item>
@@ -103,7 +117,7 @@
 </template>
 
 <script setup>
-import { ref, reactive, watch } from 'vue'
+import { ref, reactive, watch, computed } from 'vue'
 import { ElMessage } from 'element-plus'
 import { resourceSaveApi, resourceDetailApi } from '@/api/resource'
 import { resourceTypeListApi, providerListApi } from '@/api/dict'
@@ -142,14 +156,55 @@ const defaultForm = () => ({
 
 const form = reactive(defaultForm())
 
+/** 每个关联项目的分摊占比: { projectId, percent } */
+const allocations = ref([])
+
 const rules = {
   name: [{ required: true, message: '请输入资源名称', trigger: 'blur' }],
   typeId: [{ required: true, message: '请选择资源类型', trigger: 'change' }],
   ownerId: [{ required: true, message: '请选择负责人', trigger: 'change' }]
 }
 
+function projectName(pid) {
+  return projectOptions.value.find(o => o.id === pid)?.name || ('项目' + pid)
+}
+
+const sumPercent = computed(() =>
+  Math.round(allocations.value.reduce((s, a) => s + (Number(a.percent) || 0), 0) * 100) / 100
+)
+
+// 关联项目变化时, 维护 allocations(保留已设占比, 新增项目均分, 单项目强制 100%)
+watch(() => form.projectIds, (ids) => {
+  const prev = {}
+  allocations.value.forEach(a => { prev[a.projectId] = a.percent })
+  const next = (ids || []).map(pid => ({ projectId: pid, percent: prev[pid] != null ? prev[pid] : null }))
+  if (next.length === 1) {
+    next[0].percent = 100
+  } else if (next.length > 1) {
+    const unset = next.filter(a => a.percent == null)
+    if (unset.length) {
+      const even = Math.round((100 / next.length) * 100) / 100
+      next.forEach(a => { if (a.percent == null) a.percent = even })
+      const sum = next.reduce((s, a) => s + (a.percent || 0), 0)
+      next[0].percent = Math.round((next[0].percent + (100 - sum)) * 100) / 100
+    }
+  }
+  allocations.value = next
+})
+
+function equalSplit() {
+  const n = allocations.value.length
+  if (!n) return
+  const even = Math.round((100 / n) * 100) / 100
+  allocations.value.forEach(a => { a.percent = even })
+  // 把因四舍五入产生的尾差补到第一项, 保证合计=100
+  const sum = allocations.value.reduce((s, a) => s + (a.percent || 0), 0)
+  allocations.value[0].percent = Math.round((even + (100 - sum)) * 100) / 100
+}
+
 function resetForm() {
   Object.assign(form, defaultForm())
+  allocations.value = []
   formRef.value?.clearValidate()
 }
 
@@ -172,6 +227,13 @@ watch(() => props.visible, async (val) => {
         ...res.data,
         projectIds: (res.data.projects || []).map(p => p.id)
       })
+      // 用详情返回的占比初始化 allocations(若后端未返回占比则按均分)
+      const projs = res.data.projects || []
+      allocations.value = projs.map(p => ({
+        projectId: p.id,
+        percent: p.allocationPercent != null ? p.allocationPercent : (projs.length ? Math.round(100 / projs.length * 100) / 100 : 100)
+      }))
+      if (projs.length === 1) allocations.value[0].percent = 100
     }
   }
 })
@@ -179,9 +241,17 @@ watch(() => props.visible, async (val) => {
 async function handleSubmit() {
   const valid = await formRef.value.validate().catch(() => false)
   if (!valid) return
+  if (form.projectIds.length > 1 && Math.abs(sumPercent.value - 100) > 0.01) {
+    ElMessage.error('多个项目的费用分摊占比合计需为 100%')
+    return
+  }
+  const payload = {
+    ...form,
+    projectAllocations: allocations.value.map(a => ({ projectId: a.projectId, percent: a.percent }))
+  }
   saving.value = true
   try {
-    await resourceSaveApi(form)
+    await resourceSaveApi(payload)
     ElMessage.success('保存成功')
     emit('update:visible', false)
     emit('saved')
@@ -190,3 +260,44 @@ async function handleSubmit() {
   }
 }
 </script>
+
+<style scoped>
+.alloc-box {
+  width: 100%;
+  border: 1px solid var(--color-border);
+  border-radius: var(--radius-sm);
+  padding: 10px 12px;
+  background: var(--color-surface-2);
+}
+.alloc-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  margin-bottom: 8px;
+}
+.alloc-name {
+  flex: 1;
+  font-size: 13px;
+  color: var(--color-text-primary);
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+.alloc-pct {
+  color: var(--color-text-secondary);
+  font-size: 13px;
+}
+.alloc-foot {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  border-top: 1px dashed var(--color-border);
+  padding-top: 8px;
+}
+.alloc-sum {
+  font-size: 13px;
+  font-weight: 600;
+}
+.alloc-sum.ok { color: var(--color-success, #18a058); }
+.alloc-sum.warn { color: var(--color-danger, #d03050); }
+</style>

+ 15 - 0
sql/migration_cost_allocation.sql

@@ -0,0 +1,15 @@
+-- 费用分摊: 给 project_resource 增加 allocation_percent(费用分摊占比) 列
+-- 并把存量"一个资源关联多个项目"的记录归一为均分(避免占比合计>100导致统计重复计入)
+ALTER TABLE `project_resource`
+  ADD COLUMN `allocation_percent` DECIMAL(7,4) NOT NULL DEFAULT 100.0000
+  COMMENT '费用分摊占比(0~100, 同一资源各关联项目占比合计应为100)';
+
+-- 存量多关联资源: 均分(每个关联占比 = 100 / 关联数)
+UPDATE `project_resource` pr
+JOIN (
+  SELECT resource_id, COUNT(*) AS cnt
+  FROM `project_resource`
+  GROUP BY resource_id
+  HAVING cnt > 1
+) s ON s.resource_id = pr.resource_id
+SET pr.allocation_percent = ROUND(100.0 / s.cnt, 4);

+ 1 - 0
sql/schema.sql

@@ -105,6 +105,7 @@ CREATE TABLE `project_resource` (
   `id`          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
   `project_id`  BIGINT UNSIGNED NOT NULL,
   `resource_id` BIGINT UNSIGNED NOT NULL,
+  `allocation_percent` DECIMAL(7,4) NOT NULL DEFAULT 100.0000 COMMENT '费用分摊占比(0~100, 同一资源各关联项目占比合计应为100)',
   `create_time` DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
   PRIMARY KEY (`id`),
   UNIQUE KEY `uk_project_resource` (`project_id`, `resource_id`),