This commit is contained in:
tanglong 2026-07-28 15:52:23 +08:00
parent 04483942de
commit 7315b9ef11
5 changed files with 45 additions and 174 deletions

View File

@ -31,13 +31,14 @@
- 指标:外呼总量、接通率、有效通话与时长、录音覆盖率、AI 质检、敏感词预警。
- 人员链路:企业微信 UserID → 本地学邦账号映射 → EC 员工账号;管理层再按企业微信授权组织匹配 EC 部门。
- 明细权限:管理员、区域总和城市总按运动馆汇总;馆长按本馆人员展示;顾问和教练仅展示本人。
- 企业微信返回:先返回日报摘要,再向当前查询人发送 Excel;管理员附件为“运动馆明细”,馆长附件为“人员明细”。
- 企业微信返回:日报汇总指标全部通过消息回复,Excel 不再包含“日报汇总”页;管理员附件只保留“运动馆明细”,馆长附件只保留“人员明细”。
- 账号风控暂未启用。
### 学员预警名单附件
- 课时预警和高频缺勤名单超过聊天展示上限时,统一发送 `.xlsx`,不再发送 `.txt`。
- Excel 按运动馆、学员和预警类型逐行展示,并保留日期、接收人、剩余课时、排课及缺勤次数。
- 日期、接收人、预警数量和运动馆数量通过企业微信消息展示。
- Excel 只保留详细名单,按运动馆、学员和预警类型逐行展示剩余课时、排课及缺勤次数。
## 扩展新意图

View File

