Explorar o código

feat(cost): 采购费用支持按月份筛选并导出Excel(含月份维度)

C hai 2 meses
pai
achega
d9aac2cc2c

+ 125 - 0
backend/build_v9.py

@@ -0,0 +1,125 @@
+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-v8.jar")
+DST_JAR = os.path.join(BASE, "target", "resource-platform-v9.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) 重打包: 以 v8 为基底, 替换最新编译的 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 ["ProcurementCostExportVO.class", "ProcurementCostQuery.class",
+            "ResourceController.class", "ResourceServiceImpl.class"]:
+    entry = "BOOT-INF/classes/com/example/resourceplatform/" + (
+        "dto/" if cls.endswith("VO.class") or "Query" in cls
+        else "controller/" if "Controller" in cls
+        else "service/impl/")
+    entry = entry + cls
+    print("contains", entry, ":", entry in zm.namelist())

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

@@ -76,6 +76,22 @@ public class ResourceController {
         return Result.success(resourceService.procurementCostPage(query));
     }
 
+    /** 导出采购费用台账为 Excel(按当前筛选条件, 含月份); 文件名带月份 */
+    @GetMapping("/procurement-cost/export")
+    public void exportProcurementCost(ProcurementCostQuery query, HttpServletResponse response) throws IOException {
+        List<ProcurementCostExportVO> list = resourceService.procurementCostExportList(query);
+
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setCharacterEncoding("utf-8");
+        String month = (query.getMonth() == null || query.getMonth().isBlank()) ? "全部" : query.getMonth();
+        String fileName = URLEncoder.encode("采购费用_" + month, StandardCharsets.UTF_8);
+        response.setHeader("Content-Disposition", "attachment; filename*=utf-8''" + fileName + ".xlsx");
+
+        EasyExcel.write(response.getOutputStream(), ProcurementCostExportVO.class)
+                .sheet("采购费用")
+                .doWrite(list);
+    }
+
     /** 对资源执行续费: 手动指定新到期日, 记录续费历史 */
     @PostMapping("/{id}/renew")
     public Result<Void> renew(@PathVariable("id") Long id, @Valid @RequestBody RenewResourceDTO dto) {

+ 56 - 0
backend/src/main/java/com/example/resourceplatform/dto/ProcurementCostExportVO.java

@@ -0,0 +1,56 @@
+package com.example.resourceplatform.dto;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+
+@Data
+public class ProcurementCostExportVO {
+
+    @ExcelProperty("资源名称")
+    private String resourceName;
+
+    @ExcelProperty("类型")
+    private String typeName;
+
+    @ExcelProperty("服务商")
+    private String providerName;
+
+    @ExcelProperty("关联项目")
+    private String projectNames;
+
+    @ExcelProperty("计费周期")
+    private String costCycleText;
+
+    @ExcelProperty("购买/续费时长")
+    private String durationText;
+
+    @ExcelProperty("费用")
+    private BigDecimal cost;
+
+    @ExcelProperty("费用类型")
+    private String costTypeText;
+
+    @ExcelProperty("发生日期")
+    private LocalDate occurDate;
+
+    @ExcelProperty("到期日期")
+    private LocalDate expireDate;
+
+    public static ProcurementCostExportVO from(ProcurementCostVO v) {
+        ProcurementCostExportVO e = new ProcurementCostExportVO();
+        e.setResourceName(v.getResourceName());
+        e.setTypeName(v.getTypeName());
+        e.setProviderName(v.getProviderName());
+        e.setProjectNames(v.getProjectNames());
+        e.setCostCycleText(v.getCostCycleText());
+        e.setDurationText(v.getDurationText());
+        e.setCost(v.getCost());
+        e.setCostTypeText(v.getIsRenewal() ? "续费" : "采购");
+        e.setOccurDate(v.getOccurDate());
+        e.setExpireDate(v.getExpireDate());
+        return e;
+    }
+}

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

@@ -9,4 +9,7 @@ public class ProcurementCostQuery extends ResourceQuery {
 
     /** 是否续费: null-全部, true-仅续费, false-仅初始采购 */
     private Boolean isRenewal;
+
+    /** 月份筛选(按发生日期), 格式 yyyy-MM; 为空表示不限月份 */
+    private String month;
 }

+ 4 - 1
backend/src/main/java/com/example/resourceplatform/service/ResourceService.java

@@ -39,7 +39,10 @@ public interface ResourceService {
 
     /**
      * 采购费用统一台账: 把每个资源的初始采购 + 每次续费合并为一条费用记录。
-     * 支持 资源名称/类型/服务商/是否续费 过滤, 按发生日期倒序分页。
+     * 支持 资源名称/类型/服务商/是否续费/月份 过滤, 按发生日期倒序分页。
      */
     Page<ProcurementCostVO> procurementCostPage(ProcurementCostQuery query);
+
+    /** 采购费用台账不分页全量(导出Excel用), 过滤条件同 procurementCostPage */
+    List<ProcurementCostExportVO> procurementCostExportList(ProcurementCostQuery query);
 }

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

@@ -301,6 +301,28 @@ public class ResourceServiceImpl implements ResourceService {
 
     @Override
     public Page<ProcurementCostVO> procurementCostPage(ProcurementCostQuery query) {
+        // 1) 合并两类来源(采购 + 续费)并按发生日期倒序, 若指定月份则按月过滤
+        List<ProcurementCostVO> rows = buildProcurementRows(query);
+
+        // 2) 内存分页(数据量小, 合并两类来源后分页最简单稳妥)
+        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;
+    }
+
+    @Override
+    public List<ProcurementCostExportVO> procurementCostExportList(ProcurementCostQuery query) {
+        List<ProcurementCostVO> rows = buildProcurementRows(query);
+        return rows.stream().map(ProcurementCostExportVO::from).toList();
+    }
+
+    /** 合并初始采购与续费记录为统一台账, 按发生日期倒序; month(非空)时仅保留该月发生的记录 */
+    private List<ProcurementCostVO> buildProcurementRows(ProcurementCostQuery query) {
         // 1) 取满足过滤条件的资源(不翻页), 既用于生成采购行, 也作为续费记录的资源范围
         List<Resource> resources = resourceMapper.selectList(buildWrapper(query));
         Map<Long, ResourceVO> voMap = assembleVOList(resources).stream()
@@ -335,15 +357,15 @@ public class ResourceServiceImpl implements ResourceService {
             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;
+        // 3) 月份筛选(按发生日期): 命中 query.month 前缀(yyyy-MM)的记录
+        String month = query.getMonth();
+        if (month != null && !month.isBlank()) {
+            final String m = month;
+            rows = rows.stream()
+                    .filter(r -> r.getOccurDate() != null && r.getOccurDate().toString().startsWith(m))
+                    .toList();
+        }
+        return rows;
     }
 
     private ProcurementCostVO toPurchaseRow(ResourceVO vo) {

+ 9 - 0
backend/start_v9.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-v9.jar

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

@@ -13,3 +13,4 @@ export const resourceExportApi = (params) => request.get('/resource/export', { p
 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 })
+export const procurementCostExportApi = (params) => request.get('/resource/procurement-cost/export', { params, responseType: 'blob' })

+ 38 - 4
frontend/src/views/ProcurementCost.vue

@@ -38,9 +38,21 @@
             <el-option label="续费" :value="true" />
           </el-select>
         </el-form-item>
+        <el-form-item label="月份">
+          <el-date-picker
+            v-model="query.month"
+            type="month"
+            value-format="YYYY-MM"
+            placeholder="全部月份"
+            clearable
+            style="width: 150px"
+            @change="handleSearch"
+          />
+        </el-form-item>
         <el-form-item>
           <el-button type="primary" @click="handleSearch">查询</el-button>
           <el-button @click="handleReset">重置</el-button>
+          <el-button type="success" @click="handleExport">导出 Excel</el-button>
         </el-form-item>
       </el-form>
 
@@ -94,11 +106,11 @@
 <script setup>
 import { ref, reactive, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
-import { procurementCostApi } from '@/api/resource'
+import { procurementCostApi, procurementCostExportApi } 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 query = reactive({ pageNum: 1, pageSize: 10, name: '', typeId: null, providerId: null, isRenewal: null, month: '' })
 const list = ref([])
 const total = ref(0)
 const totalCost = ref('0.00')
@@ -118,7 +130,8 @@ async function loadList() {
     const res = await procurementCostApi({
       ...query,
       // 布尔值 null 时不传, 避免后端收到字符串 "null"
-      isRenewal: query.isRenewal === null ? undefined : query.isRenewal
+      isRenewal: query.isRenewal === null ? undefined : query.isRenewal,
+      month: query.month || undefined
     })
     list.value = res.data.records || []
     total.value = res.data.total || 0
@@ -135,10 +148,31 @@ function handleSearch() {
 }
 
 function handleReset() {
-  Object.assign(query, { pageNum: 1, pageSize: 10, name: '', typeId: null, providerId: null, isRenewal: null })
+  Object.assign(query, { pageNum: 1, pageSize: 10, name: '', typeId: null, providerId: null, isRenewal: null, month: '' })
   loadList()
 }
 
+async function handleExport() {
+  try {
+    const params = {
+      name: query.name || undefined,
+      typeId: query.typeId || undefined,
+      providerId: query.providerId || undefined,
+      isRenewal: query.isRenewal === null ? undefined : query.isRenewal,
+      month: query.month || undefined
+    }
+    const blob = await procurementCostExportApi(params)
+    const url = window.URL.createObjectURL(new Blob([blob]))
+    const link = document.createElement('a')
+    link.href = url
+    link.download = `采购费用_${query.month || '全部'}.xlsx`
+    link.click()
+    window.URL.revokeObjectURL(url)
+  } catch (e) {
+    // 错误提示已由 axios 拦截器统一处理
+  }
+}
+
 function goDetail(id) {
   if (id) router.push(`/resource/${id}`)
 }