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