Browse Source

feat: 采购费用统一台账 + 资源续费(手动新到期日, 费用仅入历史)

C 2 tháng trước cách đây
mục cha
commit
ae6d465570

+ 126 - 0
backend/build_v8.py

@@ -0,0 +1,126 @@
+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-v7.jar")
+DST_JAR = os.path.join(BASE, "target", "resource-platform-v8.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 50 lines) ===")
+    print("\n".join(err.splitlines()[:50]))
+    sys.exit(1)
+print("compiled classes:", len(glob.glob(os.path.join(OUT, "**", "*.class"), recursive=True)))
+
+# 3) 重打包: 以 v7 为基底, 替换最新编译的 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 ["ResourceRenewal.class", "ResourceRenewalMapper.class",
+            "ProcurementCostVO.class", "ResourceRenewalServiceImpl.class"]:
+    name = "BOOT-INF/classes/com/example/resourceplatform/" + (
+        "entity/" if "Renewal.class" in cls and "Mapper" not in cls and "Service" not in cls
+        else "mapper/" if "Mapper" in cls
+        else "dto/" if "VO" in cls
+        else "service/impl/")
+    entry = name + cls
+    print("contains", entry, ":", entry in zm.namelist())

+ 23 - 0
backend/src/main/java/com/example/resourceplatform/controller/ResourceController.java

@@ -4,6 +4,8 @@ import com.alibaba.excel.EasyExcel;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.example.resourceplatform.common.Result;
 import com.example.resourceplatform.dto.*;
+import com.example.resourceplatform.entity.ResourceRenewal;
+import com.example.resourceplatform.service.ResourceRenewalService;
 import com.example.resourceplatform.service.ResourceService;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.validation.Valid;
