talent-card-server/src/main/java/com/talentcard/server/service/ServiceAppointmentAdminService.java
@@ -20,8 +20,10 @@
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;
@@ -39,27 +41,31 @@
    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<Map<String, Object>> page(String applicationType, String keyword, String serviceKeyword,
                                                String cardLevel, String status, String submittedStart,
    public PageResult<Map<String, Object>> 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<ServiceApplication> countWrapper = filter(applicationType, keyword, serviceKeyword, cardLevel, status, submittedStart, submittedEnd);
        QueryWrapper<ServiceApplication> countWrapper = filter(applicationType, keyword, applicantName, applicantMobile, serviceKeyword, cardLevel, subsidy, submissionType, status, submittedStart, submittedEnd);
        Long total = applicationMapper.selectCount(countWrapper);
        QueryWrapper<ServiceApplication> pageWrapper = filter(applicationType, keyword, serviceKeyword, cardLevel, status, submittedStart, submittedEnd)
        QueryWrapper<ServiceApplication> 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<Map<String, Object>> records = applicationMapper.selectList(pageWrapper).stream()
@@ -74,6 +80,16 @@
        return detailView(application);
    }
    @Transactional(readOnly = true)
    public List<Map<String, Object>> operationLogs(Long id) {
        application(id);
        return operationLogMapper.selectList(new QueryWrapper<ServiceApplicationOperationLog>()
                        .eq("application_id", id)
                        .orderByAsc("created_at", "id"))
                .stream().map(this::operationLogView).toList();
    }
    @Transactional
    public Map<String, Object> updateStatus(Long id, ServiceAppointmentStatusUpdateRequest request, Long operatorId) {
        ServiceApplication current = applicationMapper.selectByIdForUpdate(id);
@@ -85,7 +101,7 @@
        if (REJECTED_STATUSES.contains(nextStatus) && remark.isBlank()) {
            throw new BusinessException(ErrorCode.BAD_REQUEST, "拒绝或驳回时必须填写处理备注");
        }
        String handlerName = first(request.handlerName(), operatorId == null ? "" : "管理员" + operatorId, "管理员");
        String handlerName = first(operatorName(operatorId), request.handlerName(), "管理员");
        LocalDateTime handledAt = PENDING_STATUSES.contains(nextStatus) ? null : LocalDateTime.now();
        UpdateWrapper<ServiceApplication> update = new UpdateWrapper<ServiceApplication>()
                .set("status", nextStatus)
@@ -98,14 +114,29 @@
        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 serviceKeyword,
                              String cardLevel, String status, String submittedStart, String submittedEnd) {
        QueryWrapper<ServiceApplication> wrapper = filter(applicationType, keyword, serviceKeyword, cardLevel, status, submittedStart, submittedEnd)
    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<ServiceApplication> wrapper = filter(applicationType, keyword, applicantName, applicantMobile, serviceKeyword, cardLevel, subsidy, submissionType, status, submittedStart, submittedEnd)
                .orderByDesc("submitted_at", "id");
        List<ServiceApplication> applications = applicationMapper.selectList(wrapper);
        String[] headers = {
@@ -142,8 +173,9 @@
        }
    }
    private QueryWrapper<ServiceApplication> filter(String applicationType, String keyword, String serviceKeyword,
                                                    String cardLevel, String status, String submittedStart, String submittedEnd) {
    private QueryWrapper<ServiceApplication> filter(String applicationType, String keyword, String applicantName,
                                                    String applicantMobile, String serviceKeyword,
                                                    String cardLevel, String subsidy, String submissionType, String status, String submittedStart, String submittedEnd) {
        QueryWrapper<ServiceApplication> wrapper = new QueryWrapper<ServiceApplication>().eq("deleted", 0);
        String type = normalizeFilterType(applicationType);
        if (!type.isBlank()) {
@@ -161,6 +193,20 @@
                    .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 + "%";
@@ -171,6 +217,17 @@
        }
        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));
@@ -197,10 +254,26 @@
        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());
@@ -209,6 +282,7 @@
    private Map<String, Object> detailView(ServiceApplication application) {
        Map<String, Object> result = listView(application);
        result.put("handlerName", handlerName(application));
        result.put("talentId", application.getTalentId());
        result.put("cardId", application.getCardId());
        result.put("merchantId", application.getMerchantId());
@@ -219,7 +293,7 @@
        result.put("applicantIdCardNo", sensitiveDataCrypto.decrypt(application.getApplicantIdCardNo()));
        result.put("checkInDate", application.getCheckInDate());
        result.put("checkOutDate", application.getCheckOutDate());
        result.put("guestCount", application.getGuestCount());
        result.put("guestCount", guestCount(application));
        result.put("subsidy", application.getSubsidy());
        result.put("remark", application.getRejectReason());
        result.put("estimatedProcessingTime", application.getEstimatedProcessingTime());
@@ -237,6 +311,13 @@
        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<String, Object> child = new LinkedHashMap<>();
        child.put("id", application.getChildId());
@@ -247,6 +328,24 @@
        result.put("child", child);
        return result;
    }
    private Map<String, Object> operationLogView(ServiceApplicationOperationLog log) {
        Map<String, Object> 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<String> exportValues(int index, ServiceApplication application) {
        return List.of(
@@ -358,6 +457,36 @@
        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<ServiceApplicationOperationLog>()
                        .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<String> proofFileIds(String value) {
        if (value == null || value.isBlank()) {
            return List.of();
@@ -404,4 +533,4 @@
    private String clean(String value) {
        return value == null ? "" : value.trim();
    }
}
}