ソースを参照

准入管理调整&电费核算

fuyuchuan 1 日 前
コミット
783d235fb0

+ 27 - 2
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/controller/web/SettlementController.java

@@ -3,6 +3,7 @@ package com.usky.vpp.controller.web;
 import com.usky.common.core.bean.ApiResult;
 import com.usky.common.core.bean.CommonPage;
 import com.usky.vpp.domain.VppEnergyReadingMonthly;
+import com.usky.vpp.domain.VppSettlementBill;
 import com.usky.vpp.service.VppSettlementService;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
@@ -67,8 +68,32 @@ public class SettlementController {
     }
 
     @GetMapping(value = "/bill")
-    public ApiResult<Object> pageBill(@RequestParam(required = false) java.util.Map<String, Object> params) {
-        return ApiResult.success(null);
+    public ApiResult<CommonPage<VppSettlementBill>> pageBill(
+            @RequestParam(value = "id", required = false) Long id,
+            @RequestParam(value = "customerName", required = false) String customerName,
+            @RequestParam(value = "current", required = false, defaultValue = "1") Integer current,
+            @RequestParam(value = "size", required = false, defaultValue = "20") Integer size) {
+        return ApiResult.success(vppSettlementService.pageBill(id, customerName, current, size));
+    }
+
+    /**
+     * 导出电费账单数据(Excel)
+     * <ul>
+     *   <li>scope = "current":导出当前页,需传 current + size</li>
+     *   <li>scope = "all":导出当前租户全部未删除电费账单</li>
+     *   <li>scope = "filtered":导出筛选结果,需至少传一个筛选条件</li>
+     * </ul>
+     */
+    @GetMapping(value = "/bill/export")
+    public void exportBill(
+            @RequestParam("scope") String scope,
+            @RequestParam("fileName") String fileName,
+            @RequestParam(value = "id", required = false) Long id,
+            @RequestParam(value = "customerName", required = false) String customerName,
+            @RequestParam(value = "current", required = false) Integer current,
+            @RequestParam(value = "size", required = false) Integer size,
+            HttpServletResponse response) {
+        vppSettlementService.exportBill(scope, fileName, id, customerName, current, size, response);
     }
 
     @PostMapping(value = "/bill")

+ 0 - 9
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppCustomerAccess.java

@@ -52,15 +52,6 @@ public class VppCustomerAccess implements Serializable {
     @TableField(exist = false)
     private LocalDateTime signingDate;
 
-    @TableField(exist = false)
-    private String contactName;
-
-    @TableField(exist = false)
-    private String contactPhone;
-
-    @TableField(exist = false)
-    private String contactEmail;
-
     @TableField("apply_at")
     private LocalDateTime applyAt;
     @TableField("tenant_id")

+ 3 - 3
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppEnergyReadingMonthly.java

@@ -29,11 +29,11 @@ public class VppEnergyReadingMonthly implements Serializable {
     @TableField(exist = false)
     private String customerName;
 
-    @TableField("resource_id")
-    private Long resourceId;
+    @TableField("site_id")
+    private Long siteId;
 
     @TableField(exist = false)
-    private String resourceName;
+    private String siteName;
 
     @TableField("settle_year")
     private String settleYear;

+ 13 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/domain/VppSettlementBill.java

@@ -27,12 +27,25 @@ public class VppSettlementBill implements Serializable {
     private String billNo;
     @TableField("customer_id")
     private Long customerId;
+
+    @TableField(exist = false)
+    private String customerName;
+
+    @TableField("site_id")
+    private Long siteId;
+
+    @TableField(exist = false)
+    private String siteName;
+
     @TableField("settle_year")
     private String settleYear;
     @TableField("settle_month")
     private Integer settleMonth;
     @TableField("total_energy_kwh")
     private BigDecimal totalEnergyKwh;
+    @TableField(exist = false)
+    private BigDecimal contractPrice;
+
     @TableField("total_amount")
     private BigDecimal totalAmount;
     @TableField("paid_amount")

+ 12 - 8
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/VppSettlementService.java

@@ -2,6 +2,7 @@ package com.usky.vpp.service;
 
 import com.usky.common.core.bean.CommonPage;
 import com.usky.vpp.domain.VppEnergyReadingMonthly;
+import com.usky.vpp.domain.VppSettlementBill;
 
 import javax.servlet.http.HttpServletResponse;
 import java.util.Map;
@@ -20,15 +21,18 @@ public interface VppSettlementService {
 
     /**
      * 导出月度电量核算数据(Excel)
-     *
-     * @param scope        导出范围:current / all / filtered
-     * @param fileName     文件名(不含扩展名)
-     * @param id           客户ID精确匹配(filtered 模式可用)
-     * @param customerName 客户名称模糊匹配(filtered 模式可用)
-     * @param calcCycle    核算周期,格式 yyyy-MM(filtered 模式可用)
-     * @param current      当前页码(current 模式必传)
-     * @param size         页大小(current 模式必传)
      */
     void exportReading(String scope, String fileName, Long id, String customerName, String calcCycle,
                        Integer current, Integer size, HttpServletResponse response);
+
+    /**
+     * 分页查询电费账单
+     */
+    CommonPage<VppSettlementBill> pageBill(Long id, String customerName, Integer current, Integer size);
+
+    /**
+     * 导出电费账单数据(Excel)
+     */
+    void exportBill(String scope, String fileName, Long id, String customerName,
+                    Integer current, Integer size, HttpServletResponse response);
 }

+ 120 - 52
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppCustomerServiceImpl.java

@@ -82,6 +82,11 @@ public class VppCustomerServiceImpl implements VppCustomerService {
         validateAccessRequest(request);
         checkCustomerNameUnique(request.getCustomerName(), null);
 
+        VppCustomer customer = buildCustomerFromAccessRequest(request);
+        VppAuditHelper.fillCreate(customer);
+        customerMapper.insert(customer);
+        savePrimaryContact(customer.getId(), request);
+
         VppCustomerAccess access = new VppCustomerAccess();
         access.setApplyNo(VppAuditHelper.nextApplyNo());
         access.setAccountNo(request.getAccountNo());
@@ -91,17 +96,12 @@ public class VppCustomerServiceImpl implements VppCustomerService {
         access.setContractCapacity(request.getContractCapacity());
         access.setBusinessLicenseUrl(request.getBusinessLicenseUrl());
         access.setCreditStatus(request.getCreditStatus());
+        access.setCustomerId(customer.getId());
         access.setAccessStatus(ACCESS_PENDING);
         access.setApplyAt(LocalDateTime.now());
-        // 暂存联系人信息(审核通过时写入联系人表)
-        access.setContactName(request.getContactName());
-        access.setContactPhone(request.getContactPhone());
-        access.setContactEmail(request.getContactEmail());
-        // access.setSigningDate(request.getSigningDate());
         VppAuditHelper.fillCreate(access);
 
-        int insert = accessMapper.insert(access);
-        return insert > 0;
+        return accessMapper.insert(access) > 0;
     }
 
     @Override
@@ -127,9 +127,17 @@ public class VppCustomerServiceImpl implements VppCustomerService {
             return new CommonPage<>(Collections.emptyList(), page.getTotal(), pageSize, pageNum);
         }
 
+        Map<Long, VppCustomer> customerMap = loadCustomerMap(records.stream()
+                .map(VppCustomerAccess::getCustomerId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet()));
+        Map<Long, VppCustomerContact> primaryContactMap = loadPrimaryContactMap(customerMap.keySet());
+
         List<CustomerAccessResponseVO> list = records.stream().map(entity -> {
             CustomerAccessResponseVO vo = new CustomerAccessResponseVO();
             BeanUtils.copyProperties(entity, vo);
+            fillAccessContactInfo(vo, customerMap.get(entity.getCustomerId()),
+                    primaryContactMap.get(entity.getCustomerId()));
             return vo;
         }).collect(Collectors.toList());
 
@@ -156,38 +164,11 @@ public class VppCustomerServiceImpl implements VppCustomerService {
         access.setAuditAt(LocalDateTime.now());
 
         if (request.getResult() == ACCESS_PASSED) {
-            // 审核通过:校验同租户下 accountNo 和 customerName 唯一
-            Integer tenantId = SecurityUtils.getTenantId();
-            LambdaQueryWrapper<VppCustomer> dupWrapper = new LambdaQueryWrapper<>();
-            dupWrapper.eq(VppCustomer::getTenantId, tenantId)
-                    .eq(VppCustomer::getDeleteFlag, VppAuditHelper.NOT_DELETED)
-                    .and(w -> w.
-                            // eq(VppCustomer::getAccountNo, access.getAccountNo())
-                            // .or().
-                                    eq(VppCustomer::getCustomerName, access.getCustomerName()));
-            Integer dupCount = customerMapper.selectCount(dupWrapper);
-            if (dupCount != null && dupCount > 0) {
-                // throw new BusinessException("同租户下已存在相同电力户号或客户名称的客户!");
-                throw new BusinessException("租户下已存在相同名称的客户!");
+            if (access.getCustomerId() == null) {
+                throw new BusinessException("准入申请未关联客户,无法审核通过!");
             }
-
+            checkCustomerNameUnique(access.getCustomerName(), access.getCustomerId());
             access.setAccessStatus(ACCESS_PASSED);
-            VppCustomer customer = buildCustomerFromAccess(access);
-            VppAuditHelper.fillCreate(customer);
-            customerMapper.insert(customer);
-            access.setCustomerId(customer.getId());
-
-            // 审核通过时,同步创建主联系人
-            if (StringUtils.isNotBlank(access.getContactName())) {
-                VppCustomerContact contact = new VppCustomerContact();
-                contact.setCustomerId(customer.getId());
-                contact.setContactName(access.getContactName());
-                contact.setContactPhone(access.getContactPhone());
-                contact.setContactEmail(access.getContactEmail());
-                contact.setIsPrimary(1);
-                VppAuditHelper.fillCreate(contact);
-                contactMapper.insert(contact);
-            }
         } else {
             access.setAccessStatus(ACCESS_REJECTED);
         }
@@ -208,7 +189,10 @@ public class VppCustomerServiceImpl implements VppCustomerService {
         if (access.getAccessStatus() != ACCESS_REJECTED) {
             throw new BusinessException("仅已驳回的准入申请允许修改!");
         }
-        checkCustomerNameUnique(request.getCustomerName(), null);
+        if (access.getCustomerId() == null) {
+            throw new BusinessException("准入申请未关联客户,无法修改!");
+        }
+        checkCustomerNameUnique(request.getCustomerName(), access.getCustomerId());
 
         access.setAccountNo(request.getAccountNo());
         access.setCustomerName(request.getCustomerName());
@@ -217,14 +201,17 @@ public class VppCustomerServiceImpl implements VppCustomerService {
         access.setContractCapacity(request.getContractCapacity());
         access.setBusinessLicenseUrl(request.getBusinessLicenseUrl());
         access.setCreditStatus(request.getCreditStatus());
-        access.setContactName(request.getContactName());
-        access.setContactPhone(request.getContactPhone());
-        access.setContactEmail(request.getContactEmail());
         access.setAccessStatus(ACCESS_PENDING);
         access.setAuditOpinion(null);
         access.setAuditAt(null);
         access.setApplyAt(LocalDateTime.now());
 
+        VppCustomer customer = findCustomerById(access.getCustomerId());
+        applyCustomerFromAccessRequest(customer, request);
+        VppAuditHelper.fillUpdate(customer);
+        customerMapper.updateById(customer);
+        upsertPrimaryContact(customer.getId(), request);
+
         VppAuditHelper.fillUpdate(access);
         return accessMapper.updateById(access) > 0;
     }
@@ -947,21 +934,89 @@ public class VppCustomerServiceImpl implements VppCustomerService {
 
     // ==================== 内部辅助方法 ====================
 
-    private VppCustomer buildCustomerFromAccess(VppCustomerAccess access) {
+    private VppCustomer buildCustomerFromAccessRequest(CustomerAccessRequestVO request) {
         VppCustomer customer = new VppCustomer();
-        customer.setAccountNo(access.getAccountNo());
-        customer.setCustomerName(access.getCustomerName());
-        customer.setCustomerType(access.getCustomerType());
-        customer.setPowerCompany(access.getPowerCompany());
-        customer.setContractCapacity(access.getContractCapacity());
-        customer.setCreditStatus(access.getCreditStatus());
-        customer.setBusinessLicenseUrl(access.getBusinessLicenseUrl());
+        applyCustomerFromAccessRequest(customer, request);
         customer.setLifecycleStatus(LIFECYCLE_ADMITTED);
         customer.setDrNotifyMinutes(30);
         customer.setSigningDate(LocalDateTime.now());
         return customer;
     }
 
+    private void applyCustomerFromAccessRequest(VppCustomer customer, CustomerAccessRequestVO request) {
+        customer.setAccountNo(request.getAccountNo());
+        customer.setCustomerName(request.getCustomerName());
+        customer.setCustomerType(request.getCustomerType());
+        customer.setPowerCompany(request.getPowerCompany());
+        customer.setContractCapacity(request.getContractCapacity());
+        customer.setCreditStatus(request.getCreditStatus());
+        customer.setBusinessLicenseUrl(request.getBusinessLicenseUrl());
+        customer.setAddress(request.getAddress());
+    }
+
+    private void savePrimaryContact(Long customerId, CustomerAccessRequestVO request) {
+        VppCustomerContact contact = new VppCustomerContact();
+        contact.setCustomerId(customerId);
+        contact.setContactName(request.getContactName());
+        contact.setContactPhone(request.getContactPhone());
+        contact.setContactEmail(request.getContactEmail());
+        contact.setIsPrimary(1);
+        VppAuditHelper.fillCreate(contact);
+        contactMapper.insert(contact);
+    }
+
+    private void upsertPrimaryContact(Long customerId, CustomerAccessRequestVO request) {
+        LambdaQueryWrapper<VppCustomerContact> wrapper = new LambdaQueryWrapper<>();
+        wrapper.eq(VppCustomerContact::getCustomerId, customerId)
+                .eq(VppCustomerContact::getTenantId, SecurityUtils.getTenantId())
+                .eq(VppCustomerContact::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(VppCustomerContact::getIsPrimary, 1);
+        VppCustomerContact contact = contactMapper.selectOne(wrapper);
+        if (contact == null) {
+            savePrimaryContact(customerId, request);
+            return;
+        }
+        contact.setContactName(request.getContactName());
+        contact.setContactPhone(request.getContactPhone());
+        contact.setContactEmail(request.getContactEmail());
+        VppAuditHelper.fillUpdate(contact);
+        contactMapper.updateById(contact);
+    }
+
+    private Map<Long, VppCustomer> loadCustomerMap(Set<Long> customerIds) {
+        if (customerIds == null || customerIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return customerMapper.selectBatchIds(customerIds).stream()
+                .filter(customer -> !VppAuditHelper.isDeleted(customer.getDeleteFlag()))
+                .collect(Collectors.toMap(VppCustomer::getId, customer -> customer, (a, b) -> a));
+    }
+
+    private Map<Long, VppCustomerContact> loadPrimaryContactMap(Set<Long> customerIds) {
+        if (customerIds == null || customerIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        LambdaQueryWrapper<VppCustomerContact> wrapper = new LambdaQueryWrapper<>();
+        wrapper.in(VppCustomerContact::getCustomerId, customerIds)
+                .eq(VppCustomerContact::getTenantId, SecurityUtils.getTenantId())
+                .eq(VppCustomerContact::getDeleteFlag, VppAuditHelper.NOT_DELETED)
+                .eq(VppCustomerContact::getIsPrimary, 1);
+        return contactMapper.selectList(wrapper).stream()
+                .collect(Collectors.toMap(VppCustomerContact::getCustomerId, contact -> contact, (a, b) -> a));
+    }
+
+    private void fillAccessContactInfo(CustomerAccessResponseVO vo, VppCustomer customer,
+                                       VppCustomerContact contact) {
+        if (customer != null) {
+            vo.setAddress(customer.getAddress());
+        }
+        if (contact != null) {
+            vo.setContactName(contact.getContactName());
+            vo.setContactPhone(contact.getContactPhone());
+            vo.setContactEmail(contact.getContactEmail());
+        }
+    }
+
     private CustomerResponseVO.ContractBriefVO toContractBrief(VppContract contract) {
         CustomerResponseVO.ContractBriefVO brief = new CustomerResponseVO.ContractBriefVO();
         brief.setId(contract.getId());
@@ -1046,9 +1101,9 @@ public class VppCustomerServiceImpl implements VppCustomerService {
     }
 
     private void validateAccessRequest(CustomerAccessRequestVO request) {
-        // if (request == null || StringUtils.isBlank(request.getAccountNo())) {
-        //     throw new BusinessException("电力户号不能为空!");
-        // }
+        if (request == null) {
+            throw new BusinessException("准入申请参数不能为空!");
+        }
         if (StringUtils.isBlank(request.getCustomerName())) {
             throw new BusinessException("客户名称不能为空!");
         }
@@ -1061,6 +1116,19 @@ public class VppCustomerServiceImpl implements VppCustomerService {
         if (request.getContractCapacity() == null) {
             throw new BusinessException("用电容量不能为空!");
         }
+        if (StringUtils.isBlank(request.getContactName())) {
+            throw new BusinessException("联系人不能为空!");
+        }
+        if (StringUtils.isBlank(request.getContactPhone())) {
+            throw new BusinessException("联系电话不能为空!");
+        }
+        VppFieldValidator.validatePhone(request.getContactPhone());
+        if (StringUtils.isBlank(request.getAddress())) {
+            throw new BusinessException("地址不能为空!");
+        }
+        if (StringUtils.isNotBlank(request.getContactEmail())) {
+            VppFieldValidator.validateEmail(request.getContactEmail());
+        }
     }
 
     private void validateContactRequest(CustomerContactRequest request) {

+ 238 - 40
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/impl/VppSettlementServiceImpl.java

@@ -8,10 +8,12 @@ import com.usky.common.core.exception.BusinessException;
 import com.usky.common.security.utils.SecurityUtils;
 import com.usky.vpp.domain.VppCustomer;
 import com.usky.vpp.domain.VppEnergyReadingMonthly;
-import com.usky.vpp.domain.VppResourcePoint;
+import com.usky.vpp.domain.VppSettlementBill;
+import com.usky.vpp.domain.VppSite;
 import com.usky.vpp.mapper.VppCustomerMapper;
 import com.usky.vpp.mapper.VppEnergyReadingMonthlyMapper;
-import com.usky.vpp.mapper.VppResourcePointMapper;
+import com.usky.vpp.mapper.VppSettlementBillMapper;
+import com.usky.vpp.mapper.VppSiteMapper;
 import com.usky.vpp.service.VppSettlementService;
 import com.usky.vpp.util.VppAuditHelper;
 import org.apache.commons.lang3.StringUtils;
@@ -20,6 +22,8 @@ import org.springframework.stereotype.Service;
 
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
 import java.nio.charset.StandardCharsets;
 import java.util.Collections;
 import java.util.HashMap;
@@ -40,7 +44,9 @@ public class VppSettlementServiceImpl implements VppSettlementService {
     @Autowired
     private VppEnergyReadingMonthlyMapper energyReadingMonthlyMapper;
     @Autowired
-    private VppResourcePointMapper resourcePointMapper;
+    private VppSiteMapper siteMapper;
+    @Autowired
+    private VppSettlementBillMapper settlementBillMapper;
 
     @Override
     public CommonPage<VppEnergyReadingMonthly> pageReading(Long id, String customerName, String calcCycle,
@@ -53,7 +59,7 @@ public class VppSettlementServiceImpl implements VppSettlementService {
         page = energyReadingMonthlyMapper.selectPage(page, wrapper);
         List<VppEnergyReadingMonthly> records = page.getRecords();
         fillCustomerNames(records);
-        fillResourceNames(records);
+        fillSiteNames(records);
         return new CommonPage<>(records, page.getTotal(), size, current);
     }
 
@@ -105,10 +111,76 @@ public class VppSettlementServiceImpl implements VppSettlementService {
             readingList = Collections.emptyList();
         }
         fillCustomerNames(readingList);
-        fillResourceNames(readingList);
+        fillSiteNames(readingList);
         writeReadingExcel(fileName, readingList, response);
     }
 
+    @Override
+    public CommonPage<VppSettlementBill> pageBill(Long id, String customerName, Integer current, Integer size) {
+        IPage<VppSettlementBill> page = new Page<>(current, size);
+        LambdaQueryWrapper<VppSettlementBill> wrapper = buildBillQueryWrapper(id, customerName);
+        if (wrapper == null) {
+            return new CommonPage<>(Collections.emptyList(), 0L, size, current);
+        }
+        page = settlementBillMapper.selectPage(page, wrapper);
+        List<VppSettlementBill> records = page.getRecords();
+        fillBillCustomerNames(records);
+        fillBillSiteNames(records);
+        fillBillContractPrice(records);
+        return new CommonPage<>(records, page.getTotal(), size, current);
+    }
+
+    @Override
+    public void exportBill(String scope, String fileName, Long id, String customerName,
+                           Integer current, Integer size, HttpServletResponse response) {
+        List<VppSettlementBill> billList;
+
+        switch (scope) {
+            case "current":
+                if (current == null || size == null) {
+                    throw new BusinessException("current 模式下分页参数 current 和 size 不能为空!");
+                }
+                IPage<VppSettlementBill> page = new Page<>(current, size);
+                LambdaQueryWrapper<VppSettlementBill> currWrapper = baseBillQueryWrapper();
+                currWrapper.orderByDesc(VppSettlementBill::getSettleYear)
+                        .orderByDesc(VppSettlementBill::getSettleMonth);
+                page = settlementBillMapper.selectPage(page, currWrapper);
+                billList = page.getRecords();
+                break;
+
+            case "all":
+                LambdaQueryWrapper<VppSettlementBill> allWrapper = baseBillQueryWrapper();
+                allWrapper.orderByDesc(VppSettlementBill::getSettleYear)
+                        .orderByDesc(VppSettlementBill::getSettleMonth);
+                billList = settlementBillMapper.selectList(allWrapper);
+                break;
+
+            case "filtered":
+                boolean hasFilter = id != null || StringUtils.isNotBlank(customerName);
+                if (!hasFilter) {
+                    throw new BusinessException("filtered 模式下请至少提供一个筛选条件(id / customerName)!");
+                }
+                LambdaQueryWrapper<VppSettlementBill> filteredWrapper = buildBillQueryWrapper(id, customerName);
+                if (filteredWrapper == null) {
+                    billList = Collections.emptyList();
+                    break;
+                }
+                billList = settlementBillMapper.selectList(filteredWrapper);
+                break;
+
+            default:
+                throw new BusinessException("不支持的导出范围:" + scope + ",请使用 current / all / filtered");
+        }
+
+        if (billList == null) {
+            billList = Collections.emptyList();
+        }
+        fillBillCustomerNames(billList);
+        fillBillSiteNames(billList);
+        fillBillContractPrice(billList);
+        writeBillExcel(fileName, billList, response);
+    }
+
     private LambdaQueryWrapper<VppEnergyReadingMonthly> baseReadingQueryWrapper() {
         return new LambdaQueryWrapper<VppEnergyReadingMonthly>()
                 .eq(VppEnergyReadingMonthly::getTenantId, SecurityUtils.getTenantId())
@@ -139,6 +211,25 @@ public class VppSettlementServiceImpl implements VppSettlementService {
                 .orderByDesc(VppEnergyReadingMonthly::getSettleMonth);
     }
 
+    private LambdaQueryWrapper<VppSettlementBill> baseBillQueryWrapper() {
+        return new LambdaQueryWrapper<VppSettlementBill>()
+                .eq(VppSettlementBill::getTenantId, SecurityUtils.getTenantId())
+                .eq(VppSettlementBill::getDeleteFlag, VppAuditHelper.NOT_DELETED);
+    }
+
+    private LambdaQueryWrapper<VppSettlementBill> buildBillQueryWrapper(Long customerId, String customerName) {
+        Map<Long, String> customerNameMap = resolveCustomerNameMap(customerName);
+        if (StringUtils.isNotBlank(customerName) && customerNameMap.isEmpty()) {
+            return null;
+        }
+
+        return baseBillQueryWrapper()
+                .eq(customerId != null, VppSettlementBill::getCustomerId, customerId)
+                .in(StringUtils.isNotBlank(customerName), VppSettlementBill::getCustomerId, customerNameMap.keySet())
+                .orderByDesc(VppSettlementBill::getSettleYear)
+                .orderByDesc(VppSettlementBill::getSettleMonth);
+    }
+
     private Map<Long, String> resolveCustomerNameMap(String customerName) {
         LambdaQueryWrapper<VppCustomer> customerQueryWrapper = new LambdaQueryWrapper<VppCustomer>()
                 .eq(VppCustomer::getTenantId, SecurityUtils.getTenantId())
@@ -165,25 +256,72 @@ public class VppSettlementServiceImpl implements VppSettlementService {
         records.forEach(reading -> reading.setCustomerName(customerNameMap.get(reading.getCustomerId())));
     }
 
-    private void fillResourceNames(List<VppEnergyReadingMonthly> records) {
+    private void fillSiteNames(List<VppEnergyReadingMonthly> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        Map<Long, String> siteNameMap = loadSiteNameMap(records.stream()
+                .map(VppEnergyReadingMonthly::getSiteId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet()));
+        records.forEach(reading -> reading.setSiteName(resolveSiteName(reading.getSiteId(), siteNameMap)));
+    }
+
+    private void fillBillSiteNames(List<VppSettlementBill> records) {
         if (records == null || records.isEmpty()) {
             return;
         }
-        Set<Long> resourceIds = records.stream()
-                .map(VppEnergyReadingMonthly::getResourceId)
+        Map<Long, String> siteNameMap = loadSiteNameMap(records.stream()
+                .map(VppSettlementBill::getSiteId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet()));
+        records.forEach(bill -> bill.setSiteName(resolveSiteName(bill.getSiteId(), siteNameMap)));
+    }
+
+    private Map<Long, String> loadSiteNameMap(Set<Long> siteIds) {
+        if (siteIds == null || siteIds.isEmpty()) {
+            return Collections.emptyMap();
+        }
+        return siteMapper.selectBatchIds(siteIds).stream()
+                .collect(Collectors.toMap(VppSite::getId, VppSite::getSiteName, (a, b) -> a));
+    }
+
+    private String resolveSiteName(Long siteId, Map<Long, String> siteNameMap) {
+        if (siteId == null) {
+            return "户号级";
+        }
+        return siteNameMap.get(siteId);
+    }
+
+    private void fillBillCustomerNames(List<VppSettlementBill> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        Set<Long> customerIds = records.stream()
+                .map(VppSettlementBill::getCustomerId)
                 .filter(Objects::nonNull)
                 .collect(Collectors.toSet());
-        Map<Long, String> resourceNameMap = resourceIds.isEmpty()
-                ? Collections.emptyMap()
-                : resourcePointMapper.selectBatchIds(resourceIds).stream()
-                        .collect(Collectors.toMap(VppResourcePoint::getId, VppResourcePoint::getResourceName, (a, b) -> a));
-        records.forEach(reading -> {
-            if (reading.getResourceId() == null) {
-                reading.setResourceName("户号级");
-            } else {
-                reading.setResourceName(resourceNameMap.get(reading.getResourceId()));
-            }
-        });
+        if (customerIds.isEmpty()) {
+            return;
+        }
+        Map<Long, String> customerNameMap = vppCustomerMapper.selectBatchIds(customerIds).stream()
+                .collect(Collectors.toMap(VppCustomer::getId, VppCustomer::getCustomerName, (a, b) -> a));
+        records.forEach(bill -> bill.setCustomerName(customerNameMap.get(bill.getCustomerId())));
+    }
+
+    private void fillBillContractPrice(List<VppSettlementBill> records) {
+        if (records == null || records.isEmpty()) {
+            return;
+        }
+        records.forEach(bill -> bill.setContractPrice(calcContractPrice(bill.getTotalEnergyKwh(), bill.getTotalAmount())));
+    }
+
+    private BigDecimal calcContractPrice(BigDecimal totalEnergyKwh, BigDecimal totalAmount) {
+        if (totalEnergyKwh == null || totalAmount == null
+                || totalEnergyKwh.compareTo(BigDecimal.ZERO) <= 0) {
+            return null;
+        }
+        return totalAmount.divide(totalEnergyKwh, 4, RoundingMode.HALF_UP);
     }
 
     private Map<String, Object> resolveCalcCycle(String calcCycle) {
@@ -211,7 +349,7 @@ public class VppSettlementServiceImpl implements VppSettlementService {
             headerStyle.setFont(headerFont);
 
             String[] headers = {
-                    "序号", "客户名称", "资源点", "结算年", "结算月",
+                    "序号", "客户名称", "站点名称", "核算周期",
                     "总用电量(kWh)", "峰电量(kWh)", "平电量(kWh)", "谷电量(kWh)", "尖电量(kWh)",
                     "发电量(kWh)", "核算状态", "核算时间", "创建时间"
             };
@@ -227,18 +365,17 @@ public class VppSettlementServiceImpl implements VppSettlementService {
                 org.apache.poi.xssf.usermodel.XSSFRow row = sheet.createRow(rowIdx++);
                 setCell(row, 0, rowIdx - 1);
                 setCell(row, 1, reading.getCustomerName());
-                setCell(row, 2, reading.getResourceName());
-                setCell(row, 3, reading.getSettleYear());
-                setCell(row, 4, reading.getSettleMonth());
-                setCell(row, 5, numberStr(reading.getTotalEnergyKwh()));
-                setCell(row, 6, numberStr(reading.getPeakEnergyKwh()));
-                setCell(row, 7, numberStr(reading.getFlatEnergyKwh()));
-                setCell(row, 8, numberStr(reading.getValleyEnergyKwh()));
-                setCell(row, 9, numberStr(reading.getSharpEnergyKwh()));
-                setCell(row, 10, numberStr(reading.getGenEnergyKwh()));
-                setCell(row, 11, calcStatusLabel(reading.getCalcStatus()));
-                setCell(row, 12, dateTimeStr(reading.getCalcAt()));
-                setCell(row, 13, dateTimeStr(reading.getCreateTime()));
+                setCell(row, 2, reading.getSiteName());
+                setCell(row, 3, settleCycleLabel(reading.getSettleYear(), reading.getSettleMonth()));
+                setCell(row, 4, numberStr(reading.getTotalEnergyKwh()));
+                setCell(row, 5, numberStr(reading.getPeakEnergyKwh()));
+                setCell(row, 6, numberStr(reading.getFlatEnergyKwh()));
+                setCell(row, 7, numberStr(reading.getValleyEnergyKwh()));
+                setCell(row, 8, numberStr(reading.getSharpEnergyKwh()));
+                setCell(row, 9, numberStr(reading.getGenEnergyKwh()));
+                setCell(row, 10, calcStatusLabel(reading.getCalcStatus()));
+                setCell(row, 11, dateTimeStr(reading.getCalcAt()));
+                setCell(row, 12, dateTimeStr(reading.getCreateTime()));
             }
 
             for (int i = 0; i < headers.length; i++) {
@@ -249,19 +386,73 @@ public class VppSettlementServiceImpl implements VppSettlementService {
                 }
             }
 
-            String encodedFileName = java.net.URLEncoder.encode(fileName + ".xlsx", StandardCharsets.UTF_8.name())
-                    .replace("+", "%20");
-            response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
-            response.setCharacterEncoding(StandardCharsets.UTF_8.name());
-            response.setHeader("Content-Disposition", "attachment; filename=" + encodedFileName
-                    + "; filename*=UTF-8''" + encodedFileName);
-            workbook.write(response.getOutputStream());
-            response.getOutputStream().flush();
+            writeExcelResponse(fileName, workbook, response);
         } catch (IOException e) {
             throw new BusinessException("导出月度电量核算数据失败: " + e.getMessage());
         }
     }
 
+    private void writeBillExcel(String fileName, List<VppSettlementBill> billList, HttpServletResponse response) {
+        try (org.apache.poi.xssf.usermodel.XSSFWorkbook workbook = new org.apache.poi.xssf.usermodel.XSSFWorkbook()) {
+            org.apache.poi.xssf.usermodel.XSSFSheet sheet = workbook.createSheet("电费账单");
+
+            org.apache.poi.xssf.usermodel.XSSFCellStyle headerStyle = workbook.createCellStyle();
+            headerStyle.setFillForegroundColor(org.apache.poi.ss.usermodel.IndexedColors.GREY_25_PERCENT.getIndex());
+            headerStyle.setFillPattern(org.apache.poi.ss.usermodel.FillPatternType.SOLID_FOREGROUND);
+            org.apache.poi.xssf.usermodel.XSSFFont headerFont = workbook.createFont();
+            headerFont.setBold(true);
+            headerStyle.setFont(headerFont);
+
+            String[] headers = {
+                    "序号", "客户名称", "站点名称", "结算周期", "用电量(kWh)", "合同电价(元/kWh)",
+                    "电费(元)", "结算时间"
+            };
+            org.apache.poi.xssf.usermodel.XSSFRow headerRow = sheet.createRow(0);
+            for (int i = 0; i < headers.length; i++) {
+                org.apache.poi.xssf.usermodel.XSSFCell cell = headerRow.createCell(i);
+                cell.setCellValue(headers[i]);
+                cell.setCellStyle(headerStyle);
+            }
+
+            int rowIdx = 1;
+            for (VppSettlementBill bill : billList) {
+                org.apache.poi.xssf.usermodel.XSSFRow row = sheet.createRow(rowIdx++);
+                setCell(row, 0, rowIdx - 1);
+                setCell(row, 1, bill.getCustomerName());
+                setCell(row, 2, bill.getSiteName());
+                setCell(row, 3, settleCycleLabel(bill.getSettleYear(), bill.getSettleMonth()));
+                setCell(row, 4, numberStr(bill.getTotalEnergyKwh()));
+                setCell(row, 5, numberStr(bill.getContractPrice()));
+                setCell(row, 6, numberStr(bill.getTotalAmount()));
+                setCell(row, 7, dateTimeStr(bill.getGeneratedAt()));
+            }
+
+            for (int i = 0; i < headers.length; i++) {
+                sheet.autoSizeColumn(i);
+                int width = sheet.getColumnWidth(i);
+                if (width > 15000) {
+                    sheet.setColumnWidth(i, 15000);
+                }
+            }
+
+            writeExcelResponse(fileName, workbook, response);
+        } catch (IOException e) {
+            throw new BusinessException("导出电费账单数据失败: " + e.getMessage());
+        }
+    }
+
+    private void writeExcelResponse(String fileName, org.apache.poi.xssf.usermodel.XSSFWorkbook workbook,
+                                    HttpServletResponse response) throws IOException {
+        String encodedFileName = java.net.URLEncoder.encode(fileName + ".xlsx", StandardCharsets.UTF_8.name())
+                .replace("+", "%20");
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+        response.setHeader("Content-Disposition", "attachment; filename=" + encodedFileName
+                + "; filename*=UTF-8''" + encodedFileName);
+        workbook.write(response.getOutputStream());
+        response.getOutputStream().flush();
+    }
+
     private static void setCell(org.apache.poi.xssf.usermodel.XSSFRow row, int col, Object value) {
         if (value != null) {
             row.createCell(col).setCellValue(String.valueOf(value));
@@ -278,6 +469,13 @@ public class VppSettlementServiceImpl implements VppSettlementService {
         return value == null ? "" : value.toString();
     }
 
+    private static String settleCycleLabel(String settleYear, Integer settleMonth) {
+        if (StringUtils.isBlank(settleYear) || settleMonth == null) {
+            return "";
+        }
+        return settleYear + "-" + String.format("%02d", settleMonth);
+    }
+
     private static String calcStatusLabel(Integer calcStatus) {
         if (calcStatus == null) {
             return "";

+ 5 - 0
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CustomerAccessRequestVO.java

@@ -75,4 +75,9 @@ public class CustomerAccessRequestVO {
      * 联系邮箱
      */
     private String contactEmail;
+
+    /**
+     * 地址
+     */
+    private String address;
 }

+ 21 - 1
service-vpp/service-vpp-biz/src/main/java/com/usky/vpp/service/vo/CustomerAccessResponseVO.java

@@ -73,10 +73,30 @@ public class CustomerAccessResponseVO {
     private LocalDateTime auditAt;
 
     /**
-     * 关联客户ID(审核通过后生成)
+     * 关联客户ID
      */
     private Long customerId;
 
+    /**
+     * 联系人
+     */
+    private String contactName;
+
+    /**
+     * 联系电话
+     */
+    private String contactPhone;
+
+    /**
+     * 联系邮箱
+     */
+    private String contactEmail;
+
+    /**
+     * 地址
+     */
+    private String address;
+
     /**
      * 申请时间
      */

+ 4 - 3
service-vpp/service-vpp-biz/src/main/resources/sql/vpp_schema.sql

@@ -45,7 +45,7 @@ CREATE TABLE `vpp_customer_access` (
     `access_status` TINYINT NOT NULL COMMENT '0待审核 1通过 2驳回',
     `audit_opinion` VARCHAR(500) NULL COMMENT '审核意见',
     `audit_at` DATETIME(3) NULL COMMENT '审核时间',
-    `customer_id` BIGINT NULL COMMENT '通过后关联客户ID',
+    `customer_id` BIGINT NULL COMMENT '关联客户ID',
     `apply_at` DATETIME(3) NOT NULL COMMENT '申请时间',
     `tenant_id` INT NULL COMMENT '租户ID',
     `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
@@ -239,7 +239,7 @@ CREATE TABLE `vpp_device_control_log` (
 CREATE TABLE `vpp_energy_reading_monthly` (
     `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
     `customer_id` BIGINT NOT NULL COMMENT '客户ID',
-    `resource_id` BIGINT NULL COMMENT '资源点ID,可空表示户号级',
+    `site_id` BIGINT NULL COMMENT '站点ID,可空表示户号级',
     `settle_year` SMALLINT NOT NULL COMMENT '结算年',
     `settle_month` TINYINT NOT NULL COMMENT '结算月',
     `total_energy_kwh` DECIMAL(18,4) NOT NULL COMMENT '总用电量 kWh',
@@ -258,7 +258,7 @@ CREATE TABLE `vpp_energy_reading_monthly` (
     `delete_flag` INT(1) NOT NULL DEFAULT 0 COMMENT '删除标识 0未删除 1已删除',
     `deleted_at` DATETIME(3) NULL COMMENT '软删除时间',
     PRIMARY KEY (`id`),
-    UNIQUE KEY `uk_energy_monthly` (customer_id, resource_id, settle_year, settle_month),
+    UNIQUE KEY `uk_energy_monthly` (customer_id, site_id, settle_year, settle_month),
     KEY `idx_energy_period` (settle_year, settle_month)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='月度电量核算';
 
@@ -288,6 +288,7 @@ CREATE TABLE `vpp_settlement_bill` (
     `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
     `bill_no` VARCHAR(64) NOT NULL COMMENT '账单编号',
     `customer_id` BIGINT NOT NULL COMMENT '客户ID',
+    `site_id` BIGINT NULL COMMENT '站点ID,可空表示户号级',
     `settle_year` SMALLINT NOT NULL COMMENT '结算年',
     `settle_month` TINYINT NOT NULL COMMENT '结算月',
     `total_energy_kwh` DECIMAL(18,4) NOT NULL COMMENT '总用电量',