package com.talentcard.server.service; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeParseException; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import com.talentcard.server.business.PageResult; import com.talentcard.server.request.ServiceAppointmentStatusUpdateRequest; import com.talentcard.server.common.BusinessException; import com.talentcard.server.common.ErrorCode; import com.talentcard.server.common.SensitiveDataCrypto; import com.talentcard.server.entity.ChildInformation; import com.talentcard.server.entity.ServiceApplication; import com.talentcard.server.entity.ServiceApplicationOperationLog; import com.talentcard.server.mapper.ChildInformationMapper; import com.talentcard.server.mapper.ServiceApplicationMapper; import com.talentcard.server.mapper.ServiceApplicationOperationLogMapper; import org.apache.poi.ss.usermodel.CellStyle; import org.apache.poi.ss.usermodel.FillPatternType; import org.apache.poi.ss.usermodel.IndexedColors; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; import org.apache.poi.ss.usermodel.Workbook; import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class ServiceAppointmentAdminService { private static final List PENDING_STATUSES = List.of("待处理", "待审核"); private static final List REJECTED_STATUSES = List.of("已驳回", "审核不通过", "已取消"); private final ServiceApplicationMapper applicationMapper; private final ChildInformationMapper childInformationMapper; private final ServiceApplicationOperationLogMapper operationLogMapper; private final SensitiveDataCrypto sensitiveDataCrypto; public ServiceAppointmentAdminService( ServiceApplicationMapper applicationMapper, ChildInformationMapper childInformationMapper, ServiceApplicationOperationLogMapper operationLogMapper, SensitiveDataCrypto sensitiveDataCrypto ) { this.applicationMapper = applicationMapper; this.childInformationMapper = childInformationMapper; this.operationLogMapper = operationLogMapper; this.sensitiveDataCrypto = sensitiveDataCrypto; } @Transactional(readOnly = true) public PageResult> page(String applicationType, String keyword, String applicantName, String applicantMobile, String serviceKeyword, String cardLevel, String subsidy, String submissionType, String status, String submittedStart, String submittedEnd, int page, int size) { int safePage = Math.max(page, 1); int safeSize = Math.min(Math.max(size, 1), 100); QueryWrapper countWrapper = filter(applicationType, keyword, applicantName, applicantMobile, serviceKeyword, cardLevel, subsidy, submissionType, status, submittedStart, submittedEnd); Long total = applicationMapper.selectCount(countWrapper); QueryWrapper pageWrapper = filter(applicationType, keyword, applicantName, applicantMobile, serviceKeyword, cardLevel, subsidy, submissionType, status, submittedStart, submittedEnd) .orderByDesc("submitted_at", "id") .last("limit " + safeSize + " offset " + ((safePage - 1) * safeSize)); List> records = applicationMapper.selectList(pageWrapper).stream() .map(this::listView) .toList(); return new PageResult<>(records, safePage, safeSize, total == null ? 0 : total); } @Transactional(readOnly = true) public Map detail(Long id) { ServiceApplication application = application(id); return detailView(application); } @Transactional(readOnly = true) public List> operationLogs(Long id) { application(id); return operationLogMapper.selectList(new QueryWrapper() .eq("application_id", id) .orderByAsc("created_at", "id")) .stream().map(this::operationLogView).toList(); } @Transactional public Map updateStatus(Long id, ServiceAppointmentStatusUpdateRequest request, Long operatorId) { ServiceApplication current = applicationMapper.selectByIdForUpdate(id); if (current == null) { throw new BusinessException(ErrorCode.NOT_FOUND, "预约申请不存在"); } String nextStatus = normalizeStatus(request.status(), current.getApplicationType()); String remark = first(request.remark(), request.rejectReason()); if (REJECTED_STATUSES.contains(nextStatus) && remark.isBlank()) { throw new BusinessException(ErrorCode.BAD_REQUEST, "拒绝或驳回时必须填写处理备注"); } String handlerName = first(operatorName(operatorId), request.handlerName(), "管理员"); LocalDateTime handledAt = PENDING_STATUSES.contains(nextStatus) ? null : LocalDateTime.now(); UpdateWrapper update = new UpdateWrapper() .set("status", nextStatus) .set("handled_at", handledAt) .set("handler_name", handledAt == null ? null : handlerName) .set("reject_reason", remark.isBlank() ? null : remark) .eq("id", id) .eq("deleted", 0); int updated = applicationMapper.update(null, update); if (updated != 1) { throw new BusinessException(ErrorCode.CONFLICT, "预约申请状态已变化,请刷新后重试"); } ServiceApplicationOperationLog log = new ServiceApplicationOperationLog(); log.setApplicationId(current.getId()); log.setApplicationNo(current.getApplicationNo()); log.setOperationType("STATUS_UPDATE"); log.setBeforeStatus(current.getStatus()); log.setAfterStatus(nextStatus); log.setOperatorType("ADMIN"); log.setOperatorId(operatorId); log.setOperatorName(handlerName); log.setRemark(remark.isBlank() ? null : remark); log.setCreatedAt(LocalDateTime.now()); if (operationLogMapper.insert(log) != 1) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "预约申请操作日志保存失败"); } syncChildAudit(current, nextStatus, remark); return detail(id); } @Transactional(readOnly = true) public byte[] exportExcel(String applicationType, String keyword, String applicantName, String applicantMobile, String serviceKeyword, String cardLevel, String subsidy, String submissionType, String status, String submittedStart, String submittedEnd) { QueryWrapper wrapper = filter(applicationType, keyword, applicantName, applicantMobile, serviceKeyword, cardLevel, subsidy, submissionType, status, submittedStart, submittedEnd) .orderByDesc("submitted_at", "id"); List applications = applicationMapper.selectList(wrapper); String[] headers = { "序号", "申请编号", "预约类型", "状态", "持卡人", "持卡人手机号", "人才卡等级", "人才卡号", "服务机构", "联系电话", "预约内容", "预约时间", "申请人/配偶/子女", "申请人手机号", "证件号", "子女性别", "子女出生日期", "处理人", "处理时间", "处理备注", "提交时间" }; try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream output = new ByteArrayOutputStream()) { Sheet sheet = workbook.createSheet("预约申请"); sheet.createFreezePane(0, 1); CellStyle headerStyle = headerStyle(workbook); Row headerRow = sheet.createRow(0); for (int index = 0; index < headers.length; index++) { headerRow.createCell(index).setCellValue(headers[index]); headerRow.getCell(index).setCellStyle(headerStyle); } int rowIndex = 1; for (ServiceApplication application : applications) { Row row = sheet.createRow(rowIndex); List values = exportValues(rowIndex, application); for (int index = 0; index < values.size(); index++) { row.createCell(index).setCellValue(values.get(index)); } rowIndex++; } int[] widths = { 8, 24, 18, 14, 16, 18, 16, 20, 28, 18, 24, 28, 20, 18, 24, 12, 16, 16, 22, 30, 22 }; for (int index = 0; index < widths.length; index++) { sheet.setColumnWidth(index, widths[index] * 256); } workbook.write(output); return output.toByteArray(); } catch (IOException exception) { throw new BusinessException(ErrorCode.INTERNAL_ERROR, "预约申请导出失败"); } } private QueryWrapper filter(String applicationType, String keyword, String applicantName, String applicantMobile, String serviceKeyword, String cardLevel, String subsidy, String submissionType, String status, String submittedStart, String submittedEnd) { QueryWrapper wrapper = new QueryWrapper().eq("deleted", 0); String type = normalizeFilterType(applicationType); if (!type.isBlank()) { wrapper.eq("application_type", type); } String key = clean(keyword); if (!key.isBlank()) { String like = "%" + key + "%"; wrapper.and(query -> query.like("application_no", like) .or().like("talent_name", like) .or().like("talent_mobile", like) .or().like("card_no", like) .or().like("applicant_name", like) .or().like("applicant_mobile", like) .or().like("spouse_name", like) .or().like("child_name", like)); } String name = clean(applicantName); if (!name.isBlank()) { wrapper.and(query -> query.like("talent_name", name) .or().like("applicant_name", name) .or().like("spouse_name", name) .or().like("child_name", name)); } String mobile = clean(applicantMobile); if (!mobile.isBlank()) { wrapper.and(query -> query.like("talent_mobile", mobile) .or().like("applicant_mobile", mobile) .or().like("spouse_mobile", mobile) .or().like("contact_mobile", mobile)); } String service = clean(serviceKeyword); if (!service.isBlank()) { String like = "%" + service + "%"; wrapper.and(query -> query.like("merchant_name", like) .or().like("hospital_name", like) .or().like("package_name", like) .or().like("product_name", like)); } if (!clean(cardLevel).isBlank()) { wrapper.eq("talent_level", clean(cardLevel)); } String subsidyValue = clean(subsidy); if ("是".equals(subsidyValue)) { wrapper.eq("subsidy", "是"); } else if ("否".equals(subsidyValue)) { wrapper.and(query -> query.eq("subsidy", "否").or().isNull("subsidy").or().eq("subsidy", "")); } else if (!subsidyValue.isBlank()) { wrapper.eq("subsidy", subsidyValue); } if (!clean(submissionType).isBlank()) { wrapper.eq("submission_type", clean(submissionType)); } if (!clean(status).isBlank()) { wrapper.eq("status", clean(status)); } LocalDate start = localDate(submittedStart, "提交开始日期格式不正确"); LocalDate end = localDate(submittedEnd, "提交结束日期格式不正确"); if (start != null) { wrapper.ge("submitted_at", start.atStartOfDay()); } if (end != null) { wrapper.lt("submitted_at", end.plusDays(1).atStartOfDay()); } return wrapper; } private Map listView(ServiceApplication application) { Map result = new LinkedHashMap<>(); result.put("id", application.getId()); result.put("applicationNo", application.getApplicationNo()); result.put("applicationType", application.getApplicationType()); result.put("applicationTypeName", typeName(application.getApplicationType())); result.put("status", application.getStatus()); result.put("talentName", application.getTalentName()); result.put("talentMobile", application.getTalentMobile()); result.put("talentLevel", application.getTalentLevel()); result.put("cardNo", application.getCardNo()); result.put("merchantName", application.getMerchantName()); result.put("hospitalName", application.getHospitalName()); result.put("contactMobile", application.getContactMobile()); result.put("packageName", application.getPackageName()); result.put("productName", application.getProductName()); result.put("expectedTime", application.getExpectedTime()); result.put("submissionType", application.getSubmissionType()); result.put("plateNo", application.getPlateNo()); result.put("parkingLot", application.getParkingLot()); result.put("alipayMobile", application.getAlipayMobile()); result.put("serviceName", serviceName(application)); result.put("appointmentContent", appointmentContent(application)); result.put("appointmentTime", first(application.getExpectedTime(), "以专员通知为准")); result.put("applicantName", participantName(application)); result.put("applicantMobile", sensitiveDataCrypto.decrypt(first(application.getApplicantMobile(), application.getSpouseMobile(), application.getTalentMobile()))); result.put("guestCount", guestCount(application)); if (ServiceAppointmentApplicationService.TYPE_SPOUSE.equals(application.getApplicationType())) { result.put("spouseName", first(application.getSpouseName(), application.getApplicantName())); result.put("spouseMobile", sensitiveDataCrypto.decrypt(first(application.getSpouseMobile(), application.getApplicantMobile()))); } result.put("submittedAt", application.getSubmittedAt()); result.put("handledAt", application.getHandledAt()); result.put("handlerName", application.getHandlerName()); return result; } private Map detailView(ServiceApplication application) { Map result = listView(application); result.put("handlerName", handlerName(application)); result.put("talentId", application.getTalentId()); result.put("cardId", application.getCardId()); result.put("merchantId", application.getMerchantId()); result.put("contactMobile", application.getContactMobile()); result.put("packageName", application.getPackageName()); result.put("productName", application.getProductName()); result.put("applicantMobile", sensitiveDataCrypto.decrypt(application.getApplicantMobile())); result.put("applicantIdCardNo", sensitiveDataCrypto.decrypt(application.getApplicantIdCardNo())); result.put("checkInDate", application.getCheckInDate()); result.put("checkOutDate", application.getCheckOutDate()); result.put("guestCount", guestCount(application)); result.put("subsidy", application.getSubsidy()); result.put("remark", application.getRejectReason()); result.put("estimatedProcessingTime", application.getEstimatedProcessingTime()); result.put("institutionAddress", application.getInstitutionAddressSnapshot()); result.put("institutionLongitude", application.getInstitutionLongitudeSnapshot()); result.put("institutionLatitude", application.getInstitutionLatitudeSnapshot()); Map spouse = new LinkedHashMap<>(); spouse.put("name", first(application.getSpouseName())); spouse.put("mobile", sensitiveDataCrypto.decrypt(application.getSpouseMobile())); spouse.put("idCardNo", sensitiveDataCrypto.decrypt(application.getSpouseIdCardNo())); spouse.put("politicalStatus", first(application.getSpousePoliticalStatus())); spouse.put("education", first(application.getSpouseEducation())); spouse.put("schoolMajor", first(application.getSpouseSchoolMajor())); spouse.put("workUnit", first(application.getSpouseWorkUnit())); spouse.put("inInstitution", first(application.getSpouseInInstitution())); spouse.put("workExperience", first(application.getSpouseWorkExperience())); result.put("spouse", spouse); result.put("spouseIdCardNo", spouse.get("idCardNo")); result.put("spousePoliticalStatus", spouse.get("politicalStatus")); result.put("spouseEducation", spouse.get("education")); result.put("spouseSchoolMajor", spouse.get("schoolMajor")); result.put("spouseWorkUnit", spouse.get("workUnit")); result.put("spouseInInstitution", spouse.get("inInstitution")); result.put("spouseWorkExperience", spouse.get("workExperience")); Map child = new LinkedHashMap<>(); child.put("id", application.getChildId()); child.put("name", first(application.getChildName())); child.put("gender", first(application.getChildGender())); child.put("birthDate", application.getChildBirthDate()); child.put("proofFileIds", proofFileIds(application.getProofFileIds())); result.put("child", child); return result; } private Map operationLogView(ServiceApplicationOperationLog log) { Map result = new LinkedHashMap<>(); result.put("id", log.getId()); result.put("applicationId", log.getApplicationId()); result.put("applicationNo", log.getApplicationNo()); result.put("operationType", log.getOperationType()); result.put("operationTypeText", "SUBMIT".equals(log.getOperationType()) ? "提交申请" : "状态更新"); result.put("beforeStatus", log.getBeforeStatus()); result.put("afterStatus", log.getAfterStatus()); result.put("operatorType", log.getOperatorType()); result.put("operatorId", log.getOperatorId()); result.put("operatorName", log.getOperatorName()); result.put("remark", log.getRemark()); result.put("createdAt", log.getCreatedAt()); return result; } private List exportValues(int index, ServiceApplication application) { return List.of( String.valueOf(index), first(application.getApplicationNo()), typeName(application.getApplicationType()), first(application.getStatus()), first(application.getTalentName()), first(application.getTalentMobile()), first(application.getTalentLevel()), first(application.getCardNo()), serviceName(application), first(application.getContactMobile()), appointmentContent(application), first(application.getExpectedTime()), participantName(application), sensitiveDataCrypto.decrypt(application.getApplicantMobile()), sensitiveDataCrypto.decrypt(first(application.getApplicantIdCardNo(), application.getSpouseIdCardNo())), first(application.getChildGender()), application.getChildBirthDate() == null ? "" : application.getChildBirthDate().toString(), first(application.getHandlerName()), application.getHandledAt() == null ? "" : application.getHandledAt().toString(), first(application.getRejectReason()), application.getSubmittedAt() == null ? "" : application.getSubmittedAt().toString() ); } private void syncChildAudit(ServiceApplication application, String status, String remark) { if (!ServiceAppointmentApplicationService.TYPE_CHILD.equals(application.getApplicationType()) || application.getChildId() == null) { return; } String auditStatus = switch (status) { case "审核通过", "已处理" -> "已通过"; case "审核不通过", "已驳回", "已取消" -> "审核不通过"; case "待审核", "待处理" -> "待审核"; default -> null; }; if (auditStatus == null) { return; } childInformationMapper.update(null, new UpdateWrapper() .set("audit_status", auditStatus) .set("reject_reason", "审核不通过".equals(auditStatus) ? remark : null) .eq("id", application.getChildId()) .eq("talent_id", application.getTalentId()) .eq("deleted", 0)); } private ServiceApplication application(Long id) { if (id == null || id <= 0) { throw new BusinessException(ErrorCode.BAD_REQUEST, "预约申请ID不能为空"); } ServiceApplication application = applicationMapper.selectOne(new QueryWrapper() .eq("id", id) .eq("deleted", 0) .last("limit 1")); if (application == null) { throw new BusinessException(ErrorCode.NOT_FOUND, "预约申请不存在"); } return application; } private String normalizeStatus(String raw, String applicationType) { String status = clean(raw); boolean child = ServiceAppointmentApplicationService.TYPE_CHILD.equals(applicationType); return switch (status) { case "待处理", "处理中", "已处理", "已驳回", "已取消", "待审核", "审核通过", "审核不通过" -> status; case "通过", "已通过", "完成", "处理完成" -> child ? "审核通过" : "已处理"; case "拒绝", "驳回", "不通过", "审核拒绝" -> child ? "审核不通过" : "已驳回"; default -> throw new BusinessException(ErrorCode.BAD_REQUEST, "处理状态不正确"); }; } private String normalizeFilterType(String raw) { String value = clean(raw).toUpperCase(Locale.ROOT).replace('-', '_'); if (value.isBlank()) { return ""; } return switch (value) { case "MEDICAL", "MEDICAL_CHECKUP", "MEDICAL_RESERVATION", "PHYSICAL_EXAM", "HEALTH_CHECK" -> ServiceAppointmentApplicationService.TYPE_MEDICAL; case "FAST_TRACK", "QUICK_CHANNEL", "GREEN_CHANNEL", "MEDICAL_APPLICATION", "HOSPITAL", "HOSPITAL_APPOINTMENT" -> ServiceAppointmentApplicationService.TYPE_FAST_TRACK; case "HOTEL", "HOTEL_RESERVATION", "HOTEL_BOOKING" -> ServiceAppointmentApplicationService.TYPE_HOTEL; case "SPOUSE", "SPOUSE_EMPLOYMENT", "EMPLOYMENT" -> ServiceAppointmentApplicationService.TYPE_SPOUSE; case "CHILD", "CHILDREN", "CHILD_ENTRUSTMENT", "CHILDREN_ENTRUSTMENT", "STUDENT_CARE", "STUDENT_ENTRUSTMENT" -> ServiceAppointmentApplicationService.TYPE_CHILD; default -> raw; }; } private String typeName(String type) { return switch (type) { case ServiceAppointmentApplicationService.TYPE_MEDICAL -> "体检预约"; case ServiceAppointmentApplicationService.TYPE_FAST_TRACK -> "医院快捷通道"; case ServiceAppointmentApplicationService.TYPE_HOTEL -> "酒店预约"; case ServiceAppointmentApplicationService.TYPE_SPOUSE -> "配偶推荐就业"; case ServiceAppointmentApplicationService.TYPE_CHILD -> "学生托管"; default -> "预约申请"; }; } private String serviceName(ServiceApplication application) { return first(application.getHospitalName(), application.getMerchantName(), application.getParkingLot(), "人才服务机构"); } private String appointmentContent(ServiceApplication application) { return first(application.getProductName(), application.getPackageName(), typeName(application.getApplicationType())); } private String participantName(ServiceApplication application) { return first(application.getApplicantName(), application.getSpouseName(), application.getChildName(), application.getTalentName()); } private Integer guestCount(ServiceApplication application) { if (application.getGuestCount() != null) { return application.getGuestCount(); } return ServiceAppointmentApplicationService.TYPE_HOTEL.equals(application.getApplicationType()) ? 1 : null; } private String handlerName(ServiceApplication application) { if (application.getHandledAt() == null) { return first(application.getHandlerName()); } ServiceApplicationOperationLog log = operationLogMapper.selectOne( new QueryWrapper() .eq("application_id", application.getId()) .eq("operation_type", "STATUS_UPDATE") .eq("after_status", application.getStatus()) .orderByDesc("created_at", "id") .last("limit 1") ); return first( log == null ? "" : operatorName(log.getOperatorId()), log == null ? "" : log.getOperatorName(), application.getHandlerName() ); } private String operatorName(Long operatorId) { return operatorId == null ? "" : first(operationLogMapper.selectOperatorName(operatorId)); } private List proofFileIds(String value) { if (value == null || value.isBlank()) { return List.of(); } List result = new ArrayList<>(); for (String item : value.split(",")) { String cleaned = clean(item); if (!cleaned.isBlank()) { result.add(cleaned); } } return result; } private LocalDate localDate(String value, String message) { String text = clean(value); if (text.isBlank()) { return null; } try { return LocalDate.parse(text); } catch (DateTimeParseException exception) { throw new BusinessException(ErrorCode.BAD_REQUEST, message); } } private CellStyle headerStyle(Workbook workbook) { CellStyle style = workbook.createCellStyle(); style.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); style.setFillPattern(FillPatternType.SOLID_FOREGROUND); return style; } private String first(String... values) { for (String value : values) { String text = clean(value); if (!text.isBlank()) { return text; } } return ""; } private String clean(String value) { return value == null ? "" : value.trim(); } }