@@ -21,6 +23,7 @@ import java.util.List;
 public class ResourceController {
 
     private final ResourceService resourceService;
+    private final ResourceRenewalService resourceRenewalService;
 
     @GetMapping("/page")
     public Result<Page<ResourceVO>> page(ResourceQuery query) {
@@ -67,6 +70,26 @@ public class ResourceController {
         return Result.success(resourceService.costStatByType(range));
     }
 
+    /** 采购费用统一台账: 初始采购 + 每次续费, 支持过滤与分页 */
+    @GetMapping("/procurement-cost")
+    public Result<Page<ProcurementCostVO>> procurementCost(ProcurementCostQuery query) {
+        return Result.success(resourceService.procurementCostPage(query));
+    }
+
+    /** 对资源执行续费: 手动指定新到期日, 记录续费历史 */
+    @PostMapping("/{id}/renew")
+    public Result<Void> renew(@PathVariable("id") Long id, @Valid @RequestBody RenewResourceDTO dto) {
+        dto.setResourceId(id);
+        resourceRenewalService.renew(dto);
+        return Result.success();
+    }
+
+    /** 查询某资源的续费历史 */
+    @GetMapping("/{id}/renewals")
+    public Result<List<ResourceRenewal>> renewals(@PathVariable("id") Long id) {
+        return Result.success(resourceRenewalService.listByResource(id));
+    }
+
     /** 导出Excel,使用EasyExcel直接写到响应流 */
     @GetMapping("/export")
     public void export(ResourceQuery query, HttpServletResponse response) throws IOException {

+ 12 - 0
backend/src/main/java/com/example/resourceplatform/dto/ProcurementCostQuery.java

@@ -0,0 +1,12 @@
+package com.example.resourceplatform.dto;
+
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class ProcurementCostQuery extends ResourceQuery {
+
+    /** 是否续费: null-全部, true-仅续费, false-仅初始采购 */
+    private Boolean isRenewal;
+}

+ 42 - 0
backend/src/main/java/com/example/resourceplatform/dto/ProcurementCostVO.java

@@ -0,0 +1,42 @@
+package com.example.resourceplatform.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+@Data
+public class ProcurementCostVO {
+
+    /** 行唯一标识: 采购=p-{resourceId}, 续费=r-{renewalId} */
+    private String rowId;
+
+    private Long resourceId;
+
+    private String resourceName;
+
+    private String typeName;
+
+    private String providerName;
+
+    private String projectNames;
+
+    private BigDecimal cost;
+
+    /** 计费周期文本: 月付/年付/一次性 */
+    private String costCycleText;
+
+    /** 购买时长 / 续费时长文本 */
+    private String durationText;
+
+    /** 发生日期: 采购=start_date, 续费=renew_date */
+    private LocalDate occurDate;
+
+    /** 到期日期: 采购=expire_date, 续费=new_expire_date */
+    private LocalDate expireDate;
+
+    /** true=续费记录, false=初始采购 */
+    private Boolean isRenewal;
+
+    private String remark;
+}

+ 27 - 0
backend/src/main/java/com/example/resourceplatform/dto/RenewResourceDTO.java

@@ -0,0 +1,27 @@
+package com.example.resourceplatform.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+@Data
+public class RenewResourceDTO {
+
+    /** 资源ID,由路径参数注入 */
+    private Long resourceId;
+
+    /** 续费操作/生效日期,不传则取当天 */
+    private LocalDate renewDate;
+
+    /** 续费后的新到期日期(手动选择,必填) */
+    private LocalDate newExpireDate;
+
+    /** 续费时长编码: 1-1个月 ... 7-3年(仅作记录,不影响日期计算) */
+    private Integer durationCode;
+
+    /** 本次续费金额 */
+    private BigDecimal cost;
+
+    private String remark;
+}

+ 43 - 0
backend/src/main/java/com/example/resourceplatform/entity/ResourceRenewal.java

@@ -0,0 +1,43 @@
+package com.example.resourceplatform.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("resource_renewal")
+public class ResourceRenewal {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 关联 resource.id */
+    private Long resourceId;
+
+    /** 续费操作/生效日期 */
+    private LocalDate renewDate;
+
+    /** 续费前到期日期 */
+    private LocalDate oldExpireDate;
+
+    /** 续费后新到期日期 */
+    private LocalDate newExpireDate;
+
+    /** 续费时长: 1-1个月 2-2个月 3-3个月 4-6个月 5-1年 6-2年 7-3年 */
+    private Integer durationCode;
+
+    /** 本次续费金额(只作为历史记录,不回写资源费用字段) */
+    private BigDecimal cost;
+
+    private String remark;
+
+    /** 操作人,关联 sys_user.id */
+    private Long operatorId;
+
+    private LocalDateTime createTime;
+}

+ 9 - 0
backend/src/main/java/com/example/resourceplatform/mapper/ResourceRenewalMapper.java

@@ -0,0 +1,9 @@
+package com.example.resourceplatform.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.example.resourceplatform.entity.ResourceRenewal;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ResourceRenewalMapper extends BaseMapper<ResourceRenewal> {
+}

+ 15 - 0
backend/src/main/java/com/example/resourceplatform/service/ResourceRenewalService.java

@@ -0,0 +1,15 @@
+package com.example.resourceplatform.service;
+
+import com.example.resourceplatform.dto.RenewResourceDTO;
+import com.example.resourceplatform.entity.ResourceRenewal;
+
+import java.util.List;
+
+public interface ResourceRenewalService {
+
+    /** 对资源执行续费: 记录续费历史、顺延到期日并刷新状态 */
+    void renew(RenewResourceDTO dto);
+
+    /** 查询某资源的续费历史(按续费时间倒序) */
+    List<ResourceRenewal> listByResource(Long resourceId);
+}

+ 6 - 0
backend/src/main/java/com/example/resourceplatform/service/ResourceService.java

@@ -36,4 +36,10 @@ public interface ResourceService {
 
     /** 根据资源真实到期日期,刷新所有资源的status字段,定时任务调用 */
     void refreshExpireStatus();
+
+    /**
+     * 采购费用统一台账: 把每个资源的初始采购 + 每次续费合并为一条费用记录。
+     * 支持 资源名称/类型/服务商/是否续费 过滤, 按发生日期倒序分页。
+     */
+    Page<ProcurementCostVO> procurementCostPage(ProcurementCostQuery query);
 }

+ 91 - 0
backend/src/main/java/com/example/resourceplatform/service/impl/ResourceRenewalServiceImpl.java

@@ -0,0 +1,91 @@
+package com.example.resourceplatform.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.example.resourceplatform.common.BusinessException;
+import com.example.resourceplatform.common.ResourceStatus;
+import com.example.resourceplatform.dto.RenewResourceDTO;
+import com.example.resourceplatform.entity.Resource;
+import com.example.resourceplatform.entity.ResourceRenewal;
+import com.example.resourceplatform.mapper.ResourceMapper;
+import com.example.resourceplatform.mapper.ResourceRenewalMapper;
+import com.example.resourceplatform.security.SecurityUtils;
+import com.example.resourceplatform.service.OperationLogService;
+import com.example.resourceplatform.service.ResourceRenewalService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class ResourceRenewalServiceImpl implements ResourceRenewalService {
+
+    private final ResourceMapper resourceMapper;
+    private final ResourceRenewalMapper renewalMapper;
+    private final OperationLogService operationLogService;
+
+    @Value("${notify.remind-days-far}")
+    private int remindDaysFar;
+
+    @Override
+    @Transactional
+    public void renew(RenewResourceDTO dto) {
+        Resource resource = resourceMapper.selectById(dto.getResourceId());
+        if (resource == null) {
+            throw new BusinessException("资源不存在");
+        }
+        LocalDate oldExpire = resource.getExpireDate();
+        LocalDate newExpire = dto.getNewExpireDate();
+        if (newExpire == null) {
+            throw new BusinessException("请选择续费后的新到期日期");
+        }
+        // 新到期日须晚于当前到期日;若已过期则以今天为下限,避免倒挂
+        LocalDate floor = (oldExpire != null) ? oldExpire : LocalDate.now();
+        if (!newExpire.isAfter(floor)) {
+            throw new BusinessException("新到期日期须晚于当前到期日期");
+        }
+
+        ResourceRenewal renewal = new ResourceRenewal();
+        renewal.setResourceId(resource.getId());
+        renewal.setRenewDate(dto.getRenewDate() != null ? dto.getRenewDate() : LocalDate.now());
+        renewal.setOldExpireDate(oldExpire);
+        renewal.setNewExpireDate(newExpire);
+        renewal.setDurationCode(dto.getDurationCode());
+        renewal.setCost(dto.getCost() != null ? dto.getCost() : BigDecimal.ZERO);
+        renewal.setRemark(dto.getRemark());
+        renewal.setOperatorId(SecurityUtils.getCurrentUserId());
+        renewalMapper.insert(renewal);
+
+        // 更新资源到期日并刷新状态(按新到期日重算 使用中/即将到期/已过期)
+        resource.setExpireDate(newExpire);
+        resource.setStatus(computeStatus(newExpire));
+        resourceMapper.updateById(resource);
+
+        operationLogService.record("资源", "续费",
+                resource.getId(),
+                "资源:" + resource.getName() + ",续费至" + newExpire + ",金额:" + renewal.getCost());
+    }
+
+    @Override
+    public List<ResourceRenewal> listByResource(Long resourceId) {
+        return renewalMapper.selectList(
+                new LambdaQueryWrapper<ResourceRenewal>()
+                        .eq(ResourceRenewal::getResourceId, resourceId)
+                        .orderByDesc(ResourceRenewal::getCreateTime));
+    }
+
+    private int computeStatus(LocalDate expireDate) {
+        LocalDate today = LocalDate.now();
+        if (expireDate.isBefore(today)) {
+            return ResourceStatus.EXPIRED.getCode();
+        }
+        if (!expireDate.isAfter(today.plusDays(remindDaysFar))) {
+            return ResourceStatus.EXPIRING.getCode();
+        }
+        return ResourceStatus.ACTIVE.getCode();
+    }
+}

+ 98 - 0
backend/src/main/java/com/example/resourceplatform/service/impl/ResourceServiceImpl.java

@@ -32,6 +32,7 @@ public class ResourceServiceImpl implements ResourceService {
     private final ProviderMapper providerMapper;
     private final ProjectMapper projectMapper;
     private final ProjectResourceMapper projectResourceMapper;
+    private final ResourceRenewalMapper renewalMapper;
     private final SysUserService sysUserService;
     private final OperationLogService operationLogService;
 
@@ -299,6 +300,103 @@ public class ResourceServiceImpl implements ResourceService {
     }
 
     @Override
+    public Page<ProcurementCostVO> procurementCostPage(ProcurementCostQuery query) {
+        // 1) 取满足过滤条件的资源(不翻页), 既用于生成采购行, 也作为续费记录的资源范围
+        List<Resource> resources = resourceMapper.selectList(buildWrapper(query));
+        Map<Long, ResourceVO> voMap = assembleVOList(resources).stream()
+                .collect(Collectors.toMap(ResourceVO::getId, v -> v, (a, b) -> a));
+        Set<Long> matchedIds = voMap.keySet();
+
+        boolean includePurchase = query.getIsRenewal() == null || !query.getIsRenewal();
+        boolean includeRenew = query.getIsRenewal() == null || query.getIsRenewal();
+
+        List<ProcurementCostVO> rows = new ArrayList<>();
+        if (includePurchase) {
+            for (ResourceVO vo : voMap.values()) {
+                rows.add(toPurchaseRow(vo));
+            }
+        }
+        if (includeRenew && !matchedIds.isEmpty()) {
+            List<ResourceRenewal> renewals = renewalMapper.selectList(
+                    new LambdaQueryWrapper<ResourceRenewal>()
+                            .in(ResourceRenewal::getResourceId, matchedIds));
+            for (ResourceRenewal rn : renewals) {
+                rows.add(toRenewalRow(rn, voMap.get(rn.getResourceId())));
+            }
+        }
+
+        // 2) 按发生日期倒序(采购=start_date, 续费=renew_date)
+        rows.sort((a, b) -> {
+            LocalDate da = a.getOccurDate();
+            LocalDate db = b.getOccurDate();
+            if (da == null && db == null) return 0;
+            if (da == null) return 1;
+            if (db == null) return -1;
+            return db.compareTo(da);
+        });
+
+        // 3) 内存分页(数据量小, 合并两类来源后分页最简单稳妥)
+        int total = rows.size();
+        int from = (query.getPageNum() - 1) * query.getPageSize();
+        int to = Math.min(from + query.getPageSize(), total);
+        List<ProcurementCostVO> pageRows = from >= total ? List.of() : rows.subList(from, to);
+
+        Page<ProcurementCostVO> page = new Page<>(query.getPageNum(), query.getPageSize(), total);
+        page.setRecords(pageRows);
+        return page;
+    }
+
+    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 ProcurementCostVO toRenewalRow(ResourceRenewal rn, ResourceVO vo) {
+        ProcurementCostVO v = new ProcurementCostVO();
+        v.setRowId("r-" + rn.getId());
+        v.setResourceId(rn.getResourceId());
+        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());
+        return v;
+    }
+
+    private String joinProjects(List<ResourceVO.SimpleProject> projects) {
+        if (projects == null || projects.isEmpty()) return "";
+        return projects.stream().map(ResourceVO.SimpleProject::getName)
+                .filter(Objects::nonNull).collect(Collectors.joining("、"));
+    }
+
+    private String costCycleText(Integer cycle) {
+        if (cycle == null) return "";
+        if (cycle == 1) return "月付";
+        if (cycle == 2) return "年付";
+        if (cycle == 3) return "一次性";
+        return "";
+    }
+
+    @Override
     public void refreshExpireStatus() {
         LocalDate today = LocalDate.now();
         List<Resource> resources = resourceMapper.selectList(

+ 9 - 0
backend/start_v8.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-v8.jar

+ 3 - 0
frontend/src/api/resource.js

@@ -10,3 +10,6 @@ export const resourceUnrelateApi = (projectId, resourceId) =>
 export const costStatByProjectApi = (range) => request.get('/resource/cost-stat/project', { params: { range } })
 export const costStatByTypeApi = (range) => request.get('/resource/cost-stat/type', { params: { range } })
 export const resourceExportApi = (params) => request.get('/resource/export', { params, responseType: 'blob' })
+export const resourceRenewApi = (id, data) => request.post(`/resource/${id}/renew`, data)
+export const resourceRenewalsApi = (id) => request.get(`/resource/${id}/renewals`)
+export const procurementCostApi = (params) => request.get('/resource/procurement-cost', { params })

+ 1 - 0
frontend/src/layout/Layout.vue

@@ -14,6 +14,7 @@
         <el-menu-item index="/resource"><el-icon><Box /></el-icon><span>资源管理</span></el-menu-item>
         <el-menu-item index="/project"><el-icon><Folder /></el-icon><span>项目管理</span></el-menu-item>
         <el-menu-item index="/cost-stat"><el-icon><Wallet /></el-icon><span>费用统计</span></el-menu-item>
+        <el-menu-item index="/procurement-cost"><el-icon><Money /></el-icon><span>采购费用</span></el-menu-item>
         <el-menu-item index="/dict"><el-icon><Collection /></el-icon><span>字典管理</span></el-menu-item>
         <el-menu-item v-if="userStore.isAdmin()" index="/user"><el-icon><User /></el-icon><span>用户管理</span></el-menu-item>
         <el-menu-item index="/log"><el-icon><Document /></el-icon><span>操作日志</span></el-menu-item>

+ 2 - 0
frontend/src/router/index.js

@@ -17,6 +17,8 @@ const routes = [
       { path: 'project', name: 'ProjectList', component: () => import('@/views/project/ProjectList.vue'), meta: { title: '项目管理' } },
       { path: 'project/:id', name: 'ProjectDetail', component: () => import('@/views/project/ProjectDetail.vue'), meta: { title: '项目详情' } },
       { path: 'cost-stat', name: 'CostStat', component: () => import('@/views/CostStat.vue'), meta: { title: '费用统计' } },
+      { path: 'procurement-cost', name: 'ProcurementCost', component: () => import('@/views/ProcurementCost.vue'), meta: { title: '采购费用' } },
+      { path: 'resource/:id', name: 'ResourceDetail', component: () => import('@/views/resource/ResourceDetail.vue'), meta: { title: '资源详情' } },
       { path: 'dict', name: 'DictManage', component: () => import('@/views/dict/DictManage.vue'), meta: { title: '字典管理' } },
       { path: 'user', name: 'UserManage', component: () => import('@/views/system/UserManage.vue'), meta: { title: '用户管理', adminOnly: true } },
       { path: 'log', name: 'OperationLog', component: () => import('@/views/system/OperationLog.vue'), meta: { title: '操作日志' } }

+ 169 - 0
frontend/src/views/ProcurementCost.vue

@@ -0,0 +1,169 @@
+<template>
+  <div class="page-container">
+    <div class="page-head">
+      <div>
+        <div class="crumb">费用中枢 <b>/</b> <b>采购费用</b></div>
+        <h1>采购费用台账</h1>
+      </div>
+      <div class="head-tools">
+        <span class="filter-search">
+          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></svg>
+          <input v-model="query.name" placeholder="搜索资源名称…" @keyup.enter="handleSearch" />
+        </span>
+      </div>
+    </div>
+
+    <div class="page-card">
+      <div class="card-head">
+        <div>
+          <h2>费用明细</h2>
+          <span class="hint">共 {{ total }} 条 · 合计 ¥{{ totalCost }}</span>
+        </div>
+      </div>
+
+      <el-form :model="query" inline class="filter-form">
+        <el-form-item label="类型">
+          <el-select v-model="query.typeId" placeholder="全部" clearable style="width: 140px">
+            <el-option v-for="t in typeOptions" :key="t.id" :label="t.name" :value="t.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="服务商">
+          <el-select v-model="query.providerId" placeholder="全部" clearable style="width: 140px">
+            <el-option v-for="p in providerOptions" :key="p.id" :label="p.name" :value="p.id" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="费用类型">
+          <el-select v-model="query.isRenewal" placeholder="全部" clearable style="width: 140px">
+            <el-option label="初始采购" :value="false" />
+            <el-option label="续费" :value="true" />
+          </el-select>
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+          <el-button @click="handleReset">重置</el-button>
+        </el-form-item>
+      </el-form>
+
+      <el-table :data="list" v-loading="loading">
+        <el-table-column label="序号" width="60" header-align="right" align="right">
+          <template #default="{ $index }">{{ (query.pageNum - 1) * query.pageSize + $index + 1 }}</template>
+        </el-table-column>
+        <el-table-column label="资源名称" min-width="150">
+          <template #default="{ row }">
+            <a class="link-name" @click="goDetail(row.resourceId)">{{ row.resourceName || '-' }}</a>
+          </template>
+        </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">
+          <template #default="{ row }">
+            <span v-if="row.projectNames">{{ row.projectNames }}</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">
+          <template #default="{ row }">
+            <span class="mono-num">¥{{ fmtMoney(row.cost) }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="类型" width="80">
+          <template #default="{ row }">
+            <el-tag :type="row.isRenewal ? 'warning' : 'success'" size="small">
+              {{ row.isRenewal ? '续费' : '采购' }}
+            </el-tag>
+          </template>
+        </el-table-column>
+        <el-table-column prop="occurDate" label="发生日期" width="120" />
+        <el-table-column prop="expireDate" label="到期日期" width="120" />
+      </el-table>
+
+      <el-pagination
+        style="margin-top: 16px; justify-content: flex-end"
+        v-model:current-page="query.pageNum"
+        v-model:page-size="query.pageSize"
+        :total="total"
+        layout="total, prev, pager, next"
+        @current-change="loadList"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
+import { procurementCostApi } from '@/api/resource'
+import { resourceTypeListApi, providerListApi } from '@/api/dict'
+
+const router = useRouter()
+const query = reactive({ pageNum: 1, pageSize: 10, name: '', typeId: null, providerId: null, isRenewal: null })
+const list = ref([])
+const total = ref(0)
+const totalCost = ref('0.00')
+const loading = ref(false)
+
+const typeOptions = ref([])
+const providerOptions = ref([])
+
+function fmtMoney(v) {
+  const n = Number(v || 0)
+  return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+}
+
+async function loadList() {
+  loading.value = true
+  try {
+    const res = await procurementCostApi({
+      ...query,
+      // 布尔值 null 时不传, 避免后端收到字符串 "null"
+      isRenewal: query.isRenewal === null ? undefined : query.isRenewal
+    })
+    list.value = res.data.records || []
+    total.value = res.data.total || 0
+    const sum = (list.value || []).reduce((acc, r) => acc + Number(r.cost || 0), 0)
+    totalCost.value = sum.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+  } finally {
+    loading.value = false
+  }
+}
+
+function handleSearch() {
+  query.pageNum = 1
+  loadList()
+}
+
+function handleReset() {
+  Object.assign(query, { pageNum: 1, pageSize: 10, name: '', typeId: null, providerId: null, isRenewal: null })
+  loadList()
+}
+
+function goDetail(id) {
+  if (id) router.push(`/resource/${id}`)
+}
+
+onMounted(async () => {
+  loadList()
+  const [t, p] = await Promise.all([resourceTypeListApi(), providerListApi()])
+  typeOptions.value = t.data
+  providerOptions.value = p.data
+})
+</script>
+
+<style scoped>
+.filter-form {
+  margin: 4px 0 16px;
+}
+.muted-tip {
+  color: var(--color-text-placeholder);
+  font-size: 12px;
+}
+.link-name {
+  color: var(--color-primary);
+  cursor: pointer;
+}
+.link-name:hover {
+  text-decoration: underline;
+}
+</style>

+ 244 - 0
frontend/src/views/resource/ResourceDetail.vue

@@ -0,0 +1,244 @@
+<template>
+  <div class="page-container" v-loading="loading">
+    <div class="page-head">
+      <div>
+        <div class="crumb">
+          <a class="link-back" @click="goBack">资源台账</a> <b>/</b> <b>资源详情</b>
+        </div>
+        <h1>{{ detail.name || '资源详情' }}</h1>
+      </div>
+      <div class="head-tools">
+        <el-button type="primary" @click="openRenew">续费</el-button>
+        <el-button @click="goBack">返回</el-button>
+      </div>
+    </div>
+
+    <div class="page-card" v-if="detail.id">
+      <div class="card-head"><h2>基础信息</h2></div>
+      <el-descriptions :column="3" border>
+        <el-descriptions-item label="资源名称">{{ detail.name }}</el-descriptions-item>
+        <el-descriptions-item label="类型">{{ detail.typeName || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="服务商">{{ detail.providerName || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="账号信息">{{ detail.accountInfo || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="负责人">{{ detail.ownerName || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="状态">
+          <span :class="['status-badge', statusClass(detail.status)]">{{ detail.statusText }}</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="开通日期">{{ detail.startDate || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="到期日期">{{ detail.expireDate || '-' }}</el-descriptions-item>
+        <el-descriptions-item label="费用">¥{{ fmtMoney(detail.cost) }}</el-descriptions-item>
+        <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>
+          <span v-if="!detail.projects?.length" class="muted-tip">-</span>
+        </el-descriptions-item>
+        <el-descriptions-item label="备注" :span="3">{{ detail.remark || '-' }}</el-descriptions-item>
+      </el-descriptions>
+    </div>
+
+    <div class="page-card">
+      <div class="card-head">
+        <h2>续费历史</h2>
+        <span class="hint">共 {{ renewals.length }} 次</span>
+      </div>
+      <el-table :data="renewals" v-loading="renewLoading">
+        <el-table-column label="序号" width="60" header-align="right" align="right">
+          <template #default="{ $index }">{{ $index + 1 }}</template>
+        </el-table-column>
+        <el-table-column prop="renewDate" label="续费日期" width="130" />
+        <el-table-column label="到期日变更" min-width="200">
+          <template #default="{ row }">
+            <span class="mono-num">{{ row.oldExpireDate || '-' }}</span>
+            <span class="arrow"> → </span>
+            <span class="mono-num renew-target">{{ row.newExpireDate }}</span>
+          </template>
+        </el-table-column>
+        <el-table-column label="续费时长" width="110">
+          <template #default="{ row }">{{ durationText(row.durationCode) }}</template>
+        </el-table-column>
+        <el-table-column label="续费金额" width="120" header-align="right" align="right">
+          <template #default="{ row }"><span class="mono-num">¥{{ fmtMoney(row.cost) }}</span></template>
+        </el-table-column>
+        <el-table-column prop="remark" label="备注" min-width="140" show-overflow-tooltip />
+        <el-table-column label="操作人" width="100">
+          <template #default="{ row }">{{ operatorName(row.operatorId) }}</template>
+        </el-table-column>
+      </el-table>
+      <el-empty v-if="!renewLoading && renewals.length === 0" description="暂无续费记录" :image-size="80" />
+    </div>
+
+    <el-dialog v-model="renewVisible" title="资源续费" width="520px" @closed="resetRenewForm">
+      <el-form :model="renewForm" :rules="renewRules" ref="renewRef" label-width="90px">
+        <el-form-item label="续费日期">
+          <el-date-picker v-model="renewForm.renewDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="新到期日期" prop="newExpireDate">
+          <el-date-picker v-model="renewForm.newExpireDate" type="date" value-format="YYYY-MM-DD" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="续费时长">
+          <el-select v-model="renewForm.durationCode" placeholder="请选择" style="width: 100%" clearable>
+            <el-option label="1个月" :value="1" />
+            <el-option label="2个月" :value="2" />
+            <el-option label="3个月" :value="3" />
+            <el-option label="6个月" :value="4" />
+            <el-option label="1年" :value="5" />
+            <el-option label="2年" :value="6" />
+            <el-option label="3年" :value="7" />
+          </el-select>
+        </el-form-item>
+        <el-form-item label="续费金额">
+          <el-input-number v-model="renewForm.cost" :min="0" :precision="2" style="width: 100%" />
+        </el-form-item>
+        <el-form-item label="备注">
+          <el-input v-model="renewForm.remark" type="textarea" :rows="2" />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="renewVisible = false">取消</el-button>
+        <el-button type="primary" :loading="renewing" @click="submitRenew">确定续费</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup>
+import { ref, reactive, onMounted } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { ElMessage } from 'element-plus'
+import { resourceDetailApi, resourceRenewApi, resourceRenewalsApi } from '@/api/resource'
+import { userEnabledListApi } from '@/api/user'
+import { resourceStatusClass } from '@/utils/status'
+
+const route = useRoute()
+const router = useRouter()
+const resourceId = Number(route.params.id)
+
+const detail = reactive({})
+const renewals = ref([])
+const loading = ref(false)
+const renewLoading = ref(false)
+
+const userOptions = ref([])
+const renewVisible = ref(false)
+const renewing = ref(false)
+const renewRef = ref(null)
+
+const renewForm = reactive({
+  renewDate: todayStr(),
+  newExpireDate: '',
+  durationCode: 5,
+  cost: 0,
+  remark: ''
+})
+const renewRules = {
+  newExpireDate: [{ required: true, message: '请选择新到期日期', trigger: 'change' }]
+}
+
+function todayStr() {
+  const d = new Date()
+  const p = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
+}
+function fmtMoney(v) {
+  return Number(v || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
+}
+function costCycleText(c) {
+  return c === 1 ? '月付' : c === 2 ? '年付' : c === 3 ? '一次性' : '-'
+}
+function durationText(code) {
+  const map = { 1: '1个月', 2: '2个月', 3: '3个月', 4: '6个月', 5: '1年', 6: '2年', 7: '3年' }
+  return code != null ? (map[code] || '-') : '-'
+}
+function statusClass(s) {
+  return resourceStatusClass(s)
+}
+function operatorName(id) {
+  if (id == null) return '-'
+  return userOptions.value.find((u) => u.id === id)?.realName || `用户${id}`
+}
+function goBack() {
+  router.push('/resource')
+}
+
+async function loadDetail() {
+  loading.value = true
+  try {
+    const res = await resourceDetailApi(resourceId)
+    Object.assign(detail, res.data)
+  } finally {
+    loading.value = false
+  }
+}
+async function loadRenewals() {
+  renewLoading.value = true
+  try {
+    const res = await resourceRenewalsApi(resourceId)
+    renewals.value = res.data || []
+  } finally {
+    renewLoading.value = false
+  }
+}
+
+function openRenew() {
+  renewForm.renewDate = todayStr()
+  // 默认顺延一年作为便利初值, 用户可改
+  renewForm.newExpireDate = detail.expireDate ? addYears(detail.expireDate, 1) : addYears(todayStr(), 1)
+  renewForm.durationCode = 5
+  renewForm.cost = detail.cost || 0
+  renewForm.remark = ''
+  renewVisible.value = true
+}
+function addYears(dateStr, years) {
+  const d = new Date(dateStr)
+  d.setFullYear(d.getFullYear() + years)
+  const p = (n) => String(n).padStart(2, '0')
+  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
+}
+function resetRenewForm() {
+  renewRef.value?.clearValidate()
+}
+async function submitRenew() {
+  const valid = await renewRef.value.validate().catch(() => false)
+  if (!valid) return
+  renewing.value = true
+  try {
+    await resourceRenewApi(resourceId, { ...renewForm })
+    ElMessage.success('续费成功')
+    renewVisible.value = false
+    await Promise.all([loadDetail(), loadRenewals()])
+  } finally {
+    renewing.value = false
+  }
+}
+
+onMounted(async () => {
+  await Promise.all([loadDetail(), loadRenewals()])
+  const res = await userEnabledListApi().catch(() => ({ data: [] }))
+  userOptions.value = res.data || []
+})
+</script>
+
+<style scoped>
+.link-back {
+  color: var(--color-primary);
+  cursor: pointer;
+}
+.link-back:hover {
+  text-decoration: underline;
+}
+.muted-tip {
+  color: var(--color-text-placeholder);
+  font-size: 12px;
+}
+.mono-num {
+  font-variant-numeric: tabular-nums;
+}
+.arrow {
+  color: var(--color-text-placeholder);
+}
+.renew-target {
+  color: var(--color-primary);
+  font-weight: 600;
+}
+</style>

+ 18 - 1
frontend/src/views/resource/ResourceList.vue

@@ -52,7 +52,11 @@
         <el-table-column label="序号" width="60" header-align="right" align="right">
           <template #default="{ $index }">{{ (query.pageNum - 1) * query.pageSize + $index + 1 }}</template>
         </el-table-column>
-        <el-table-column prop="name" label="资源名称" min-width="150" />
+        <el-table-column prop="name" label="资源名称" min-width="150">
+          <template #default="{ row }">
+            <a class="link-name" @click="goDetail(row.id)">{{ row.name }}</a>
+          </template>
+        </el-table-column>
         <el-table-column prop="typeName" label="类型" width="110" />
         <el-table-column prop="providerName" label="服务商" width="100" />
         <el-table-column prop="purchaseDurationText" label="购买时长" width="90" />
@@ -104,6 +108,7 @@
 
 <script setup>
 import { ref, reactive, onMounted } from 'vue'
+import { useRouter } from 'vue-router'
 import { ElMessage } from 'element-plus'
 import ResourceForm from './ResourceForm.vue'
 import { resourcePageApi, resourceDeleteApi, resourceExportApi } from '@/api/resource'
@@ -113,6 +118,7 @@ import { resourceStatusClass } from '@/utils/status'
 import { useScrollRestore } from '@/composables/useScrollRestore'
 
 useScrollRestore()
+const router = useRouter()
 
 const query = reactive({ pageNum: 1, pageSize: 10, name: '', typeId: null, ownerId: null, status: null })
 const list = ref([])
@@ -155,6 +161,10 @@ function openCreate() {
   formVisible.value = true
 }
 
+function goDetail(id) {
+  router.push(`/resource/${id}`)
+}
+
 function openEdit(id) {
   editingId.value = id
   formVisible.value = true
@@ -192,4 +202,11 @@ onMounted(async () => {
   color: var(--color-text-placeholder);
   font-size: 12px;
 }
+.link-name {
+  color: var(--color-primary);
+  cursor: pointer;
+}
+.link-name:hover {
+  text-decoration: underline;
+}
 </style>

+ 15 - 0
sql/migration_renewal.sql

@@ -0,0 +1,15 @@
+-- 资源续费记录表迁移(在已有 resource_platform 库上执行, 不影响存量数据)
+CREATE TABLE IF NOT EXISTS `resource_renewal` (
+  `id`               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+  `resource_id`      BIGINT UNSIGNED NOT NULL COMMENT '关联resource.id',
+  `renew_date`       DATE            DEFAULT NULL COMMENT '续费操作/生效日期',
+  `old_expire_date`  DATE            DEFAULT NULL COMMENT '续费前到期日期',
+  `new_expire_date`  DATE            DEFAULT NULL COMMENT '续费后新到期日期',
+  `duration_code`    TINYINT         DEFAULT NULL COMMENT '续费时长: 1-1个月 2-2个月 3-3个月 4-6个月 5-1年 6-2年 7-3年',
+  `cost`             DECIMAL(12,2)   DEFAULT 0.00 COMMENT '本次续费金额(仅作历史记录, 不回写资源费用)',
+  `remark`           VARCHAR(500)    DEFAULT NULL,
+  `operator_id`      BIGINT UNSIGNED DEFAULT 0 COMMENT '操作人, 关联sys_user.id',
+  `create_time`      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_resource` (`resource_id`)
+) ENGINE=InnoDB COMMENT='资源续费记录表';

+ 19 - 0
sql/schema.sql

@@ -112,6 +112,25 @@ CREATE TABLE `project_resource` (
 ) ENGINE=InnoDB COMMENT='项目资源关联表';
 
 -- ============================================
+-- 6.1 资源续费记录表
+-- ============================================
+DROP TABLE IF EXISTS `resource_renewal`;
+CREATE TABLE `resource_renewal` (
+  `id`               BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
+  `resource_id`      BIGINT UNSIGNED NOT NULL COMMENT '关联resource.id',
+  `renew_date`       DATE            DEFAULT NULL COMMENT '续费操作/生效日期',
+  `old_expire_date`  DATE            DEFAULT NULL COMMENT '续费前到期日期',
+  `new_expire_date`  DATE            DEFAULT NULL COMMENT '续费后新到期日期',
+  `duration_code`    TINYINT         DEFAULT NULL COMMENT '续费时长: 1-1个月 2-2个月 3-3个月 4-6个月 5-1年 6-2年 7-3年',
+  `cost`             DECIMAL(12,2)   DEFAULT 0.00 COMMENT '本次续费金额(仅作历史记录, 不回写资源费用)',
+  `remark`           VARCHAR(500)    DEFAULT NULL,
+  `operator_id`      BIGINT UNSIGNED DEFAULT 0 COMMENT '操作人, 关联sys_user.id',
+  `create_time`      DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  PRIMARY KEY (`id`),
+  KEY `idx_resource` (`resource_id`)
+) ENGINE=InnoDB COMMENT='资源续费记录表';
+
+-- ============================================
 -- 7. 到期提醒记录表(避免重复发送邮件)
 -- ============================================
 DROP TABLE IF EXISTS `expire_notify_log`;