2
0

2 Коммиты 18de8961c6 ... ce0f7d9ab2

Автор SHA1 Сообщение Дата
  Gogs ce0f7d9ab2 订单监测新功能 2 недель назад
  Gogs e0cb90ae7f 订单监测新功能 2 недель назад

BIN
data/storage/入库数据/2025采购入库单_1119.xlsx


BIN
data/storage/入库数据/产品资料.xlsx


BIN
data/storage/半成品组装/半成品匹配成品编码明细.xlsx


BIN
data/storage/半成品组装/组装明细 - 1107.xlsx


BIN
data/storage/销售数据/2025年订单数据.xlsx


+ 4 - 2
dtm-admin/src/main/java/com/dtm/web/controller/order/AnalysisController.java

@@ -90,8 +90,10 @@ public class AnalysisController {
 
     @RequestMapping(value = "/co-purchase", method = RequestMethod.GET)
     public List<CoPurchaseDTO> getCoPurchaseRules(
-            @RequestParam(required = false) String skuKeyword) {
-        return analysisService.findCoPurchaseRules(skuKeyword);
+            @RequestParam(required = false) String skuKeyword,
+            @RequestParam(required = false) String startDate,
+            @RequestParam(required = false) String endDate) {
+        return analysisService.findCoPurchaseRules(skuKeyword, startDate, endDate);
     }
 
     @GetMapping("/average-payment-time")

+ 44 - 0
dtm-admin/src/main/java/com/dtm/web/controller/order/shop/ShopOperationReportController.java

@@ -0,0 +1,44 @@
+package com.dtm.web.controller.order.shop;
+
+import com.dtm.common.annotation.Anonymous;
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.common.utils.poi.ExcelUtil;
+import com.dtm.order.shop.dto.ShopOperationReportRow;
+import com.dtm.order.shop.service.ShopOperationReportService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletResponse;
+import java.util.List;
+
+@Anonymous
+@RestController
+@RequestMapping("/api/shop/report")
+public class ShopOperationReportController {
+    private final ShopOperationReportService reportService;
+
+    public ShopOperationReportController(ShopOperationReportService reportService) {
+        this.reportService = reportService;
+    }
+
+    @GetMapping("/list")
+    public AjaxResult list(@RequestParam(required = false) String startDate,
+                           @RequestParam(required = false) String endDate,
+                           @RequestParam(required = false, defaultValue = "day") String periodType) {
+        List<ShopOperationReportRow> rows = reportService.listReport(startDate, endDate, periodType);
+        return AjaxResult.success(rows);
+    }
+
+    @PostMapping("/export")
+    public void export(HttpServletResponse response,
+                       @RequestParam(required = false) String startDate,
+                       @RequestParam(required = false) String endDate,
+                       @RequestParam(required = false, defaultValue = "day") String periodType) {
+        List<ShopOperationReportRow> rows = reportService.listReport(startDate, endDate, periodType);
+        ExcelUtil<ShopOperationReportRow> util = new ExcelUtil<ShopOperationReportRow>(ShopOperationReportRow.class);
+        util.exportExcel(response, rows, "店铺运营报表");
+    }
+}

+ 52 - 0
dtm-admin/src/main/java/com/dtm/web/controller/supply/SupplyMonitorController.java

@@ -0,0 +1,52 @@
+package com.dtm.web.controller.supply;
+
+import com.dtm.common.core.domain.AjaxResult;
+import com.dtm.supply.service.SupplyMonitorService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/supply/monitor")
+public class SupplyMonitorController {
+    @Autowired
+    private SupplyMonitorService supplyMonitorService;
+
+    @GetMapping("/suppliers")
+    public AjaxResult suppliers(@RequestParam(required = false) String supplierName,
+                                @RequestParam(required = false) String startDate,
+                                @RequestParam(required = false) String endDate) {
+        return AjaxResult.success(supplyMonitorService.suppliers(supplierName, startDate, endDate));
+    }
+
+    @GetMapping("/compare")
+    public AjaxResult compare(@RequestParam(required = false) String supplierNames,
+                              @RequestParam(required = false) String startDate,
+                              @RequestParam(required = false) String endDate) {
+        return AjaxResult.success(supplyMonitorService.compare(splitSupplierNames(supplierNames), startDate, endDate));
+    }
+
+    @GetMapping("/payment-plan")
+    public AjaxResult paymentPlan(@RequestParam(required = false) String supplierName,
+                                  @RequestParam(required = false) String startDate,
+                                  @RequestParam(required = false) String endDate) {
+        return AjaxResult.success(supplyMonitorService.paymentPlan(supplierName, startDate, endDate));
+    }
+
+    private List<String> splitSupplierNames(String supplierNames) {
+        if (supplierNames == null || supplierNames.trim().isEmpty()) {
+            return Collections.emptyList();
+        }
+        return Arrays.stream(supplierNames.split(","))
+                .map(String::trim)
+                .filter(value -> !value.isEmpty())
+                .collect(Collectors.toList());
+    }
+}

+ 5 - 1
dtm-system/src/main/java/com/dtm/order/mapper/OrderAnalyticsMapper.java

@@ -33,5 +33,9 @@ public interface OrderAnalyticsMapper {
 
     OrderFunnelSummary selectPaymentFunnel(@Param("startDate") String startDate, @Param("endDate") String endDate);
 
-    List<CoPurchaseDTO> selectCoPurchaseRules(@Param("skuKeyword") String skuKeyword);
+    List<CoPurchaseDTO> selectCoPurchaseRules(
+            @Param("skuKeyword") String skuKeyword,
+            @Param("startDate") String startDate,
+            @Param("endDate") String endDate
+    );
 }

+ 3 - 3
dtm-system/src/main/java/com/dtm/order/service/AnalysisService.java

@@ -232,10 +232,10 @@ public class AnalysisService {
         return result;
     }
 
-    public List<CoPurchaseDTO> findCoPurchaseRules(String skuKeyword) {
+    public List<CoPurchaseDTO> findCoPurchaseRules(String skuKeyword, String startDate, String endDate) {
         if (hasDatabaseOrders()) {
             try {
-                List<CoPurchaseDTO> rules = orderAnalyticsMapper.selectCoPurchaseRules(normalize(skuKeyword));
+                List<CoPurchaseDTO> rules = orderAnalyticsMapper.selectCoPurchaseRules(normalize(skuKeyword), startDate, endDate);
                 if (rules != null) {
                     return rules;
                 }
@@ -247,7 +247,7 @@ public class AnalysisService {
         Map<String, Set<String>> purchaseProducts = new HashMap<>();
         Map<String, String> productTitle = new HashMap<>();
 
-        for (OrderAnalyticsRecord order : getAnalyticsOrders(null, null)) {
+        for (OrderAnalyticsRecord order : getAnalyticsOrders(startDate, endDate)) {
             String purchaseKey = order.getPurchaseId();
             if (purchaseKey == null || purchaseKey.isEmpty()) {
                 purchaseKey = order.getPurchasePaymentId();

+ 121 - 0
dtm-system/src/main/java/com/dtm/order/shop/dto/ShopOperationReportRow.java

@@ -0,0 +1,121 @@
+package com.dtm.order.shop.dto;
+
+import com.dtm.common.annotation.Excel;
+import com.dtm.common.annotation.Excel.ColumnType;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+public class ShopOperationReportRow implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    @Excel(name = "统计周期")
+    private String period;
+
+    @Excel(name = "平台")
+    private String platformName;
+
+    @Excel(name = "渠道/店铺")
+    private String channelName;
+
+    @Excel(name = "订单量", cellType = ColumnType.NUMERIC)
+    private Long orderCount;
+
+    @Excel(name = "销量", cellType = ColumnType.NUMERIC)
+    private BigDecimal totalQuantity;
+
+    @Excel(name = "销售额", cellType = ColumnType.NUMERIC)
+    private BigDecimal salesAmount;
+
+    @Excel(name = "客单价", cellType = ColumnType.NUMERIC)
+    private BigDecimal avgOrderValue;
+
+    @Excel(name = "毛利额")
+    private String grossProfit;
+
+    @Excel(name = "毛利率")
+    private String grossMargin;
+
+    @Excel(name = "备注")
+    private String remark;
+
+    public String getPeriod() {
+        return period;
+    }
+
+    public void setPeriod(String period) {
+        this.period = period;
+    }
+
+    public String getPlatformName() {
+        return platformName;
+    }
+
+    public void setPlatformName(String platformName) {
+        this.platformName = platformName;
+    }
+
+    public String getChannelName() {
+        return channelName;
+    }
+
+    public void setChannelName(String channelName) {
+        this.channelName = channelName;
+    }
+
+    public Long getOrderCount() {
+        return orderCount;
+    }
+
+    public void setOrderCount(Long orderCount) {
+        this.orderCount = orderCount;
+    }
+
+    public BigDecimal getTotalQuantity() {
+        return totalQuantity;
+    }
+
+    public void setTotalQuantity(BigDecimal totalQuantity) {
+        this.totalQuantity = totalQuantity;
+    }
+
+    public BigDecimal getSalesAmount() {
+        return salesAmount;
+    }
+
+    public void setSalesAmount(BigDecimal salesAmount) {
+        this.salesAmount = salesAmount;
+    }
+
+    public BigDecimal getAvgOrderValue() {
+        return avgOrderValue;
+    }
+
+    public void setAvgOrderValue(BigDecimal avgOrderValue) {
+        this.avgOrderValue = avgOrderValue;
+    }
+
+    public String getGrossProfit() {
+        return grossProfit;
+    }
+
+    public void setGrossProfit(String grossProfit) {
+        this.grossProfit = grossProfit;
+    }
+
+    public String getGrossMargin() {
+        return grossMargin;
+    }
+
+    public void setGrossMargin(String grossMargin) {
+        this.grossMargin = grossMargin;
+    }
+
+    public String getRemark() {
+        return remark;
+    }
+
+    public void setRemark(String remark) {
+        this.remark = remark;
+    }
+}

+ 7 - 0
dtm-system/src/main/java/com/dtm/order/shop/mapper/ShopValueMapper.java

@@ -1,5 +1,6 @@
 package com.dtm.order.shop.mapper;
 
+import com.dtm.order.shop.dto.ShopOperationReportRow;
 import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
@@ -33,4 +34,10 @@ public interface ShopValueMapper {
     List<Map<String, Object>> selectDepartmentEfficiency(@Param("startDate") String startDate, @Param("endDate") String endDate);
 
     List<Map<String, Object>> selectChannelDiversity(@Param("startDate") String startDate, @Param("endDate") String endDate);
+
+    List<ShopOperationReportRow> selectOperationReport(
+            @Param("startDate") String startDate,
+            @Param("endDate") String endDate,
+            @Param("periodType") String periodType
+    );
 }

+ 223 - 0
dtm-system/src/main/java/com/dtm/order/shop/service/ShopOperationReportService.java

@@ -0,0 +1,223 @@
+package com.dtm.order.shop.service;
+
+import com.dtm.order.shop.dto.ShopOperationReportRow;
+import com.dtm.order.shop.mapper.ShopValueMapper;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+public class ShopOperationReportService {
+    private static final String COST_PENDING_REMARK = "缺少成本/费用数据源,毛利额和毛利率暂未计算";
+
+    private final ShopSalesDataStore dataStore;
+    private final ShopValueMapper shopValueMapper;
+
+    public ShopOperationReportService(ShopSalesDataStore dataStore, ShopValueMapper shopValueMapper) {
+        this.dataStore = dataStore;
+        this.shopValueMapper = shopValueMapper;
+    }
+
+    public List<ShopOperationReportRow> listReport(String startDate, String endDate, String periodType) {
+        String normalizedPeriodType = normalizePeriodType(periodType);
+        if (hasDatabaseRows()) {
+            List<ShopOperationReportRow> rows = shopValueMapper.selectOperationReport(startDate, endDate, normalizedPeriodType);
+            if (rows != null && !rows.isEmpty()) {
+                fillPendingProfit(rows);
+                return rows;
+            }
+        }
+        return buildFromLoadedRecords(startDate, endDate, normalizedPeriodType);
+    }
+
+    private List<ShopOperationReportRow> buildFromLoadedRecords(String startDate, String endDate, String periodType) {
+        LocalDateRange range = parseRange(startDate, endDate);
+        Map<String, ReportAccumulator> grouped = new LinkedHashMap<>();
+        for (ShopSalesRecord record : dataStore.getSalesRecords()) {
+            if (!range.contains(record.getSalesDate())) {
+                continue;
+            }
+            String period = formatPeriod(record.getSalesDate(), periodType);
+            String platformName = defaultText(record.getPlatformName(), "未识别平台");
+            String channelName = defaultText(record.getChannelName(), "未识别渠道");
+            String key = period + "\u0001" + platformName + "\u0001" + channelName;
+            ReportAccumulator accumulator = grouped.get(key);
+            if (accumulator == null) {
+                accumulator = new ReportAccumulator(period, platformName, channelName);
+                grouped.put(key, accumulator);
+            }
+            accumulator.add(record);
+        }
+
+        List<ShopOperationReportRow> rows = new ArrayList<>();
+        for (ReportAccumulator accumulator : grouped.values()) {
+            rows.add(accumulator.toRow());
+        }
+        Collections.sort(rows, new Comparator<ShopOperationReportRow>() {
+            @Override
+            public int compare(ShopOperationReportRow left, ShopOperationReportRow right) {
+                int periodCompare = nullSafe(left.getPeriod()).compareTo(nullSafe(right.getPeriod()));
+                if (periodCompare != 0) {
+                    return periodCompare;
+                }
+                return right.getSalesAmount().compareTo(left.getSalesAmount());
+            }
+        });
+        return rows;
+    }
+
+    private void fillPendingProfit(List<ShopOperationReportRow> rows) {
+        for (ShopOperationReportRow row : rows) {
+            row.setGrossProfit(null);
+            row.setGrossMargin(null);
+            row.setRemark(COST_PENDING_REMARK);
+        }
+    }
+
+    private boolean hasDatabaseRows() {
+        try {
+            Long count = shopValueMapper.countRows();
+            return count != null && count > 0;
+        } catch (Exception ignored) {
+            return false;
+        }
+    }
+
+    private String normalizePeriodType(String periodType) {
+        if ("month".equalsIgnoreCase(periodType)) {
+            return "month";
+        }
+        if ("year".equalsIgnoreCase(periodType)) {
+            return "year";
+        }
+        return "day";
+    }
+
+    private String formatPeriod(LocalDate date, String periodType) {
+        if ("year".equals(periodType)) {
+            return String.valueOf(date.getYear());
+        }
+        if ("month".equals(periodType)) {
+            return date.format(DateTimeFormatter.ofPattern("yyyy-MM"));
+        }
+        return date.toString();
+    }
+
+    private LocalDateRange parseRange(String startDate, String endDate) {
+        LocalDate start = parseDate(startDate);
+        LocalDate end = parseDate(endDate);
+        if (start == null && end == null) {
+            return new LocalDateRange(null, null);
+        }
+        if (start == null) {
+            start = end;
+        }
+        if (end == null) {
+            end = start;
+        }
+        return new LocalDateRange(start, end);
+    }
+
+    private LocalDate parseDate(String raw) {
+        if (raw == null) {
+            return null;
+        }
+        String value = raw.trim();
+        if (value.isEmpty()) {
+            return null;
+        }
+        if (value.length() > 10) {
+            value = value.substring(0, 10);
+        }
+        value = value.replace('/', '-');
+        try {
+            return LocalDate.parse(value, DateTimeFormatter.ofPattern("yyyy-M-d"));
+        } catch (DateTimeParseException e) {
+            return null;
+        }
+    }
+
+    private String defaultText(String value, String fallback) {
+        String text = value == null ? "" : value.trim();
+        return text.isEmpty() ? fallback : text;
+    }
+
+    private static String nullSafe(String value) {
+        return value == null ? "" : value;
+    }
+
+    private static BigDecimal amount(double value) {
+        return BigDecimal.valueOf(value).setScale(2, RoundingMode.HALF_UP);
+    }
+
+    private static class ReportAccumulator {
+        private final String period;
+        private final String platformName;
+        private final String channelName;
+        private long orderCount;
+        private BigDecimal totalQuantity = BigDecimal.ZERO;
+        private BigDecimal salesAmount = BigDecimal.ZERO;
+
+        private ReportAccumulator(String period, String platformName, String channelName) {
+            this.period = period;
+            this.platformName = platformName;
+            this.channelName = channelName;
+        }
+
+        private void add(ShopSalesRecord record) {
+            orderCount++;
+            totalQuantity = totalQuantity.add(amount(record.getQuantity()));
+            salesAmount = salesAmount.add(amount(record.getSalesAmount()));
+        }
+
+        private ShopOperationReportRow toRow() {
+            ShopOperationReportRow row = new ShopOperationReportRow();
+            row.setPeriod(period);
+            row.setPlatformName(platformName);
+            row.setChannelName(channelName);
+            row.setOrderCount(orderCount);
+            row.setTotalQuantity(totalQuantity.setScale(2, RoundingMode.HALF_UP));
+            row.setSalesAmount(salesAmount.setScale(2, RoundingMode.HALF_UP));
+            row.setAvgOrderValue(orderCount > 0
+                    ? salesAmount.divide(BigDecimal.valueOf(orderCount), 2, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO);
+            row.setGrossProfit(null);
+            row.setGrossMargin(null);
+            row.setRemark(COST_PENDING_REMARK);
+            return row;
+        }
+    }
+
+    private static class LocalDateRange {
+        private final LocalDate start;
+        private final LocalDate end;
+
+        private LocalDateRange(LocalDate start, LocalDate end) {
+            this.start = start;
+            this.end = end;
+        }
+
+        private boolean contains(LocalDate date) {
+            if (date == null) {
+                return false;
+            }
+            if (start != null && date.isBefore(start)) {
+                return false;
+            }
+            if (end != null && date.isAfter(end)) {
+                return false;
+            }
+            return true;
+        }
+    }
+}

+ 601 - 0
dtm-system/src/main/java/com/dtm/supply/service/SupplyMonitorService.java

@@ -0,0 +1,601 @@
+package com.dtm.supply.service;
+
+import com.dtm.storage.util.ExcelUtils;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+@Service
+public class SupplyMonitorService {
+    private static final long CACHE_EXPIRE_MILLIS = 300_000L;
+    private static final DateTimeFormatter YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+    @Value("${supply.data.path:}")
+    private String supplyDataPath;
+
+    private final AtomicReference<CacheEntry> cache = new AtomicReference<>();
+
+    public List<Map<String, Object>> suppliers(String supplierName, String startDate, String endDate) {
+        SupplyData data = loadData();
+        LocalDate start = parseDate(startDate);
+        LocalDate end = parseDate(endDate);
+        String keyword = normalize(supplierName);
+        Map<String, SupplierStats> stats = buildStats(data, start, end);
+        return stats.values().stream()
+                .filter(item -> keyword.isEmpty() || normalize(item.supplierName).contains(keyword))
+                .map(this::toRow)
+                .sorted(Comparator.comparing((Map<String, Object> row) -> toDouble(row.get("receiptAmount"))).reversed())
+                .collect(Collectors.toList());
+    }
+
+    public List<Map<String, Object>> compare(List<String> supplierNames, String startDate, String endDate) {
+        if (supplierNames == null || supplierNames.isEmpty()) {
+            return Collections.emptyList();
+        }
+        Set<String> selected = supplierNames.stream()
+                .map(this::normalize)
+                .filter(value -> !value.isEmpty())
+                .limit(5)
+                .collect(Collectors.toSet());
+        if (selected.isEmpty()) {
+            return Collections.emptyList();
+        }
+        return suppliers(null, startDate, endDate).stream()
+                .filter(row -> selected.contains(normalize(String.valueOf(row.getOrDefault("supplierName", "")))))
+                .collect(Collectors.toList());
+    }
+
+    public List<Map<String, Object>> paymentPlan(String supplierName, String startDate, String endDate) {
+        SupplyData data = loadData();
+        LocalDate start = parseDate(startDate);
+        LocalDate end = parseDate(endDate);
+        String keyword = normalize(supplierName);
+        Map<String, PaymentAgg> plan = new HashMap<>();
+        for (ReceiptRecord receipt : data.receipts) {
+            if (receipt.supplierName.isEmpty() || !inRange(receipt.acceptanceDate, start, end)) {
+                continue;
+            }
+            if (!keyword.isEmpty() && !normalize(receipt.supplierName).contains(keyword)) {
+                continue;
+            }
+            int termDays = data.termDays.getOrDefault(receipt.supplierName, 0);
+            LocalDate dueDate = receipt.acceptanceDate == null ? null : receipt.acceptanceDate.plusDays(termDays);
+            String key = receipt.supplierName + "|" + formatDate(dueDate);
+            PaymentAgg agg = plan.computeIfAbsent(key, ignored -> new PaymentAgg(receipt.supplierCode, receipt.supplierName, termDays, dueDate));
+            agg.receiptAmount += receipt.amount;
+            agg.receiptQty += receipt.quantity;
+            agg.receiptLines++;
+        }
+        return plan.values().stream()
+                .sorted(Comparator.comparing((PaymentAgg row) -> row.dueDate == null ? LocalDate.MAX : row.dueDate)
+                        .thenComparing(row -> row.supplierName))
+                .map(row -> {
+                    Map<String, Object> result = new LinkedHashMap<>();
+                    result.put("supplierCode", row.supplierCode);
+                    result.put("supplierName", row.supplierName);
+                    result.put("termDays", row.termDays);
+                    result.put("dueDate", formatDate(row.dueDate));
+                    result.put("estimatedPayAmount", round(row.receiptAmount, 2));
+                    result.put("receiptQty", round(row.receiptQty, 2));
+                    result.put("receiptLines", row.receiptLines);
+                    result.put("status", paymentStatus(row.dueDate));
+                    return result;
+                })
+                .collect(Collectors.toList());
+    }
+
+    private Map<String, SupplierStats> buildStats(SupplyData data, LocalDate start, LocalDate end) {
+        Map<String, SupplierStats> map = new HashMap<>();
+        for (OrderRecord order : data.orders) {
+            if (order.supplierName.isEmpty() || !inRange(order.businessDate, start, end)) {
+                continue;
+            }
+            SupplierStats stats = map.computeIfAbsent(order.supplierName, key -> new SupplierStats(order.supplierCode, order.supplierName));
+            stats.orderIds.add(order.documentId);
+            stats.orderLines++;
+            stats.orderQty += order.orderQty;
+            stats.completedQty += order.completedQty;
+            stats.uncompletedQty += order.uncompletedQty;
+            stats.orderAmount += order.amount;
+        }
+        for (ReceiptRecord receipt : data.receipts) {
+            if (receipt.supplierName.isEmpty() || !inRange(receipt.businessDate, start, end)) {
+                continue;
+            }
+            SupplierStats stats = map.computeIfAbsent(receipt.supplierName, key -> new SupplierStats(receipt.supplierCode, receipt.supplierName));
+            stats.receiptQty += receipt.quantity;
+            stats.receiptAmount += receipt.amount;
+            stats.receiptLines++;
+        }
+        for (MergedRecord merged : data.mergedRecords) {
+            if (merged.supplierName.isEmpty() || !inRange(merged.acceptanceDate, start, end)) {
+                continue;
+            }
+            SupplierStats stats = map.computeIfAbsent(merged.supplierName, key -> new SupplierStats(merged.supplierCode, merged.supplierName));
+            stats.matchedLines++;
+            stats.matchedPlanQty += merged.planQty;
+            stats.matchedReceiptQty += merged.receiptQty;
+            if (merged.acceptanceDate != null && merged.planDeliveryDate != null && !merged.acceptanceDate.isAfter(merged.planDeliveryDate)) {
+                stats.onTimeLines++;
+            }
+        }
+        for (SupplierStats stats : map.values()) {
+            stats.termDays = data.termDays.getOrDefault(stats.supplierName, 0);
+        }
+        return map;
+    }
+
+    private Map<String, Object> toRow(SupplierStats stats) {
+        double completionRate = stats.orderQty > 0 ? Math.min(1D, stats.completedQty / stats.orderQty) : 0D;
+        double deliveryRate = stats.matchedLines > 0 ? stats.onTimeLines * 1D / stats.matchedLines : 0D;
+        Map<String, Object> row = new LinkedHashMap<>();
+        row.put("supplierCode", stats.supplierCode);
+        row.put("supplierName", stats.supplierName);
+        row.put("termDays", stats.termDays);
+        row.put("orderCount", stats.orderIds.size());
+        row.put("orderLines", stats.orderLines);
+        row.put("orderQty", round(stats.orderQty, 2));
+        row.put("completedQty", round(stats.completedQty, 2));
+        row.put("uncompletedQty", round(stats.uncompletedQty, 2));
+        row.put("orderAmount", round(stats.orderAmount, 2));
+        row.put("receiptQty", round(stats.receiptQty, 2));
+        row.put("receiptAmount", round(stats.receiptAmount, 2));
+        row.put("receiptLines", stats.receiptLines);
+        row.put("deliveryRate", round(deliveryRate, 4));
+        row.put("completionRate", round(completionRate, 4));
+        return row;
+    }
+
+    private synchronized SupplyData loadData() {
+        CacheEntry current = cache.get();
+        long now = System.currentTimeMillis();
+        if (current != null && now - current.loadedAt < CACHE_EXPIRE_MILLIS) {
+            return current.data;
+        }
+        SupplyData loaded = doLoadData();
+        cache.set(new CacheEntry(now, loaded));
+        return loaded;
+    }
+
+    private SupplyData doLoadData() {
+        SupplyData data = new SupplyData();
+        Path base = resolveDataPath();
+        if (base == null || !Files.exists(base)) {
+            return data;
+        }
+        data.termDays.putAll(loadTerms(base.resolve("供应商账期.xlsx")));
+        data.receipts.addAll(loadReceipts(base.resolve("采购入库统计.xlsx")));
+        data.orders.addAll(loadOrders(base.resolve("采购订单统计01.xlsx")));
+        if (data.orders.isEmpty()) {
+            data.orders.addAll(loadOrders(base.resolve("采购订单统计.xlsx")));
+        }
+        data.mergedRecords.addAll(loadMerged(base.resolve("采购数据_双键合并结果.xlsx")));
+        return data;
+    }
+
+    private Map<String, Integer> loadTerms(Path file) {
+        if (!Files.exists(file)) {
+            return Collections.emptyMap();
+        }
+        Map<String, Integer> result = new HashMap<>();
+        ExcelUtils.processSheet(file, 0, -1, new ExcelUtils.SheetHandler() {
+            int supplierNameIndex;
+            int termIndex;
+
+            @Override
+            public void onHeader(List<String> headers) {
+                supplierNameIndex = findHeaderIndex(headers, "供应商名称");
+                termIndex = findHeaderIndex(headers, "结算期限", "账期");
+            }
+
+            @Override
+            public void onRow(List<Object> row) {
+                String supplierName = stringAt(row, supplierNameIndex);
+                if (!supplierName.isEmpty()) {
+                    result.put(supplierName, parseTermDays(stringAt(row, termIndex)));
+                }
+            }
+        });
+        return result;
+    }
+
+    private List<ReceiptRecord> loadReceipts(Path file) {
+        if (!Files.exists(file)) {
+            return Collections.emptyList();
+        }
+        List<ReceiptRecord> result = new ArrayList<>();
+        ExcelUtils.processSheet(file, 0, -1, new ExcelUtils.SheetHandler() {
+            int businessDateIndex;
+            int acceptanceDateIndex;
+            int supplierCodeIndex;
+            int supplierNameIndex;
+            int amountIndex;
+            int quantityIndex;
+
+            @Override
+            public void onHeader(List<String> headers) {
+                businessDateIndex = findHeaderIndex(headers, "业务日期");
+                acceptanceDateIndex = findHeaderIndex(headers, "实际验收日期");
+                supplierCodeIndex = findHeaderIndex(headers, "供应商代码");
+                supplierNameIndex = findHeaderIndex(headers, "供应商名称");
+                amountIndex = findHeaderIndex(headers, "实际金额", "成本金额", "金额");
+                quantityIndex = findHeaderIndex(headers, "实际入库数量", "入库数量", "数量");
+            }
+
+            @Override
+            public void onRow(List<Object> row) {
+                ReceiptRecord record = new ReceiptRecord();
+                record.businessDate = parseDateObject(valueAt(row, businessDateIndex));
+                record.acceptanceDate = parseDateObject(valueAt(row, acceptanceDateIndex));
+                record.supplierCode = stringAt(row, supplierCodeIndex);
+                record.supplierName = stringAt(row, supplierNameIndex);
+                record.amount = numberAt(row, amountIndex);
+                record.quantity = numberAt(row, quantityIndex);
+                result.add(record);
+            }
+        });
+        return result;
+    }
+
+    private List<OrderRecord> loadOrders(Path file) {
+        if (!Files.exists(file)) {
+            return Collections.emptyList();
+        }
+        List<OrderRecord> result = new ArrayList<>();
+        ExcelUtils.processSheet(file, 0, -1, new ExcelUtils.SheetHandler() {
+            int businessDateIndex;
+            int documentIdIndex;
+            int supplierCodeIndex;
+            int supplierNameIndex;
+            int quantityIndex;
+            int completedIndex;
+            int uncompletedIndex;
+            int amountIndex;
+
+            @Override
+            public void onHeader(List<String> headers) {
+                businessDateIndex = findHeaderIndex(headers, "业务日期");
+                documentIdIndex = findHeaderIndex(headers, "单据ID", "联系单据");
+                supplierCodeIndex = findHeaderIndex(headers, "供应商代码");
+                supplierNameIndex = findHeaderIndex(headers, "供应商名称");
+                quantityIndex = findHeaderIndex(headers, "订单计划数量", "数量");
+                completedIndex = findHeaderIndex(headers, "完工数");
+                uncompletedIndex = findHeaderIndex(headers, "未完工数");
+                amountIndex = findHeaderIndex(headers, "实际金额", "选定金额");
+            }
+
+            @Override
+            public void onRow(List<Object> row) {
+                OrderRecord record = new OrderRecord();
+                record.businessDate = parseDateObject(valueAt(row, businessDateIndex));
+                record.documentId = stringAt(row, documentIdIndex);
+                record.supplierCode = stringAt(row, supplierCodeIndex);
+                record.supplierName = stringAt(row, supplierNameIndex);
+                record.orderQty = numberAt(row, quantityIndex);
+                record.completedQty = numberAt(row, completedIndex);
+                record.uncompletedQty = numberAt(row, uncompletedIndex);
+                record.amount = numberAt(row, amountIndex);
+                result.add(record);
+            }
+        });
+        return result;
+    }
+
+    private List<MergedRecord> loadMerged(Path file) {
+        if (!Files.exists(file)) {
+            return Collections.emptyList();
+        }
+        List<MergedRecord> result = new ArrayList<>();
+        ExcelUtils.processSheet(file, 0, -1, new ExcelUtils.SheetHandler() {
+            int acceptanceDateIndex;
+            int supplierCodeIndex;
+            int supplierNameIndex;
+            int receiptQtyIndex;
+            int planQtyIndex;
+            int planDeliveryIndex;
+
+            @Override
+            public void onHeader(List<String> headers) {
+                acceptanceDateIndex = findHeaderIndex(headers, "实际验收日期");
+                supplierCodeIndex = findHeaderIndex(headers, "供应商代码");
+                supplierNameIndex = findHeaderIndex(headers, "供应商名称");
+                receiptQtyIndex = findHeaderIndex(headers, "实际入库数量");
+                planQtyIndex = findHeaderIndex(headers, "订单计划数量", "数量");
+                planDeliveryIndex = findHeaderIndex(headers, "计划交货日期", "交货日期");
+            }
+
+            @Override
+            public void onRow(List<Object> row) {
+                MergedRecord record = new MergedRecord();
+                record.acceptanceDate = parseDateObject(valueAt(row, acceptanceDateIndex));
+                record.supplierCode = stringAt(row, supplierCodeIndex);
+                record.supplierName = stringAt(row, supplierNameIndex);
+                record.receiptQty = numberAt(row, receiptQtyIndex);
+                record.planQty = numberAt(row, planQtyIndex);
+                record.planDeliveryDate = parseDateObject(valueAt(row, planDeliveryIndex));
+                result.add(record);
+            }
+        });
+        return result;
+    }
+
+    private Path resolveDataPath() {
+        String env = Optional.ofNullable(System.getenv("SUPPLY_DATA_PATH"))
+                .filter(value -> !value.trim().isEmpty())
+                .orElseGet(() -> Optional.ofNullable(System.getenv("DTM_SUPPLY_DATA_PATH")).orElse(""));
+        Path configured = existingPath(env);
+        if (configured != null) {
+            return configured;
+        }
+        configured = existingPath(supplyDataPath);
+        if (configured != null) {
+            return configured;
+        }
+        Path direct = Paths.get(System.getProperty("user.dir"), "data", "supply");
+        if (Files.exists(direct)) {
+            return direct;
+        }
+        return findDesktopSupplyPath();
+    }
+
+    private Path existingPath(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            return null;
+        }
+        Path path = Paths.get(value.trim());
+        return Files.exists(path) ? path : null;
+    }
+
+    private Path findDesktopSupplyPath() {
+        try {
+            Path desktop = Paths.get(System.getProperty("user.home"), "Desktop");
+            if (!Files.isDirectory(desktop)) {
+                return null;
+            }
+            try (java.util.stream.Stream<Path> stream = Files.list(desktop)) {
+                return stream.map(path -> path.resolve(Paths.get("gongying", "dtm_python", "data")))
+                        .filter(Files::exists)
+                        .findFirst()
+                        .orElse(null);
+            }
+        } catch (Exception ignored) {
+            return null;
+        }
+    }
+
+    private int findHeaderIndex(List<String> headers, String... keywords) {
+        if (headers == null || keywords == null) {
+            return -1;
+        }
+        for (int i = 0; i < headers.size(); i++) {
+            String header = normalize(headers.get(i));
+            for (String keyword : keywords) {
+                if (header.contains(normalize(keyword))) {
+                    return i;
+                }
+            }
+        }
+        return -1;
+    }
+
+    private Object valueAt(List<Object> row, int index) {
+        return row != null && index >= 0 && index < row.size() ? row.get(index) : null;
+    }
+
+    private String stringAt(List<Object> row, int index) {
+        Object value = valueAt(row, index);
+        return value == null ? "" : String.valueOf(value).trim();
+    }
+
+    private double numberAt(List<Object> row, int index) {
+        return toDouble(valueAt(row, index));
+    }
+
+    private double toDouble(Object value) {
+        if (value == null) {
+            return 0D;
+        }
+        if (value instanceof Number) {
+            return ((Number) value).doubleValue();
+        }
+        String text = String.valueOf(value).replace(",", "").replace("¥", "").trim();
+        if (text.isEmpty()) {
+            return 0D;
+        }
+        try {
+            return Double.parseDouble(text);
+        } catch (NumberFormatException ignored) {
+            return 0D;
+        }
+    }
+
+    private LocalDate parseDate(String value) {
+        if (value == null || value.trim().isEmpty()) {
+            return null;
+        }
+        return parseDateObject(value);
+    }
+
+    private LocalDate parseDateObject(Object value) {
+        if (value == null) {
+            return null;
+        }
+        if (value instanceof Date) {
+            return ((Date) value).toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+        }
+        if (value instanceof Number) {
+            try {
+                return org.apache.poi.ss.usermodel.DateUtil.getJavaDate(((Number) value).doubleValue())
+                        .toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+            } catch (Exception ignored) {
+                return null;
+            }
+        }
+        String text = String.valueOf(value).trim();
+        if (text.length() > 10) {
+            text = text.substring(0, 10);
+        }
+        try {
+            return LocalDate.parse(text, YMD);
+        } catch (DateTimeParseException ignored) {
+            return null;
+        }
+    }
+
+    private int parseTermDays(String value) {
+        String digits = value == null ? "" : value.replaceAll("[^0-9]", "");
+        if (digits.isEmpty()) {
+            return 0;
+        }
+        try {
+            return Integer.parseInt(digits);
+        } catch (NumberFormatException ignored) {
+            return 0;
+        }
+    }
+
+    private boolean inRange(LocalDate date, LocalDate start, LocalDate end) {
+        if (date == null) {
+            return start == null && end == null;
+        }
+        if (start != null && date.isBefore(start)) {
+            return false;
+        }
+        return end == null || !date.isAfter(end);
+    }
+
+    private String paymentStatus(LocalDate dueDate) {
+        if (dueDate == null) {
+            return "未计算";
+        }
+        LocalDate today = LocalDate.now();
+        if (dueDate.isBefore(today)) {
+            return "已到期";
+        }
+        if (!dueDate.isAfter(today.plusDays(7))) {
+            return "7天内到期";
+        }
+        return "待付款";
+    }
+
+    private String formatDate(LocalDate date) {
+        return date == null ? "" : YMD.format(date);
+    }
+
+    private String normalize(String value) {
+        return value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
+    }
+
+    private double round(double value, int scale) {
+        double factor = Math.pow(10, scale);
+        return Math.round(value * factor) / factor;
+    }
+
+    private static class CacheEntry {
+        final long loadedAt;
+        final SupplyData data;
+
+        CacheEntry(long loadedAt, SupplyData data) {
+            this.loadedAt = loadedAt;
+            this.data = data;
+        }
+    }
+
+    private static class SupplyData {
+        final Map<String, Integer> termDays = new HashMap<>();
+        final List<ReceiptRecord> receipts = new ArrayList<>();
+        final List<OrderRecord> orders = new ArrayList<>();
+        final List<MergedRecord> mergedRecords = new ArrayList<>();
+    }
+
+    private static class ReceiptRecord {
+        LocalDate businessDate;
+        LocalDate acceptanceDate;
+        String supplierCode = "";
+        String supplierName = "";
+        double amount;
+        double quantity;
+    }
+
+    private static class OrderRecord {
+        LocalDate businessDate;
+        String documentId = "";
+        String supplierCode = "";
+        String supplierName = "";
+        double orderQty;
+        double completedQty;
+        double uncompletedQty;
+        double amount;
+    }
+
+    private static class MergedRecord {
+        LocalDate acceptanceDate;
+        LocalDate planDeliveryDate;
+        String supplierCode = "";
+        String supplierName = "";
+        double receiptQty;
+        double planQty;
+    }
+
+    private static class SupplierStats {
+        final Set<String> orderIds = new HashSet<>();
+        String supplierCode;
+        String supplierName;
+        int termDays;
+        int orderLines;
+        int receiptLines;
+        int matchedLines;
+        int onTimeLines;
+        double orderQty;
+        double completedQty;
+        double uncompletedQty;
+        double orderAmount;
+        double receiptQty;
+        double receiptAmount;
+        double matchedPlanQty;
+        double matchedReceiptQty;
+
+        SupplierStats(String supplierCode, String supplierName) {
+            this.supplierCode = supplierCode;
+            this.supplierName = supplierName;
+        }
+    }
+
+    private static class PaymentAgg {
+        String supplierCode;
+        String supplierName;
+        int termDays;
+        LocalDate dueDate;
+        int receiptLines;
+        double receiptAmount;
+        double receiptQty;
+
+        PaymentAgg(String supplierCode, String supplierName, int termDays, LocalDate dueDate) {
+            this.supplierCode = supplierCode;
+            this.supplierName = supplierName;
+            this.termDays = termDays;
+            this.dueDate = dueDate;
+        }
+    }
+}

+ 2 - 0
dtm-system/src/main/resources/mapper/order/OrderAnalyticsMapper.xml

@@ -220,6 +220,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
               AND pay_number != ''
               AND sku IS NOT NULL
               AND sku != ''
+              <include refid="dateRangeConditions"/>
             GROUP BY pay_number, sku
         ) a
         INNER JOIN (
@@ -232,6 +233,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
               AND pay_number != ''
               AND sku IS NOT NULL
               AND sku != ''
+              <include refid="dateRangeConditions"/>
             GROUP BY pay_number, sku
         ) b
             ON a.purchase_key = b.purchase_key

+ 38 - 0
dtm-system/src/main/resources/mapper/order/shop/ShopValueMapper.xml

@@ -17,6 +17,20 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </if>
     </sql>
 
+    <sql id="reportPeriodExpression">
+        <choose>
+            <when test="periodType == 'year'">
+                DATE_FORMAT(<include refid="statDateExpression"/>, '%Y')
+            </when>
+            <when test="periodType == 'month'">
+                DATE_FORMAT(<include refid="statDateExpression"/>, '%Y-%m')
+            </when>
+            <otherwise>
+                DATE_FORMAT(<include refid="statDateExpression"/>, '%Y-%m-%d')
+            </otherwise>
+        </choose>
+    </sql>
+
     <select id="countRows" resultType="java.lang.Long">
         SELECT COUNT(1) FROM dtm_shop_value_main
     </select>
@@ -161,4 +175,28 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         GROUP BY channel_name
         ORDER BY value DESC
     </select>
+
+    <select id="selectOperationReport" resultType="com.dtm.order.shop.dto.ShopOperationReportRow">
+        SELECT
+            <include refid="reportPeriodExpression"/> AS period,
+            IFNULL(NULLIF(platform_name, ''), '未识别平台') AS platformName,
+            IFNULL(NULLIF(channel_name, ''), '未识别渠道') AS channelName,
+            COUNT(1) AS orderCount,
+            IFNULL(SUM(IFNULL(quantity, 0)), 0) AS totalQuantity,
+            IFNULL(SUM(IFNULL(sales_amount, 0)), 0) AS salesAmount,
+            CASE
+                WHEN COUNT(1) &gt; 0 THEN ROUND(IFNULL(SUM(IFNULL(sales_amount, 0)), 0) / COUNT(1), 2)
+                ELSE 0
+            END AS avgOrderValue
+        FROM dtm_shop_value_main
+        <where>
+            <include refid="statDateExpression"/> IS NOT NULL
+            <include refid="dateRangeConditions"/>
+        </where>
+        GROUP BY
+            <include refid="reportPeriodExpression"/>,
+            IFNULL(NULLIF(platform_name, ''), '未识别平台'),
+            IFNULL(NULLIF(channel_name, ''), '未识别渠道')
+        ORDER BY MIN(<include refid="statDateExpression"/>) ASC, salesAmount DESC
+    </select>
 </mapper>