@ -17,7 +17,6 @@ import org.springframework.stereotype.Service;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@ -26,10 +25,6 @@ import java.util.Map;
@Service
public class ExcelReportService {
public byte[] teachingWarnings(
String title,
LocalDate startDate,
LocalDate endDate,
String recipient,
List<Map<String, Object>> alerts
) {
SXSSFWorkbook workbook = new SXSSFWorkbook(200);
@ -38,16 +33,12 @@ public class ExcelReportService {
Styles styles = styles(workbook);
Sheet summary = workbook.createSheet("预警名单");
summary.setDisplayGridlines(false);
title(summary, styles, title, 7);
metadata(summary, styles, 1, "日期", dateRange(startDate, endDate), "接收人", recipient);
metadata(summary, styles, 2, "预警记录", alerts.size(), "运动馆数量",
alerts.stream().map(this::campusName).distinct().count());
String[] headers = {"序号", "运动馆", "学员", "预警类型", "剩余课时", "排课次数", "缺勤次数", "顾问/教练"};
Row header = summary.createRow(4);
Row header = summary.createRow(0);
for (int index = 0; index < headers.length; index++) cell(header, index, headers[index], styles.header());
int rowIndex = 5;
int rowIndex = 1;
for (int index = 0; index < alerts.size(); index++) {
Map<String, Object> alert = alerts.get(index);
Row row = summary.createRow(rowIndex++);
@ -60,7 +51,7 @@ public class ExcelReportService {
nullableNumber(row, 6, alert.get("absenceCount"), styles.integer());
cell(row, 7, first(text(alert, "advisorName"), text(alert, "coachName")), styles.body());
}
configureTable(summary, 4, Math.max(rowIndex - 1, 4), headers.length, styles);
configureTable(summary, 0, Math.max(rowIndex - 1, 0), headers.length, styles);
widths(summary, 8, 10, 26, 16, 16, 12, 12, 12, 18);
return bytes(workbook);
} catch (IOException exception) {
@ -73,43 +64,10 @@ public class ExcelReportService {
public byte[] ecCallCompliance(Map<String, Object> report) {
try (XSSFWorkbook workbook = new XSSFWorkbook()) {
Styles styles = styles(workbook);
Sheet summary = workbook.createSheet("日报汇总");
summary.setDisplayGridlines(false);
title(summary, styles, "EC电话合规监控日报", 5);
Map<?, ?> window = map(report.get("reportWindow"));
Map<?, ?> scope = map(report.get("scope"));
metadata(summary, styles, 1, "日期",
dateRange(date(window.get("startDate")), date(window.get("endDate"))),
"权限范围", text(scope.get("label")));
Map<?, ?> calls = map(report.get("callMetrics"));
Map<?, ?> recording = map(report.get("recordingCompliance"));
Map<?, ?> quality = map(report.get("qualityCompliance"));
Map<?, ?> sensitive = map(report.get("sensitiveWordRisk"));
String[] headers = {"指标", "数值", "指标", "数值"};
Row header = summary.createRow(3);
for (int index = 0; index < headers.length; index++) cell(header, index, headers[index], styles.header());
metricRow(summary, styles, 4, "外呼总量", calls.get("outboundTotal"),
"接通次数", calls.get("connectedCount"));
metricRow(summary, styles, 5, "接通率", percent(calls.get("connectionRate")),
"平均通话时长(秒)", calls.get("averageDurationSeconds"));
metricRow(summary, styles, 6, "有效通话", calls.get("effectiveCallCount"),
"有效通话时长(秒)", calls.get("effectiveDurationSeconds"));
metricRow(summary, styles, 7, "录音覆盖率", percent(recording.get("recordingCoverageRate")),
"已录音/应录音", text(recording.get("recordedCallCount")) + "/" + text(recording.get("eligibleCallCount")));
metricRow(summary, styles, 8, "AI已分析", quality.get("analyzedCount"),
"质检问题", quality.get("issueCount"));
metricRow(summary, styles, 9, "敏感词命中", sensitive.get("hitCount"),
"涉及通话", sensitive.get("hitCallCount"));
widths(summary, 4, 22, 18, 24, 18);
List<Map<String, Object>> breakdown = listOfMaps(report.get("venueBreakdown"));
String sheetName = "运动馆明细";
if (breakdown.isEmpty()) {
breakdown = listOfMaps(report.get("personBreakdown"));
sheetName = "人员明细";
}
if (!breakdown.isEmpty()) writeEcBreakdown(workbook, styles, sheetName, breakdown);
boolean venueView = report.containsKey("venueBreakdown");
List<Map<String, Object>> breakdown = listOfMaps(
report.get(venueView ? "venueBreakdown" : "personBreakdown"));
writeEcBreakdown(workbook, styles, venueView ? "运动馆明细" : "人员明细", breakdown);
return bytes(workbook);
} catch (IOException exception) {
throw new IllegalStateException("生成EC电话合规日报Excel失败", exception);
@ -149,57 +107,6 @@ public class ExcelReportService {
widths(sheet, 13, 28, 16, 12, 12, 12, 16, 12, 16, 12, 16, 12, 12, 14);
}
private void title(Sheet sheet, Styles styles, String value, int columns) {
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, columns - 1));
Row row = sheet.createRow(0);
row.setHeightInPoints(28);
cell(row, 0, value, styles.title());
}
private void metadata(
Sheet sheet,
Styles styles,
int rowIndex,
String firstLabel,
Object firstValue,
String secondLabel,
Object secondValue
) {
Row row = sheet.createRow(rowIndex);
cell(row, 0, firstLabel, styles.label());
cell(row, 1, text(firstValue), styles.body());
cell(row, 3, secondLabel, styles.label());
cell(row, 4, text(secondValue), styles.body());
}
private void metricRow(
Sheet sheet,
Styles styles,
int rowIndex,
String firstLabel,
Object firstValue,
String secondLabel,
Object secondValue
) {
Row row = sheet.createRow(rowIndex);
cell(row, 0, firstLabel, styles.label());
if (firstValue instanceof PercentValue percentage) {
number(row, 1, percentage.value(), styles.percent());
} else if (firstValue instanceof Number number) {
number(row, 1, number.doubleValue(), styles.decimal());
} else {
cell(row, 1, text(firstValue), styles.body());
}
cell(row, 2, secondLabel, styles.label());
if (secondValue instanceof PercentValue percentage) {
number(row, 3, percentage.value(), styles.percent());
} else if (secondValue instanceof Number number) {
number(row, 3, number.doubleValue(), styles.decimal());
} else {
cell(row, 3, text(secondValue), styles.body());
}
}
private void configureTable(Sheet sheet, int headerRow, int lastRow, int columns, Styles styles) {
sheet.createFreezePane(0, headerRow + 1);
if (lastRow >= headerRow) sheet.setAutoFilter(new CellRangeAddress(headerRow, lastRow, 0, columns - 1));
@ -216,13 +123,6 @@ public class ExcelReportService {
}
private Styles styles(Workbook workbook) {
Font titleFont = workbook.createFont();
titleFont.setBold(true); titleFont.setFontHeightInPoints((short) 16); titleFont.setColor(IndexedColors.WHITE.getIndex());
CellStyle title = workbook.createCellStyle();
title.setFillForegroundColor(IndexedColors.DARK_BLUE.getIndex());
title.setFillPattern(FillPatternType.SOLID_FOREGROUND);
title.setFont(titleFont); title.setAlignment(HorizontalAlignment.LEFT);
Font headerFont = workbook.createFont();
headerFont.setBold(true); headerFont.setColor(IndexedColors.WHITE.getIndex());
CellStyle header = workbook.createCellStyle();
@ -231,12 +131,6 @@ public class ExcelReportService {
header.setFont(headerFont); header.setAlignment(HorizontalAlignment.CENTER);
header.setBorderBottom(BorderStyle.THIN);
Font labelFont = workbook.createFont();
labelFont.setBold(true); labelFont.setColor(IndexedColors.DARK_BLUE.getIndex());
CellStyle label = workbook.createCellStyle();
label.setFont(labelFont); label.setFillForegroundColor(IndexedColors.LIGHT_CORNFLOWER_BLUE.getIndex());
label.setFillPattern(FillPatternType.SOLID_FOREGROUND);
CellStyle body = workbook.createCellStyle();
body.setVerticalAlignment(org.apache.poi.ss.usermodel.VerticalAlignment.CENTER);
CellStyle alternate = workbook.createCellStyle();
@ -247,7 +141,7 @@ public class ExcelReportService {
CellStyle integer = workbook.createCellStyle(); integer.setDataFormat(workbook.createDataFormat().getFormat("#,##0"));
CellStyle decimal = workbook.createCellStyle(); decimal.setDataFormat(workbook.createDataFormat().getFormat("#,##0.0"));
CellStyle percent = workbook.createCellStyle(); percent.setDataFormat(workbook.createDataFormat().getFormat("0.0%"));
return new Styles(title, header, label, body, alternate, integer, decimal, percent);
return new Styles(header, body, alternate, integer, decimal, percent);
}
private void cell(Row row, int column, String value, CellStyle style) {
@ -291,30 +185,12 @@ public class ExcelReportService {
return first(text(alert, "campusName"), "未匹配运动馆");
}
private String dateRange(LocalDate startDate, LocalDate endDate) {
if (startDate == null) return "";
return startDate.equals(endDate) ? startDate.toString() : startDate + " 至 " + endDate;
}
private LocalDate date(Object value) {
if (value instanceof LocalDate date) return date;
return LocalDate.parse(text(value));
}
private PercentValue percent(Object value) {
return new PercentValue(numberValue(value));
}
private double numberValue(Object value) {
if (value instanceof Number number) return number.doubleValue();
try { return Double.parseDouble(text(value)); }
catch (NumberFormatException ignored) { return 0D; }
}
private Map<?, ?> map(Object value) {
return value instanceof Map<?, ?> map ? map : Map.of();
}
private List<Map<String, Object>> listOfMaps(Object value) {
if (!(value instanceof List<?> list)) return List.of();
List<Map<String, Object>> result = new ArrayList<>();
@ -339,11 +215,8 @@ public class ExcelReportService {
return first == null || first.isBlank() ? second : first;
}
private record PercentValue(double value) {}
private record Styles(
CellStyle title,
CellStyle header,
CellStyle label,
CellStyle body,
CellStyle alternate,
CellStyle integer,

View File

@ -13,7 +13,6 @@ import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.time.LocalDate;
@Service
class TeachingAlertPushService {
@ -90,15 +89,9 @@ class TeachingAlertPushService {
.toList();
if (ordered.size() > LARGE_LIST_FILE_THRESHOLD) {
try {
Map<String, Object> first = ordered.get(0);
LocalDate startDate = parseDate(text(first, "warningStartDate"));
LocalDate endDate = parseDate(text(first, "warningEndDate"));
byte[] workbook = excelReportService.teachingWarnings(
"学员" + warningTitle(ordered) + "名单",
startDate,
endDate,
batch.name().isEmpty() ? batch.userId() : batch.name(),
ordered);
wecom.sendMarkdown(APP_KEY, batch.userId(), formatFileSummary(batch, ordered));
sentMessages++;
byte[] workbook = excelReportService.teachingWarnings(ordered);
wecom.sendExcelFile(APP_KEY, batch.userId(), fileName(ordered), workbook);
sentMessages++;
} catch (RuntimeException exception) {
@ -125,6 +118,23 @@ class TeachingAlertPushService {
return new PushResult(alerts.size(), targetRecipientCount, sentMessages, unmatched, failedRecipients);
}
private String formatFileSummary(RecipientBatch batch, List<Map<String, Object>> alerts) {
Map<String, Object> first = alerts.get(0);
String start = text(first, "warningStartDate");
String end = text(first, "warningEndDate");
String period = start.isEmpty() ? "当前查询范围"
: start.equals(end) ? start : start + " 至 " + end;
long venueCount = alerts.stream().map(TeachingAlertPushService::campusName).distinct().count();
return new StringBuilder("### ⚠️ 学员运营预警\n")
.append("> 类型:**").append(warningTitle(alerts)).append("**\n")
.append("> 日期:").append(period).append("\n")
.append("> 接收人:").append(batch.name().isEmpty() ? batch.userId() : batch.name()).append("\n")
.append("> 预警记录:<font color=\"warning\">").append(alerts.size()).append("条</font>")
.append(" | 运动馆:").append(venueCount).append("个\n\n")
.append("<font color=\"comment\">详细名单、剩余课时及排课/缺勤次数请查看 Excel 附件。</font>")
.toString();
}
private List<String> alertCopyUserIds() {
FoundationProperties.Wecom.App app = properties.getWecom().getApps().get(APP_KEY);
if (app == null || app.getAlertCopyUserIds() == null) return List.of();
@ -211,14 +221,6 @@ class TeachingAlertPushService {
Object value = row.get(key); return value == null ? "" : String.valueOf(value).trim();
}
private static LocalDate parseDate(String value) {
try {
return value == null || value.isBlank() ? LocalDate.now() : LocalDate.parse(value);
} catch (RuntimeException ignored) {
return LocalDate.now();
}
}
record PushResult(long alertCount, int recipientCount, int sentMessageCount,
long unmatchedAlertCount, int failedRecipientCount) {}

View File

@ -5,7 +5,6 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -18,10 +17,6 @@ class ExcelReportServiceTest {
@Test
void createsTeachingWarningWorkbookThatCanBeOpened() throws Exception {
byte[] content = service.teachingWarnings(
"课时预警",
LocalDate.of(2026, 7, 1),
LocalDate.of(2026, 7, 28),
"浦东金桥馆长",
List.of(Map.of(
"campusName", "浦东金桥-跃动运动馆",
"studentName", "测试学员",
@ -34,11 +29,11 @@ class ExcelReportServiceTest {
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
assertThat(workbook.getNumberOfSheets()).isEqualTo(1);
assertThat(workbook.getSheetName(0)).isEqualTo("预警名单");
assertThat(workbook.getSheetAt(0).getRow(4).getCell(1).getStringCellValue()).isEqualTo("运动馆");
assertThat(workbook.getSheetAt(0).getRow(5).getCell(1).getStringCellValue())
assertThat(workbook.getSheetAt(0).getRow(0).getCell(1).getStringCellValue()).isEqualTo("运动馆");
assertThat(workbook.getSheetAt(0).getRow(1).getCell(1).getStringCellValue())
.isEqualTo("浦东金桥-跃动运动馆");
assertThat(workbook.getSheetAt(0).getRow(5).getCell(4).getCellType()).isEqualTo(CellType.NUMERIC);
assertThat(workbook.getSheetAt(0).getRow(5).getCell(4).getNumericCellValue()).isEqualTo(8.5D);
assertThat(workbook.getSheetAt(0).getRow(1).getCell(4).getCellType()).isEqualTo(CellType.NUMERIC);
assertThat(workbook.getSheetAt(0).getRow(1).getCell(4).getNumericCellValue()).isEqualTo(8.5D);
}
}
@ -50,11 +45,11 @@ class ExcelReportServiceTest {
byte[] content = service.ecCallCompliance(report);
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
assertThat(workbook.getSheetName(0)).isEqualTo("日报汇总");
assertThat(workbook.getSheetName(1)).isEqualTo("运动馆明细");
assertThat(workbook.getSheetAt(1).getRow(1).getCell(0).getStringCellValue())
assertThat(workbook.getNumberOfSheets()).isEqualTo(1);
assertThat(workbook.getSheetName(0)).isEqualTo("运动馆明细");
assertThat(workbook.getSheetAt(0).getRow(1).getCell(0).getStringCellValue())
.isEqualTo("浦东金桥-跃动运动馆");
assertThat(workbook.getSheetAt(1).getRow(1).getCell(2).getNumericCellValue()).isEqualTo(12D);
assertThat(workbook.getSheetAt(0).getRow(1).getCell(2).getNumericCellValue()).isEqualTo(12D);
}
}
@ -68,9 +63,10 @@ class ExcelReportServiceTest {
byte[] content = service.ecCallCompliance(report);
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
assertThat(workbook.getSheetName(1)).isEqualTo("人员明细");
assertThat(workbook.getSheetAt(1).getRow(1).getCell(1).getStringCellValue()).isEqualTo("张三");
assertThat(workbook.getSheetAt(1).getRow(2).getCell(1).getStringCellValue()).isEqualTo("李四");
assertThat(workbook.getNumberOfSheets()).isEqualTo(1);
assertThat(workbook.getSheetName(0)).isEqualTo("人员明细");
assertThat(workbook.getSheetAt(0).getRow(1).getCell(1).getStringCellValue()).isEqualTo("张三");
assertThat(workbook.getSheetAt(0).getRow(2).getCell(1).getStringCellValue()).isEqualTo("李四");
}
}

View File

@ -13,9 +13,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import org.mockito.ArgumentCaptor;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
class TeachingAlertPushServiceTest {
@ -94,7 +92,8 @@ class TeachingAlertPushServiceTest {
verify(wecom).sendExcelFile(org.mockito.ArgumentMatchers.eq("sports-venue"),
org.mockito.ArgumentMatchers.eq("manager1"), contains("课时预警名单.xlsx"),
any(byte[].class));
verify(wecom, never()).sendMarkdown(anyString(), anyString(), anyString());
verify(wecom).sendMarkdown(org.mockito.ArgumentMatchers.eq("sports-venue"),
org.mockito.ArgumentMatchers.eq("manager1"), contains("详细名单"));
}
private Map<String, Object> alert(String id, String name, String campus, String hours) {