package cn.iocoder.yudao.module.crm.service.finance; import cn.hutool.core.collection.CollUtil; import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmCustomerStatementRespVO; import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmFinanceTrendRespVO; import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmShippedNotInvoicedRespVO; import cn.iocoder.yudao.module.crm.controller.admin.finance.vo.CrmUnwrittenOffRespVO; import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerDO; import cn.iocoder.yudao.module.crm.dal.dataobject.invoice.CrmInvoiceDO; import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableDO; import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivableItemDO; import cn.iocoder.yudao.module.crm.dal.dataobject.receivable.CrmReceivablePlanDO; import cn.iocoder.yudao.module.crm.dal.mysql.invoice.CrmInvoiceMapper; import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivableItemMapper; import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivableMapper; import cn.iocoder.yudao.module.crm.dal.mysql.receivable.CrmReceivablePlanMapper; import cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum; import cn.iocoder.yudao.module.crm.enums.receivable.CrmReceivableBizTypeEnum; import cn.iocoder.yudao.module.crm.service.customer.CrmCustomerService; import cn.iocoder.yudao.module.erp.api.sale.ErpSaleOrderApi; import cn.iocoder.yudao.module.erp.api.sale.dto.ErpSaleOrderRespDTO; import cn.iocoder.yudao.module.mes.api.productsales.MesWmProductSalesApi; import cn.iocoder.yudao.module.mes.api.productsales.dto.MesWmProductSalesRespDTO; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; import java.math.BigDecimal; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.YearMonth; import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; import java.util.*; /** * CRM 财务对账与报表 Service 实现类 * * @author 超级管理员 */ @Service @Validated @Slf4j public class CrmFinanceReportServiceImpl implements CrmFinanceReportService { private static final Integer ERP_STATUS_APPROVE = 20; private static final Integer MES_STATUS_FINISHED = 4; private static final Integer AUDIT_APPROVE = CrmAuditStatusEnum.APPROVE.getStatus(); private static final Integer PLAN_STATUS_RECEIPTED = 20; @Resource private ErpSaleOrderApi saleOrderApi; @Resource private MesWmProductSalesApi productSalesApi; @Resource private CrmInvoiceMapper invoiceMapper; @Resource private CrmReceivableMapper receivableMapper; @Resource private CrmReceivableItemMapper receivableItemMapper; @Resource private CrmReceivablePlanMapper receivablePlanMapper; @Resource private CrmCustomerService customerService; @Override public CrmCustomerStatementRespVO getCustomerStatement(Long customerId) { List orders = getApprovedOrders(customerId); Map collectedByOrder = getCollectedAmountByOrder(orders, customerId); CrmCustomerStatementRespVO result = new CrmCustomerStatementRespVO(); result.setCustomerId(customerId); result.setOrderCount(orders.size()); result.setOrderTotalAmount(BigDecimal.ZERO); result.setShippedAmount(BigDecimal.ZERO); result.setInvoicedAmount(BigDecimal.ZERO); result.setCollectedAmount(BigDecimal.ZERO); result.setShippedNotInvoicedAmount(BigDecimal.ZERO); result.setOutstandingAmount(BigDecimal.ZERO); result.setNextMonthPlanAmount(getNextMonthPlanAmount(customerId)); List rows = new ArrayList<>(orders.size()); for (ErpSaleOrderRespDTO order : orders) { CrmCustomerStatementRespVO.OrderRow row = new CrmCustomerStatementRespVO.OrderRow(); row.setSaleOrderId(order.getId()); row.setSaleOrderNo(order.getNo()); row.setContractId(order.getContractId()); row.setOrderAmount(zeroIfNull(order.getTotalPrice())); row.setShippedAmount(getShippedAmount(order.getId())); row.setInvoicedAmount(getInvoicedAmount(order.getId())); row.setCollectedAmount(collectedByOrder.getOrDefault(order.getId(), BigDecimal.ZERO)); row.setShippedNotInvoicedAmount(maxZero(row.getShippedAmount().subtract(row.getInvoicedAmount()))); row.setOutstandingAmount(maxZero(row.getOrderAmount().subtract(row.getCollectedAmount()))); rows.add(row); result.setOrderTotalAmount(result.getOrderTotalAmount().add(row.getOrderAmount())); result.setShippedAmount(result.getShippedAmount().add(row.getShippedAmount())); result.setInvoicedAmount(result.getInvoicedAmount().add(row.getInvoicedAmount())); result.setCollectedAmount(result.getCollectedAmount().add(row.getCollectedAmount())); result.setShippedNotInvoicedAmount(result.getShippedNotInvoicedAmount().add(row.getShippedNotInvoicedAmount())); result.setOutstandingAmount(result.getOutstandingAmount().add(row.getOutstandingAmount())); } result.setOrders(rows); if (customerId != null) { CrmCustomerDO customer = customerService.getCustomer(customerId); result.setCustomerName(customer == null ? null : customer.getName()); } return result; } @Override public List getShippedNotInvoicedList(Long customerId) { List orders = getApprovedOrders(customerId); if (CollUtil.isEmpty(orders)) { return Collections.emptyList(); } Map orderMap = new HashMap<>(); orders.forEach(order -> orderMap.put(order.getId(), order)); Map customerNameMap = getCustomerNameMap(orders.stream() .map(ErpSaleOrderRespDTO::getCustomerId).filter(Objects::nonNull).toList()); List result = new ArrayList<>(); for (ErpSaleOrderRespDTO order : orders) { for (MesWmProductSalesRespDTO saleOut : getFinishedSaleOuts(order.getId())) { BigDecimal outAmount = zeroIfNull(saleOut.getTotalPrice()); BigDecimal invoicedAmount = sumInvoicePrice(invoiceMapper.selectListBySaleOutIdAndStatus( saleOut.getId(), List.of(AUDIT_APPROVE))); BigDecimal remainAmount = outAmount.subtract(invoicedAmount); if (remainAmount.compareTo(BigDecimal.ZERO) <= 0) { continue; } CrmShippedNotInvoicedRespVO vo = new CrmShippedNotInvoicedRespVO(); vo.setSaleOutId(saleOut.getId()); vo.setSaleOutCode(saleOut.getCode()); vo.setSalesDate(saleOut.getSalesDate()); vo.setSaleOrderId(order.getId()); vo.setSaleOrderNo(order.getNo()); vo.setCustomerId(order.getCustomerId()); vo.setCustomerName(customerNameMap.get(order.getCustomerId())); vo.setOutAmount(outAmount); vo.setInvoicedAmount(invoicedAmount); vo.setRemainInvoiceAmount(remainAmount); result.add(vo); } } return result; } @Override public List getUnwrittenOffList(Long customerId) { List invoices = invoiceMapper.selectList(new LambdaQueryWrapperX() .eq(CrmInvoiceDO::getAuditStatus, AUDIT_APPROVE) .eqIfPresent(CrmInvoiceDO::getCustomerId, customerId) .orderByAsc(CrmInvoiceDO::getInvoiceTime)); if (CollUtil.isEmpty(invoices)) { return Collections.emptyList(); } Map collectedMap = getCollectedAmountByInvoice(customerId); Map customerNameMap = getCustomerNameMap(invoices.stream() .map(CrmInvoiceDO::getCustomerId).filter(Objects::nonNull).toList()); List result = new ArrayList<>(); LocalDate today = LocalDate.now(); for (CrmInvoiceDO invoice : invoices) { BigDecimal invoiceAmount = zeroIfNull(invoice.getPrice()); BigDecimal collectedAmount = collectedMap.getOrDefault(invoice.getId(), BigDecimal.ZERO); BigDecimal remainAmount = invoiceAmount.subtract(collectedAmount); if (remainAmount.compareTo(BigDecimal.ZERO) <= 0) { continue; } CrmUnwrittenOffRespVO vo = new CrmUnwrittenOffRespVO(); vo.setInvoiceId(invoice.getId()); vo.setInvoiceNo(invoice.getNo()); vo.setTaxInvoiceNo(invoice.getInvoiceNo()); vo.setCustomerId(invoice.getCustomerId()); vo.setCustomerName(customerNameMap.get(invoice.getCustomerId())); vo.setContractId(invoice.getContractId()); vo.setSaleOrderId(invoice.getSaleOrderId()); vo.setInvoiceTime(invoice.getInvoiceTime()); vo.setInvoiceAmount(invoiceAmount); vo.setCollectedAmount(collectedAmount); vo.setRemainAmount(remainAmount); long agingDays = invoice.getInvoiceTime() == null ? 0 : Math.max(0, ChronoUnit.DAYS.between(invoice.getInvoiceTime().toLocalDate(), today)); vo.setAgingDays(agingDays); vo.setAgingBucket(agingBucket(agingDays)); result.add(vo); } return result; } @Override public List getMonthlyTrend(Integer months) { int monthCount = months == null ? 6 : Math.min(Math.max(months, 1), 36); YearMonth startMonth = YearMonth.now().minusMonths(monthCount - 1L); LocalDateTime beginTime = startMonth.atDay(1).atStartOfDay(); LocalDateTime endTime = YearMonth.now().plusMonths(1).atDay(1).atStartOfDay(); List invoices = invoiceMapper.selectList(new LambdaQueryWrapperX() .eq(CrmInvoiceDO::getAuditStatus, AUDIT_APPROVE) .between(CrmInvoiceDO::getInvoiceTime, beginTime, endTime)); List receivables = receivableMapper.selectList(new LambdaQueryWrapperX() .eq(CrmReceivableDO::getAuditStatus, AUDIT_APPROVE) .between(CrmReceivableDO::getReturnTime, beginTime, endTime)); Map invoicedMap = new HashMap<>(); Map collectedMap = new HashMap<>(); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM"); invoices.forEach(invoice -> { if (invoice.getInvoiceTime() != null) { mergeAmount(invoicedMap, invoice.getInvoiceTime().format(formatter), zeroIfNull(invoice.getPrice())); } }); receivables.forEach(receivable -> { if (receivable.getReturnTime() != null) { mergeAmount(collectedMap, receivable.getReturnTime().format(formatter), zeroIfNull(receivable.getPrice())); } }); List result = new ArrayList<>(monthCount); for (int i = 0; i < monthCount; i++) { String month = startMonth.plusMonths(i).format(formatter); CrmFinanceTrendRespVO vo = new CrmFinanceTrendRespVO(); vo.setMonth(month); vo.setInvoicedAmount(invoicedMap.getOrDefault(month, BigDecimal.ZERO)); vo.setCollectedAmount(collectedMap.getOrDefault(month, BigDecimal.ZERO)); result.add(vo); } return result; } // ==================== 数据查询与计算 ==================== private List getApprovedOrders(Long customerId) { List orders = customerId == null ? saleOrderApi.getSaleOrderList().getCheckedData() : saleOrderApi.getSaleOrderListByCustomerId(customerId).getCheckedData(); if (CollUtil.isEmpty(orders)) { return Collections.emptyList(); } return orders.stream() .filter(order -> ObjectUtilEqual(order.getStatus(), ERP_STATUS_APPROVE)) .toList(); } private List getFinishedSaleOuts(Long saleOrderId) { List saleOuts = productSalesApi.getProductSalesListBySaleOrderId(saleOrderId); if (CollUtil.isEmpty(saleOuts)) { return Collections.emptyList(); } return saleOuts.stream().filter(saleOut -> ObjectUtilEqual(saleOut.getStatus(), MES_STATUS_FINISHED)).toList(); } private BigDecimal getShippedAmount(Long saleOrderId) { return getFinishedSaleOuts(saleOrderId).stream() .map(saleOut -> zeroIfNull(saleOut.getTotalPrice())) .reduce(BigDecimal.ZERO, BigDecimal::add); } private BigDecimal getInvoicedAmount(Long saleOrderId) { return sumInvoicePrice(invoiceMapper.selectListBySaleOrderIdAndStatus(saleOrderId, List.of(AUDIT_APPROVE))); } /** * 计算每张销售订单已回款金额 * * 优先取核销明细(销售订单 / 开票),历史回款没有核销明细时按回款关联的开票折算到订单。 */ private Map getCollectedAmountByOrder(List orders, Long customerId) { Map result = new HashMap<>(); Map orderMap = new HashMap<>(); orders.forEach(order -> orderMap.put(order.getId(), order)); if (orderMap.isEmpty()) { return result; } // 1. 已审核回款 List receivables = customerId == null ? receivableMapper.selectList(new LambdaQueryWrapperX() .eq(CrmReceivableDO::getAuditStatus, AUDIT_APPROVE)) : receivableMapper.selectListByCustomerId(customerId).stream() .filter(receivable -> ObjectUtilEqual(receivable.getAuditStatus(), AUDIT_APPROVE)).toList(); if (CollUtil.isEmpty(receivables)) { return result; } Map receivableMap = new HashMap<>(); receivables.forEach(receivable -> receivableMap.put(receivable.getId(), receivable)); // 2. 核销明细 List items = receivableItemMapper.selectListByReceivableIds(receivableMap.keySet()); Set receivablesWithItems = new HashSet<>(); Set invoiceIds = new HashSet<>(); items.forEach(item -> { receivablesWithItems.add(item.getReceivableId()); if (CrmReceivableBizTypeEnum.SALE_INVOICE.getType().equals(item.getBizType()) && item.getBizId() != null) { invoiceIds.add(item.getBizId()); } }); receivables.forEach(receivable -> { if (receivable.getInvoiceId() != null) { invoiceIds.add(receivable.getInvoiceId()); } }); Map invoiceOrderMap = new HashMap<>(); if (CollUtil.isNotEmpty(invoiceIds)) { invoiceMapper.selectByIds(invoiceIds).forEach(invoice -> { if (invoice.getSaleOrderId() != null) { invoiceOrderMap.put(invoice.getId(), invoice.getSaleOrderId()); } }); } for (CrmReceivableItemDO item : items) { if (!receivableMap.containsKey(item.getReceivableId())) { continue; } Long orderId = null; if (CrmReceivableBizTypeEnum.SALE_ORDER.getType().equals(item.getBizType())) { orderId = item.getBizId(); } else if (CrmReceivableBizTypeEnum.SALE_INVOICE.getType().equals(item.getBizType())) { orderId = invoiceOrderMap.get(item.getBizId()); } if (orderId != null && orderMap.containsKey(orderId)) { result.merge(orderId, zeroIfNull(item.getReceiptPrice()), BigDecimal::add); } } // 3. 历史回款:没有核销明细时,按回款关联开票折算到订单(只处理订单范围内的开票) for (CrmReceivableDO receivable : receivables) { if (receivablesWithItems.contains(receivable.getId()) || receivable.getInvoiceId() == null) { continue; } Long orderId = invoiceOrderMap.get(receivable.getInvoiceId()); if (orderId != null && orderMap.containsKey(orderId)) { result.merge(orderId, zeroIfNull(receivable.getPrice()), BigDecimal::add); } } return result; } /** * 计算每张开票已回款金额(仅已审核回款) * * 有核销明细时按明细归集到开票;历史回款按 invoiceId 直接归集。 */ private Map getCollectedAmountByInvoice(Long customerId) { Map result = new HashMap<>(); List receivables = customerId == null ? receivableMapper.selectList(new LambdaQueryWrapperX() .eq(CrmReceivableDO::getAuditStatus, AUDIT_APPROVE)) : receivableMapper.selectListByCustomerId(customerId).stream() .filter(receivable -> ObjectUtilEqual(receivable.getAuditStatus(), AUDIT_APPROVE)).toList(); if (CollUtil.isEmpty(receivables)) { return result; } Map receivableMap = new HashMap<>(); receivables.forEach(receivable -> receivableMap.put(receivable.getId(), receivable)); List items = receivableItemMapper.selectListByReceivableIds(receivableMap.keySet()); Set receivablesWithItems = new HashSet<>(); for (CrmReceivableItemDO item : items) { receivablesWithItems.add(item.getReceivableId()); if (CrmReceivableBizTypeEnum.SALE_INVOICE.getType().equals(item.getBizType()) && item.getBizId() != null) { result.merge(item.getBizId(), zeroIfNull(item.getReceiptPrice()), BigDecimal::add); } } for (CrmReceivableDO receivable : receivables) { if (receivablesWithItems.contains(receivable.getId()) || receivable.getInvoiceId() == null) { continue; } result.merge(receivable.getInvoiceId(), zeroIfNull(receivable.getPrice()), BigDecimal::add); } return result; } private BigDecimal getNextMonthPlanAmount(Long customerId) { YearMonth nextMonth = YearMonth.now().plusMonths(1); LocalDateTime beginTime = nextMonth.atDay(1).atStartOfDay(); LocalDateTime endTime = nextMonth.plusMonths(1).atDay(1).atStartOfDay(); List plans = receivablePlanMapper.selectList( new LambdaQueryWrapperX() .eqIfPresent(CrmReceivablePlanDO::getCustomerId, customerId) .ge(CrmReceivablePlanDO::getReturnTime, beginTime) .lt(CrmReceivablePlanDO::getReturnTime, endTime)); return plans.stream() .filter(plan -> !PLAN_STATUS_RECEIPTED.equals(plan.getStatus())) .map(plan -> zeroIfNull(plan.getPrice())) .reduce(BigDecimal.ZERO, BigDecimal::add); } private Map getCustomerNameMap(Collection customerIds) { if (CollUtil.isEmpty(customerIds)) { return Collections.emptyMap(); } Map customerMap = customerService.getCustomerMap(customerIds); Map result = new HashMap<>(); customerMap.forEach((id, customer) -> result.put(id, customer.getName())); return result; } private static BigDecimal sumInvoicePrice(List invoices) { if (CollUtil.isEmpty(invoices)) { return BigDecimal.ZERO; } return invoices.stream().map(invoice -> zeroIfNull(invoice.getPrice())).reduce(BigDecimal.ZERO, BigDecimal::add); } private static void mergeAmount(Map map, String key, BigDecimal amount) { map.merge(key, amount, BigDecimal::add); } private static String agingBucket(long days) { if (days <= 30) { return "0-30天"; } if (days <= 60) { return "31-60天"; } if (days <= 90) { return "61-90天"; } return "90天以上"; } private static boolean ObjectUtilEqual(Object left, Object right) { return Objects.equals(left, right); } private static BigDecimal zeroIfNull(BigDecimal value) { return value == null ? BigDecimal.ZERO : value; } private static BigDecimal maxZero(BigDecimal value) { return value.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : value; } }