From 9733de9f22a5e27593eb4e31584c2f43301bcecf Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Wed, 8 Apr 2026 17:41:17 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E5=8A=9F=E8=83=BD=E8=B0=83?= =?UTF-8?q?=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/workspace.xml | 1 + .../unis/crm/controller/WorkController.java | 43 ++ .../crm/dto/work/WorkCheckInExportDTO.java | 135 +++++ .../dto/work/WorkDailyReportExportDTO.java | 152 ++++++ .../java/com/unis/crm/mapper/WorkMapper.java | 22 + .../com/unis/crm/service/WorkService.java | 22 + .../crm/service/impl/WorkServiceImpl.java | 100 ++++ .../main/resources/mapper/work/WorkMapper.xml | 118 +++++ .../crm/mapper/WorkMapperDataScopeTest.java | 37 ++ .../crm/service/impl/WorkServiceImplTest.java | 49 ++ .../org.mockito.plugins.MockMaker | 1 + frontend/src/lib/auth.ts | 65 +++ frontend/src/pages/Expansion.tsx | 428 ++++++++++++++- frontend/src/pages/Opportunities.tsx | 348 ++++++++++++- frontend/src/pages/Work.tsx | 490 +++++++++++++++++- 15 files changed, 1972 insertions(+), 39 deletions(-) create mode 100644 backend/src/main/java/com/unis/crm/dto/work/WorkCheckInExportDTO.java create mode 100644 backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java create mode 100644 backend/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 3ed10614..54f71231 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -90,6 +90,7 @@ + diff --git a/backend/src/main/java/com/unis/crm/controller/WorkController.java b/backend/src/main/java/com/unis/crm/controller/WorkController.java index 35b08449..e6962208 100644 --- a/backend/src/main/java/com/unis/crm/controller/WorkController.java +++ b/backend/src/main/java/com/unis/crm/controller/WorkController.java @@ -5,12 +5,17 @@ import com.unis.crm.common.BusinessException; import com.unis.crm.common.CurrentUserUtils; import com.unis.crm.dto.work.CreateWorkCheckInRequest; import com.unis.crm.dto.work.CreateWorkDailyReportRequest; +import com.unis.crm.dto.work.WorkCheckInExportDTO; +import com.unis.crm.dto.work.WorkDailyReportExportDTO; import com.unis.crm.dto.work.WorkHistoryPageDTO; import com.unis.crm.dto.work.WorkOverviewDTO; import com.unis.crm.service.WorkService; import com.unisbase.common.annotation.Log; import jakarta.validation.Valid; import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import org.springframework.format.annotation.DateTimeFormat; import org.springframework.core.io.Resource; import org.springframework.http.CacheControl; import org.springframework.http.MediaType; @@ -53,6 +58,44 @@ public class WorkController { return ApiResponse.success(workService.getHistory(CurrentUserUtils.requireCurrentUserId(userId), type, page, size)); } + @GetMapping("/checkins/export-data") + public ApiResponse> exportCheckIns( + @RequestHeader("X-User-Id") Long userId, + @RequestParam(value = "startDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(value = "endDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "deptName", required = false) String deptName, + @RequestParam(value = "bizType", required = false) String bizType, + @RequestParam(value = "status", required = false) String status) { + return ApiResponse.success(workService.exportCheckIns( + CurrentUserUtils.requireCurrentUserId(userId), + startDate, + endDate, + keyword, + deptName, + bizType, + status)); + } + + @GetMapping("/daily-reports/export-data") + public ApiResponse> exportDailyReports( + @RequestHeader("X-User-Id") Long userId, + @RequestParam(value = "startDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate startDate, + @RequestParam(value = "endDate", required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate endDate, + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "deptName", required = false) String deptName, + @RequestParam(value = "bizType", required = false) String bizType, + @RequestParam(value = "status", required = false) String status) { + return ApiResponse.success(workService.exportDailyReports( + CurrentUserUtils.requireCurrentUserId(userId), + startDate, + endDate, + keyword, + deptName, + bizType, + status)); + } + @GetMapping("/reverse-geocode") public ApiResponse reverseGeocode( @RequestHeader(value = "X-User-Id", required = false) Long userId, diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInExportDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInExportDTO.java new file mode 100644 index 00000000..6c4bff39 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkCheckInExportDTO.java @@ -0,0 +1,135 @@ +package com.unis.crm.dto.work; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +public class WorkCheckInExportDTO { + + private String checkinDate; + private String checkinTime; + private String userName; + private String deptName; + private String bizType; + private String bizName; + private String locationText; + private BigDecimal longitude; + private BigDecimal latitude; + private String remark; + private String status; + private String createdAt; + private String updatedAt; + private List photoUrls = new ArrayList<>(); + + public String getCheckinDate() { + return checkinDate; + } + + public void setCheckinDate(String checkinDate) { + this.checkinDate = checkinDate; + } + + public String getCheckinTime() { + return checkinTime; + } + + public void setCheckinTime(String checkinTime) { + this.checkinTime = checkinTime; + } + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getDeptName() { + return deptName; + } + + public void setDeptName(String deptName) { + this.deptName = deptName; + } + + public String getBizType() { + return bizType; + } + + public void setBizType(String bizType) { + this.bizType = bizType; + } + + public String getBizName() { + return bizName; + } + + public void setBizName(String bizName) { + this.bizName = bizName; + } + + public String getLocationText() { + return locationText; + } + + public void setLocationText(String locationText) { + this.locationText = locationText; + } + + public BigDecimal getLongitude() { + return longitude; + } + + public void setLongitude(BigDecimal longitude) { + this.longitude = longitude; + } + + public BigDecimal getLatitude() { + return latitude; + } + + public void setLatitude(BigDecimal latitude) { + this.latitude = latitude; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(String updatedAt) { + this.updatedAt = updatedAt; + } + + public List getPhotoUrls() { + return photoUrls; + } + + public void setPhotoUrls(List photoUrls) { + this.photoUrls = photoUrls; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java new file mode 100644 index 00000000..cefcbc95 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java @@ -0,0 +1,152 @@ +package com.unis.crm.dto.work; + +import java.util.ArrayList; +import java.util.List; + +public class WorkDailyReportExportDTO { + + private String reportDate; + private String submitTime; + private String userName; + private String deptName; + private String sourceType; + private String status; + private String workContent; + private String tomorrowPlan; + private Integer score; + private String comment; + private String reviewerName; + private String reviewedAt; + private String createdAt; + private String updatedAt; + private List lineItems = new ArrayList<>(); + private List planItems = new ArrayList<>(); + + public String getReportDate() { + return reportDate; + } + + public void setReportDate(String reportDate) { + this.reportDate = reportDate; + } + + public String getSubmitTime() { + return submitTime; + } + + public void setSubmitTime(String submitTime) { + this.submitTime = submitTime; + } + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getDeptName() { + return deptName; + } + + public void setDeptName(String deptName) { + this.deptName = deptName; + } + + public String getSourceType() { + return sourceType; + } + + public void setSourceType(String sourceType) { + this.sourceType = sourceType; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getWorkContent() { + return workContent; + } + + public void setWorkContent(String workContent) { + this.workContent = workContent; + } + + public String getTomorrowPlan() { + return tomorrowPlan; + } + + public void setTomorrowPlan(String tomorrowPlan) { + this.tomorrowPlan = tomorrowPlan; + } + + public Integer getScore() { + return score; + } + + public void setScore(Integer score) { + this.score = score; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public String getReviewerName() { + return reviewerName; + } + + public void setReviewerName(String reviewerName) { + this.reviewerName = reviewerName; + } + + public String getReviewedAt() { + return reviewedAt; + } + + public void setReviewedAt(String reviewedAt) { + this.reviewedAt = reviewedAt; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(String updatedAt) { + this.updatedAt = updatedAt; + } + + public List getLineItems() { + return lineItems; + } + + public void setLineItems(List lineItems) { + this.lineItems = lineItems; + } + + public List getPlanItems() { + return planItems; + } + + public void setPlanItems(List planItems) { + this.planItems = planItems; + } +} diff --git a/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java b/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java index 7a9aaabd..cb182b33 100644 --- a/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java @@ -3,10 +3,13 @@ package com.unis.crm.mapper; import com.unis.crm.dto.work.CreateWorkCheckInRequest; import com.unis.crm.dto.work.CreateWorkDailyReportRequest; import com.unis.crm.dto.work.WorkCheckInDTO; +import com.unis.crm.dto.work.WorkCheckInExportDTO; import com.unis.crm.dto.work.WorkDailyReportDTO; +import com.unis.crm.dto.work.WorkDailyReportExportDTO; import com.unis.crm.dto.work.WorkHistoryItemDTO; import com.unis.crm.dto.work.WorkSuggestedActionDTO; import com.unisbase.annotation.DataScope; +import java.time.LocalDate; import java.util.List; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -26,6 +29,25 @@ public interface WorkMapper { @DataScope(tableAlias = "r", ownerColumn = "user_id") List selectReportHistory(@Param("limit") int limit); + @DataScope(tableAlias = "c", ownerColumn = "user_id") + List selectCheckInExportRows( + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("keyword") String keyword, + @Param("deptName") String deptName, + @Param("bizType") String bizType, + @Param("status") String status, + @Param("limit") int limit); + + @DataScope(tableAlias = "r", ownerColumn = "user_id") + List selectDailyReportExportRows( + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("keyword") String keyword, + @Param("deptName") String deptName, + @Param("status") String status, + @Param("limit") int limit); + Long selectTodayCheckInId(@Param("userId") Long userId); int insertCheckIn(@Param("userId") Long userId, @Param("request") CreateWorkCheckInRequest request); diff --git a/backend/src/main/java/com/unis/crm/service/WorkService.java b/backend/src/main/java/com/unis/crm/service/WorkService.java index 27ad9e77..209453c3 100644 --- a/backend/src/main/java/com/unis/crm/service/WorkService.java +++ b/backend/src/main/java/com/unis/crm/service/WorkService.java @@ -2,9 +2,13 @@ package com.unis.crm.service; import com.unis.crm.dto.work.CreateWorkCheckInRequest; import com.unis.crm.dto.work.CreateWorkDailyReportRequest; +import com.unis.crm.dto.work.WorkCheckInExportDTO; +import com.unis.crm.dto.work.WorkDailyReportExportDTO; import com.unis.crm.dto.work.WorkHistoryPageDTO; import com.unis.crm.dto.work.WorkOverviewDTO; import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; import org.springframework.core.io.Resource; import org.springframework.web.multipart.MultipartFile; @@ -14,6 +18,24 @@ public interface WorkService { WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size); + List exportCheckIns( + Long userId, + LocalDate startDate, + LocalDate endDate, + String keyword, + String deptName, + String bizType, + String status); + + List exportDailyReports( + Long userId, + LocalDate startDate, + LocalDate endDate, + String keyword, + String deptName, + String bizType, + String status); + Long saveCheckIn(Long userId, CreateWorkCheckInRequest request); Long saveDailyReport(Long userId, CreateWorkDailyReportRequest request); diff --git a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java index 8f2bc3f5..5110e535 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/WorkServiceImpl.java @@ -6,7 +6,9 @@ import com.unis.crm.common.BusinessException; import com.unis.crm.dto.work.CreateWorkCheckInRequest; import com.unis.crm.dto.work.CreateWorkDailyReportRequest; import com.unis.crm.dto.work.WorkCheckInDTO; +import com.unis.crm.dto.work.WorkCheckInExportDTO; import com.unis.crm.dto.work.WorkDailyReportDTO; +import com.unis.crm.dto.work.WorkDailyReportExportDTO; import com.unis.crm.dto.work.WorkHistoryItemDTO; import com.unis.crm.dto.work.WorkHistoryPageDTO; import com.unis.crm.dto.work.WorkReportLineItemDTO; @@ -70,6 +72,7 @@ public class WorkServiceImpl implements WorkService { private static final Pattern PLAN_ITEMS_METADATA_PATTERN = Pattern.compile("\\[\\[WORK_PLAN_ITEMS]](.*?)\\[\\[/WORK_PLAN_ITEMS]]", Pattern.DOTALL); private static final String ONLY_SEE_ROLE_CODE = "only_see"; private static final String WORK_REPORT_FOLLOW_UP_TYPE = "工作日报"; + private static final int EXPORT_LIMIT = 5000; private static final String TENCENT_COORD_TYPE_GPS = "1"; private static final String TENCENT_COORD_TYPE_GCJ02 = "2"; private static final String OPEN_STREET_MAP_USER_AGENT = "unis-crm/1.0"; @@ -134,6 +137,58 @@ public class WorkServiceImpl implements WorkService { return new WorkHistoryPageDTO(pagedItems, hasMore, safePage, safeSize); } + @Override + public List exportCheckIns( + Long userId, + LocalDate startDate, + LocalDate endDate, + String keyword, + String deptName, + String bizType, + String status) { + requireUser(userId); + validateDateRange(startDate, endDate); + String normalizedBizType = normalizeBizType(bizType); + List rows = workMapper.selectCheckInExportRows( + startDate, + endDate, + normalizeOptionalText(keyword), + normalizeOptionalText(deptName), + normalizedBizType, + normalizeOptionalText(status), + EXPORT_LIMIT); + normalizeCheckInExportRows(rows); + return rows; + } + + @Override + public List exportDailyReports( + Long userId, + LocalDate startDate, + LocalDate endDate, + String keyword, + String deptName, + String bizType, + String status) { + requireUser(userId); + validateDateRange(startDate, endDate); + String normalizedBizType = normalizeBizType(bizType); + List rows = workMapper.selectDailyReportExportRows( + startDate, + endDate, + normalizeOptionalText(keyword), + normalizeOptionalText(deptName), + normalizeOptionalText(status), + EXPORT_LIMIT); + normalizeDailyReportExportRows(rows); + if (normalizedBizType == null) { + return rows; + } + return rows.stream() + .filter(row -> hasReportLineOfType(row, normalizedBizType)) + .toList(); + } + @Override public Long saveCheckIn(Long userId, CreateWorkCheckInRequest request) { requireUser(userId); @@ -593,6 +648,51 @@ public class WorkServiceImpl implements WorkService { } } + private void normalizeCheckInExportRows(List rows) { + if (rows == null) { + return; + } + for (WorkCheckInExportDTO row : rows) { + if (row == null) { + continue; + } + PhotoMetadata photoMetadata = extractPhotoMetadata(row.getRemark()); + row.setRemark(photoMetadata.cleanText()); + row.setPhotoUrls(photoMetadata.photoUrls()); + } + } + + private void normalizeDailyReportExportRows(List rows) { + if (rows == null) { + return; + } + for (WorkDailyReportExportDTO row : rows) { + if (row == null) { + continue; + } + ReportLineMetadata reportLineMetadata = extractReportLineMetadata(row.getWorkContent()); + row.setWorkContent(stripVisitTimeFromReportText(reportLineMetadata.cleanText())); + row.setLineItems(reportLineMetadata.lineItems()); + PlanItemMetadata planItemMetadata = extractPlanItemMetadata(row.getTomorrowPlan()); + row.setTomorrowPlan(planItemMetadata.cleanText()); + row.setPlanItems(planItemMetadata.planItems()); + } + } + + private boolean hasReportLineOfType(WorkDailyReportExportDTO row, String bizType) { + if (row == null || bizType == null || row.getLineItems() == null) { + return false; + } + return row.getLineItems().stream() + .anyMatch(item -> item != null && bizType.equals(item.getBizType())); + } + + private void validateDateRange(LocalDate startDate, LocalDate endDate) { + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw new BusinessException("开始日期不能晚于结束日期"); + } + } + private String stripVisitTimeFromReportText(String rawText) { String normalized = rawText == null ? "" : rawText; return normalized diff --git a/backend/src/main/resources/mapper/work/WorkMapper.xml b/backend/src/main/resources/mapper/work/WorkMapper.xml index 2f6e665d..af5db759 100644 --- a/backend/src/main/resources/mapper/work/WorkMapper.xml +++ b/backend/src/main/resources/mapper/work/WorkMapper.xml @@ -242,6 +242,124 @@ limit #{limit} + + select + to_char(c.checkin_date, 'YYYY-MM-DD') as checkinDate, + to_char(c.checkin_time, 'HH24:MI') as checkinTime, + coalesce(nullif(btrim(c.user_name), ''), nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName, + coalesce(nullif(btrim(c.dept_name), ''), nullif(btrim(org_info.org_names), ''), '') as deptName, + coalesce(c.biz_type, '') as bizType, + coalesce(c.biz_name, '') as bizName, + coalesce(c.location_text, '') as locationText, + c.longitude, + c.latitude, + coalesce(c.remark, '') as remark, + coalesce(c.status, 'normal') as status, + to_char(c.created_at, 'YYYY-MM-DD HH24:MI') as createdAt, + to_char(c.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt + from work_checkin c + left join sys_user u on u.user_id = c.user_id and u.is_deleted = 0 + left join lateral ( + select string_agg(distinct o.org_name, '、' order by o.org_name) as org_names + from sys_tenant_user tu + join sys_org o on o.id = tu.org_id + where tu.user_id = c.user_id + and tu.is_deleted = 0 + and o.is_deleted = 0 + ) org_info on true + where 1 = 1 + + and c.checkin_date >= #{startDate} + + + and c.checkin_date <= #{endDate} + + + and ( + coalesce(c.user_name, '') ilike concat('%', #{keyword}, '%') + or coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%') + or coalesce(u.username, '') ilike concat('%', #{keyword}, '%') + or coalesce(c.biz_name, '') ilike concat('%', #{keyword}, '%') + or coalesce(c.location_text, '') ilike concat('%', #{keyword}, '%') + or coalesce(c.remark, '') ilike concat('%', #{keyword}, '%') + ) + + + and coalesce(nullif(btrim(c.dept_name), ''), nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%') + + + and c.biz_type = #{bizType} + + + and coalesce(c.status, 'normal') = #{status} + + order by coalesce(c.checkin_date::timestamp + c.checkin_time::time, c.created_at) desc nulls last, c.id desc + limit #{limit} + + + + select + to_char(r.report_date, 'YYYY-MM-DD') as reportDate, + to_char(r.submit_time, 'YYYY-MM-DD HH24:MI') as submitTime, + coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as userName, + coalesce(nullif(btrim(org_info.org_names), ''), '') as deptName, + coalesce(r.source_type, 'manual') as sourceType, + coalesce(r.status, 'submitted') as status, + coalesce(r.work_content, '') as workContent, + coalesce(r.tomorrow_plan, '') as tomorrowPlan, + rc.score, + rc.comment_content as comment, + coalesce(nullif(btrim(reviewer.display_name), ''), nullif(btrim(reviewer.username), ''), '') as reviewerName, + to_char(rc.reviewed_at, 'YYYY-MM-DD HH24:MI') as reviewedAt, + to_char(r.created_at, 'YYYY-MM-DD HH24:MI') as createdAt, + to_char(r.updated_at, 'YYYY-MM-DD HH24:MI') as updatedAt + from work_daily_report r + left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0 + left join lateral ( + select string_agg(distinct o.org_name, '、' order by o.org_name) as org_names + from sys_tenant_user tu + join sys_org o on o.id = tu.org_id + where tu.user_id = r.user_id + and tu.is_deleted = 0 + and o.is_deleted = 0 + ) org_info on true + left join ( + select distinct on (report_id) + report_id, + reviewer_user_id, + score, + comment_content, + reviewed_at + from work_daily_report_comment + order by report_id, reviewed_at desc nulls last, id desc + ) rc on rc.report_id = r.id + left join sys_user reviewer on reviewer.user_id = rc.reviewer_user_id and reviewer.is_deleted = 0 + where 1 = 1 + + and r.report_date >= #{startDate} + + + and r.report_date <= #{endDate} + + + and ( + coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%') + or coalesce(u.username, '') ilike concat('%', #{keyword}, '%') + or coalesce(r.work_content, '') ilike concat('%', #{keyword}, '%') + or coalesce(r.tomorrow_plan, '') ilike concat('%', #{keyword}, '%') + or coalesce(rc.comment_content, '') ilike concat('%', #{keyword}, '%') + ) + + + and coalesce(nullif(btrim(org_info.org_names), ''), '') ilike concat('%', #{deptName}, '%') + + + and coalesce(r.status, 'submitted') = #{status} + + order by coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) desc nulls last, r.id desc + limit #{limit} + + select id from work_checkin diff --git a/backend/src/test/java/com/unis/crm/mapper/WorkMapperDataScopeTest.java b/backend/src/test/java/com/unis/crm/mapper/WorkMapperDataScopeTest.java index 16a6625f..224dc383 100644 --- a/backend/src/test/java/com/unis/crm/mapper/WorkMapperDataScopeTest.java +++ b/backend/src/test/java/com/unis/crm/mapper/WorkMapperDataScopeTest.java @@ -30,4 +30,41 @@ class WorkMapperDataScopeTest { assertEquals("r", dataScope.tableAlias()); assertEquals("user_id", dataScope.ownerColumn()); } + + @Test + void selectCheckInExportRows_shouldUseDataScopeOnCheckInUserId() throws Exception { + Method method = WorkMapper.class.getMethod( + "selectCheckInExportRows", + java.time.LocalDate.class, + java.time.LocalDate.class, + String.class, + String.class, + String.class, + String.class, + int.class); + + DataScope dataScope = method.getAnnotation(DataScope.class); + + assertNotNull(dataScope); + assertEquals("c", dataScope.tableAlias()); + assertEquals("user_id", dataScope.ownerColumn()); + } + + @Test + void selectDailyReportExportRows_shouldUseDataScopeOnReportUserId() throws Exception { + Method method = WorkMapper.class.getMethod( + "selectDailyReportExportRows", + java.time.LocalDate.class, + java.time.LocalDate.class, + String.class, + String.class, + String.class, + int.class); + + DataScope dataScope = method.getAnnotation(DataScope.class); + + assertNotNull(dataScope); + assertEquals("r", dataScope.tableAlias()); + assertEquals("user_id", dataScope.ownerColumn()); + } } diff --git a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java index 0afa06b9..d4252ffc 100644 --- a/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java +++ b/backend/src/test/java/com/unis/crm/service/impl/WorkServiceImplTest.java @@ -10,6 +10,8 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.unis.crm.common.BusinessException; import com.unis.crm.dto.work.CreateWorkCheckInRequest; import com.unis.crm.dto.work.CreateWorkDailyReportRequest; +import com.unis.crm.dto.work.WorkCheckInExportDTO; +import com.unis.crm.dto.work.WorkDailyReportExportDTO; import com.unis.crm.dto.work.WorkHistoryItemDTO; import com.unis.crm.dto.work.WorkHistoryPageDTO; import com.unis.crm.mapper.ProfileMapper; @@ -135,6 +137,53 @@ class WorkServiceImplTest { assertEquals("当前角色仅可查看打卡历史记录", exception.getMessage()); } + @Test + void exportCheckIns_shouldCleanPhotoMetadata() { + WorkCheckInExportDTO row = new WorkCheckInExportDTO(); + row.setRemark("已完成现场拜访\n[[CHECKIN_PHOTOS]]/api/work/checkin-photos/a.jpg||/api/work/checkin-photos/b.jpg[[/CHECKIN_PHOTOS]]"); + when(workMapper.selectCheckInExportRows(null, null, null, null, null, null, 5000)) + .thenReturn(List.of(row)); + + List result = workService.exportCheckIns(17L, null, null, null, null, null, null); + + assertEquals(1, result.size()); + assertEquals("已完成现场拜访", result.get(0).getRemark()); + assertEquals(List.of("/api/work/checkin-photos/a.jpg", "/api/work/checkin-photos/b.jpg"), result.get(0).getPhotoUrls()); + } + + @Test + void exportDailyReports_shouldCleanMetadataAndFilterByBizType() { + WorkDailyReportExportDTO opportunityReport = new WorkDailyReportExportDTO(); + opportunityReport.setWorkContent(""" + 1. 2026-04-02 跟进商机“项目A”:项目最新进展:推进中;后续规划:继续推进 + [[WORK_REPORT_LINES]][{"workDate":"2026-04-02","bizType":"opportunity","bizId":1,"bizName":"项目A","content":"项目最新进展:推进中\\n后续规划:继续推进","latestProgress":"推进中","nextPlan":"继续推进","editorText":"@商机 项目A\\n# 项目最新进展:推进中\\n# 后续规划:继续推进"}][[/WORK_REPORT_LINES]] + """); + opportunityReport.setTomorrowPlan(""" + 1. 跟进客户 + [[WORK_PLAN_ITEMS]][{"content":"跟进客户"}][[/WORK_PLAN_ITEMS]] + """); + WorkDailyReportExportDTO salesReport = new WorkDailyReportExportDTO(); + salesReport.setWorkContent(""" + 1. 2026-04-02 跟进销售拓展“张三”:沟通内容:已沟通 + [[WORK_REPORT_LINES]][{"workDate":"2026-04-02","bizType":"sales","bizId":2,"bizName":"张三","content":"沟通内容:已沟通","evaluationContent":"已沟通"}][[/WORK_REPORT_LINES]] + """); + salesReport.setTomorrowPlan(""" + 1. 继续沟通 + [[WORK_PLAN_ITEMS]][{"content":"继续沟通"}][[/WORK_PLAN_ITEMS]] + """); + when(workMapper.selectDailyReportExportRows(null, null, null, null, null, 5000)) + .thenReturn(List.of(opportunityReport, salesReport)); + + List result = workService.exportDailyReports(17L, null, null, null, null, "opportunity", null); + + assertEquals(1, result.size()); + assertEquals("1. 2026-04-02 跟进商机“项目A”:项目最新进展:推进中;后续规划:继续推进", result.get(0).getWorkContent()); + assertEquals(1, result.get(0).getLineItems().size()); + assertEquals("项目A", result.get(0).getLineItems().get(0).getBizName()); + assertEquals(1, result.get(0).getPlanItems().size()); + assertEquals("跟进客户", result.get(0).getPlanItems().get(0).getContent()); + } + private WorkHistoryItemDTO historyItem(Long id, String type, String date, String time, String content) { WorkHistoryItemDTO item = new WorkHistoryItemDTO(); item.setId(id); diff --git a/backend/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker b/backend/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker new file mode 100644 index 00000000..fdbd0b15 --- /dev/null +++ b/backend/src/test/resources/mockito-extensions/org.mockito.plugins.MockMaker @@ -0,0 +1 @@ +mock-maker-subclass diff --git a/frontend/src/lib/auth.ts b/frontend/src/lib/auth.ts index 7e939a14..ae33bb3f 100644 --- a/frontend/src/lib/auth.ts +++ b/frontend/src/lib/auth.ts @@ -180,6 +180,42 @@ export interface WorkHistoryItem { photoUrls?: string[]; } +export interface WorkCheckInExportRow { + checkinDate?: string; + checkinTime?: string; + userName?: string; + deptName?: string; + bizType?: "sales" | "channel" | "opportunity" | string; + bizName?: string; + locationText?: string; + longitude?: number; + latitude?: number; + photoUrls?: string[]; + remark?: string; + status?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface WorkDailyReportExportRow { + reportDate?: string; + submitTime?: string; + userName?: string; + deptName?: string; + sourceType?: string; + status?: string; + workContent?: string; + tomorrowPlan?: string; + score?: number; + comment?: string; + reviewerName?: string; + reviewedAt?: string; + createdAt?: string; + updatedAt?: string; + lineItems?: WorkReportLineItem[]; + planItems?: WorkTomorrowPlanItem[]; +} + export interface WorkOverview { todayCheckIn?: WorkCheckIn; todayReport?: WorkDailyReport; @@ -194,6 +230,15 @@ export interface WorkHistoryPage { size?: number; } +export interface WorkExportQuery { + startDate?: string; + endDate?: string; + keyword?: string; + deptName?: string; + bizType?: string; + status?: string; +} + export interface CreateWorkCheckInPayload { locationText: string; remark?: string; @@ -836,6 +881,26 @@ export async function getWorkHistory(type: "checkin" | "report", page = 1, size return request(`/api/work/history?${params.toString()}`, undefined, true); } +function buildWorkExportQuery(params?: WorkExportQuery) { + const searchParams = new URLSearchParams(); + Object.entries(params ?? {}).forEach(([key, value]) => { + const normalized = value?.trim(); + if (normalized) { + searchParams.set(key, normalized); + } + }); + const query = searchParams.toString(); + return query ? `?${query}` : ""; +} + +export async function getWorkCheckInExportData(params?: WorkExportQuery) { + return request(`/api/work/checkins/export-data${buildWorkExportQuery(params)}`, undefined, true); +} + +export async function getWorkDailyReportExportData(params?: WorkExportQuery) { + return request(`/api/work/daily-reports/export-data${buildWorkExportQuery(params)}`, undefined, true); +} + export async function reverseWorkGeocode(latitude: number, longitude: number) { return request(`/api/work/reverse-geocode?lat=${latitude}&lon=${longitude}`, undefined, true); } diff --git a/frontend/src/pages/Expansion.tsx b/frontend/src/pages/Expansion.tsx index e7bb2a5a..e78bebdf 100644 --- a/frontend/src/pages/Expansion.tsx +++ b/frontend/src/pages/Expansion.tsx @@ -29,6 +29,20 @@ import { cn } from "@/lib/utils"; type ExpansionItem = SalesExpansionItem | ChannelExpansionItem; type ExpansionTab = "sales" | "channel"; +type ExpansionExportFilters = { + keyword?: string; + intent?: string; + officeName?: string; + industry?: string; + employmentStatus?: string; + province?: string; + certificationLevel?: string; + channelIndustry?: string; + channelAttribute?: string; + establishedStartDate?: string; + establishedEndDate?: string; + hasRelatedProject?: string; +}; type SalesCreateField = | "employeeNo" | "officeName" @@ -137,6 +151,131 @@ function normalizeExportText(value?: string | number | boolean | null) { return normalized; } +function normalizeExportFilterText(value?: string | number | boolean | null) { + return normalizeExportText(value).toLowerCase(); +} + +function matchesExportKeyword(value: string, keyword?: string) { + const normalizedKeyword = normalizeExportFilterText(keyword); + return !normalizedKeyword || value.toLowerCase().includes(normalizedKeyword); +} + +function matchesTextFilter(value: string | undefined, filterValue?: string) { + const normalizedFilter = normalizeExportFilterText(filterValue); + if (!normalizedFilter) { + return true; + } + return normalizeExportFilterText(value).includes(normalizedFilter); +} + +function matchesDateRange(value?: string, startDate?: string, endDate?: string) { + const normalizedValue = normalizeExportText(value).slice(0, 10); + if (!normalizedValue) { + return !startDate && !endDate; + } + if (startDate && normalizedValue < startDate) { + return false; + } + if (endDate && normalizedValue > endDate) { + return false; + } + return true; +} + +function hasRelatedProjects(projects?: Array<{ amount?: number }>) { + return Boolean(projects?.length); +} + +function matchesRelatedProjectFilter(projects: Array<{ amount?: number }> | undefined, filterValue?: string) { + if (filterValue === "yes") { + return hasRelatedProjects(projects); + } + if (filterValue === "no") { + return !hasRelatedProjects(projects); + } + return true; +} + +function matchesSalesExportFilters(item: SalesExpansionItem, filters: ExpansionExportFilters) { + const keywordText = [ + item.employeeNo, + item.name, + item.owner, + item.phone, + item.officeName, + item.dept, + item.title, + item.industry, + item.intent, + item.relatedProjects?.map((project) => `${project.opportunityCode ?? ""} ${project.opportunityName ?? ""}`).join(" "), + item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.evaluationContent ?? ""} ${followUp.nextPlan ?? ""}`).join(" "), + ].map(normalizeExportText).filter(Boolean).join(" "); + + if (!matchesExportKeyword(keywordText, filters.keyword)) { + return false; + } + if (!matchesTextFilter(item.intent || item.intentLevel, filters.intent)) { + return false; + } + if (!matchesTextFilter(item.officeName, filters.officeName)) { + return false; + } + if (!matchesTextFilter(item.industry, filters.industry)) { + return false; + } + if (filters.employmentStatus === "active" && item.active !== true && item.employmentStatus !== "active") { + return false; + } + if (filters.employmentStatus === "inactive" && item.active !== false && item.employmentStatus !== "inactive") { + return false; + } + return matchesRelatedProjectFilter(item.relatedProjects, filters.hasRelatedProject); +} + +function matchesChannelExportFilters(item: ChannelExpansionItem, filters: ExpansionExportFilters) { + const keywordText = [ + item.channelCode, + item.name, + item.owner, + item.province, + item.city, + item.officeAddress, + item.certificationLevel, + item.channelIndustry, + item.channelAttribute, + item.internalAttribute, + item.intent, + item.primaryContactName, + item.primaryContactMobile, + item.contacts?.map((contact) => `${contact.name ?? ""} ${contact.mobile ?? ""} ${contact.title ?? ""}`).join(" "), + item.relatedProjects?.map((project) => `${project.opportunityCode ?? ""} ${project.opportunityName ?? ""}`).join(" "), + item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.evaluationContent ?? ""} ${followUp.nextPlan ?? ""}`).join(" "), + ].map(normalizeExportText).filter(Boolean).join(" "); + + if (!matchesExportKeyword(keywordText, filters.keyword)) { + return false; + } + if (!matchesTextFilter(item.intent || item.intentLevel, filters.intent)) { + return false; + } + if (!matchesTextFilter(item.province, filters.province)) { + return false; + } + if (!matchesTextFilter(item.certificationLevel, filters.certificationLevel)) { + return false; + } + if (!matchesTextFilter(item.channelIndustry, filters.channelIndustry)) { + return false; + } + if (!matchesTextFilter(item.channelAttribute, filters.channelAttribute)) { + return false; + } + if (!matchesDateRange(item.establishedDate, filters.establishedStartDate, filters.establishedEndDate)) { + return false; + } + return matchesRelatedProjectFilter(item.relatedProjects, filters.hasRelatedProject); +} + function formatExportBoolean(value?: boolean, trueLabel = "是", falseLabel = "否") { if (value === null || value === undefined) { return ""; @@ -568,6 +707,248 @@ function RequiredMark() { return *; } +function ExpansionExportFilterModal({ + activeTab, + initialFilters, + exporting, + exportError, + officeOptions, + industryOptions, + provinceOptions, + certificationLevelOptions, + channelAttributeOptions, + onClose, + onConfirm, +}: { + activeTab: ExpansionTab; + initialFilters: ExpansionExportFilters; + exporting: boolean; + exportError: string; + officeOptions: ExpansionDictOption[]; + industryOptions: ExpansionDictOption[]; + provinceOptions: ExpansionDictOption[]; + certificationLevelOptions: ExpansionDictOption[]; + channelAttributeOptions: ExpansionDictOption[]; + onClose: () => void; + onConfirm: (filters: ExpansionExportFilters) => void; +}) { + const [draftFilters, setDraftFilters] = useState(initialFilters); + const isSalesTab = activeTab === "sales"; + const hasDraftFilters = Boolean( + draftFilters.keyword + || draftFilters.intent + || draftFilters.officeName + || draftFilters.industry + || draftFilters.employmentStatus + || draftFilters.province + || draftFilters.certificationLevel + || draftFilters.channelIndustry + || draftFilters.channelAttribute + || draftFilters.establishedStartDate + || draftFilters.establishedEndDate + || draftFilters.hasRelatedProject, + ); + const handleFilterChange = (key: keyof ExpansionExportFilters, value: string) => { + setDraftFilters((current) => ({ ...current, [key]: value })); + }; + const renderOption = (option: ExpansionDictOption) => { + const value = option.label || option.value || ""; + return value ? {value} : null; + }; + const toSearchableOptions = (options: ExpansionDictOption[], allLabel: string) => [ + { value: "", label: allLabel }, + ...options + .map((option) => { + const value = option.label || option.value || ""; + return value ? { value, label: value } : null; + }) + .filter((option): option is { value: string; label: string } => Boolean(option)), + ]; + + return ( + + setDraftFilters({})} + disabled={!hasDraftFilters} + className="crm-btn crm-btn-secondary disabled:cursor-not-allowed disabled:opacity-60" + > + 清空条件 + + onConfirm(draftFilters)} + disabled={exporting} + className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60" + > + {exporting ? "导出中..." : "确认导出"} + + + )} + > + + + 关键词 + handleFilterChange("keyword", event.target.value)} + placeholder={isSalesTab ? "搜索姓名、工号、电话、行业、项目" : "搜索渠道、联系人、地区、项目"} + className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" + /> + + + 合作意向 + handleFilterChange("intent", value)} + /> + + + 关联项目 + handleFilterChange("hasRelatedProject", value)} + /> + + + {isSalesTab ? ( + <> + + 代表处 / 办事处 + handleFilterChange("officeName", value)} + /> + + + 所属行业 + handleFilterChange("industry", value)} + /> + + + 销售是否在职 + handleFilterChange("employmentStatus", value)} + /> + + > + ) : ( + <> + + 省份 + handleFilterChange("province", value)} + /> + + + 认证级别 + handleFilterChange("certificationLevel", value)} + /> + + + 聚焦行业 + handleFilterChange("channelIndustry", value)} + /> + + + 渠道属性 + handleFilterChange("channelAttribute", value)} + /> + + + 建立联系开始日期 + handleFilterChange("establishedStartDate", event.target.value)} + className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" + /> + + + 建立联系结束日期 + handleFilterChange("establishedEndDate", event.target.value)} + className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" + /> + + > + )} + + {exportError ? {exportError} : null} + + ); +} + export default function Expansion() { const currentUserId = getStoredCurrentUserId(); const location = useLocation(); @@ -595,6 +976,8 @@ export default function Expansion() { const [editOpen, setEditOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [exporting, setExporting] = useState(false); + const [exportFilterOpen, setExportFilterOpen] = useState(false); + const [exportFilters, setExportFilters] = useState({}); const [salesDuplicateChecking, setSalesDuplicateChecking] = useState(false); const [channelDuplicateChecking, setChannelDuplicateChecking] = useState(false); const [createError, setCreateError] = useState(""); @@ -616,7 +999,7 @@ export default function Expansion() { const [channelForm, setChannelForm] = useState(defaultChannelForm); const [editSalesForm, setEditSalesForm] = useState(defaultSalesForm); const [editChannelForm, setEditChannelForm] = useState(defaultChannelForm); - const hasForegroundModal = createOpen || editOpen; + const hasForegroundModal = createOpen || editOpen || exportFilterOpen; const loadMeta = useCallback(async () => { const data = await getExpansionMeta(); @@ -1170,27 +1553,30 @@ export default function Expansion() { setExportError(""); }; - const handleExport = async () => { + const handleExport = async (filters: ExpansionExportFilters) => { if (exporting) { return; } const isSalesTab = activeTab === "sales"; - const items = isSalesTab ? salesData : channelData; - if (items.length <= 0) { - setExportError(`当前${isSalesTab ? "销售人员拓展" : "渠道拓展"}暂无可导出数据`); - return; - } - setExporting(true); setExportError(""); + setExportFilters(filters); try { + const overview = await getExpansionOverview(""); + const exportSalesItems = dedupeExpansionItemsById(overview.salesItems ?? []).filter((item) => matchesSalesExportFilters(item, filters)); + const exportChannelItems = dedupeExpansionItemsById(overview.channelItems ?? []).filter((item) => matchesChannelExportFilters(item, filters)); + const exportItems = isSalesTab ? exportSalesItems : exportChannelItems; + if (exportItems.length <= 0) { + throw new Error(`当前筛选条件下暂无可导出的${isSalesTab ? "销售人员拓展" : "渠道拓展"}数据`); + } + const ExcelJS = await import("exceljs"); const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet(isSalesTab ? "销售人员拓展" : "渠道拓展"); - const headers = isSalesTab ? buildSalesExportHeaders(salesData) : buildChannelExportHeaders(channelData); - const rows = isSalesTab ? buildSalesExportData(salesData) : buildChannelExportData(channelData); + const headers = isSalesTab ? buildSalesExportHeaders(exportSalesItems) : buildChannelExportHeaders(exportChannelItems); + const rows = isSalesTab ? buildSalesExportData(exportSalesItems) : buildChannelExportData(exportChannelItems); worksheet.addRow(headers); rows.forEach((row) => { @@ -1242,6 +1628,7 @@ export default function Expansion() { const buffer = await workbook.xlsx.writeBuffer(); const filename = `${isSalesTab ? "销售人员拓展" : "渠道拓展"}_${formatExportFilenameTime()}.xlsx`; downloadExcelFile(filename, buffer); + setExportFilterOpen(false); } catch (error) { setExportError(error instanceof Error ? error.message : "导出失败,请稍后重试"); } finally { @@ -1626,7 +2013,10 @@ export default function Expansion() { { + setExportError(""); + setExportFilterOpen(true); + }} disabled={exporting} className={cn("crm-btn-sm crm-btn-secondary flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-60", disableMobileMotion ? "active:scale-100" : "active:scale-95")} > @@ -1803,6 +2193,22 @@ export default function Expansion() { + {exportFilterOpen && ( + setExportFilterOpen(false)} + onConfirm={(filters) => void handleExport(filters)} + /> + )} + {createOpen && ( , filterValue?: string) { + const normalizedFilter = normalizeOpportunityExportFilterText(filterValue); + if (!normalizedFilter) { + return true; + } + return values.some((value) => normalizeOpportunityExportFilterText(value).includes(normalizedFilter)); +} + +function matchesOpportunityDateRange(value?: string, startDate?: string, endDate?: string) { + const normalizedValue = normalizeOpportunityExportText(value).slice(0, 10); + if (!normalizedValue) { + return !startDate && !endDate; + } + if (startDate && normalizedValue < startDate) { + return false; + } + if (endDate && normalizedValue > endDate) { + return false; + } + return true; +} + +function matchesOpportunityRelationFilter(hasRelation: boolean, filterValue?: string) { + if (filterValue === "yes") { + return hasRelation; + } + if (filterValue === "no") { + return !hasRelation; + } + return true; +} + +function matchesOpportunityExportFilters(item: OpportunityItem, filters: OpportunityExportFilters) { + const keywordText = [ + item.code, + item.name, + item.client, + item.owner, + item.projectLocation, + item.operatorName, + item.stage, + item.type, + item.salesExpansionName, + item.channelExpansionName, + item.preSalesName, + item.competitorName, + item.latestProgress, + item.nextPlan, + item.notes, + item.followUps?.map((followUp) => `${followUp.content ?? ""} ${followUp.latestProgress ?? ""} ${followUp.nextAction ?? ""}`).join(" "), + ].map(normalizeOpportunityExportText).filter(Boolean).join(" "); + + if (!matchesOpportunityExportKeyword(keywordText, filters.keyword)) { + return false; + } + if (!matchesOpportunityDateRange(item.date, filters.expectedStartDate, filters.expectedEndDate)) { + return false; + } + if (!matchesOpportunityTextFilter([item.stageCode, item.stage], filters.stage)) { + return false; + } + if (filters.confidence && normalizeConfidenceGrade(item.confidence) !== filters.confidence) { + return false; + } + if (!matchesOpportunityTextFilter([item.projectLocation], filters.projectLocation)) { + return false; + } + if (!matchesOpportunityTextFilter([item.type], filters.opportunityType)) { + return false; + } + if (!matchesOpportunityTextFilter([item.operatorCode, item.operatorName], filters.operatorName)) { + return false; + } + if (!matchesOpportunityRelationFilter(Boolean(item.salesExpansionId || item.salesExpansionName), filters.hasSalesExpansion)) { + return false; + } + return matchesOpportunityRelationFilter(Boolean(item.channelExpansionId || item.channelExpansionName), filters.hasChannelExpansion); +} + function toFormFromItem(item: OpportunityItem): CreateOpportunityPayload { return { opportunityName: item.name || "", @@ -447,6 +547,211 @@ function DetailItem({ ); } +function OpportunityExportFilterModal({ + initialFilters, + exporting, + exportError, + archiveTab, + stageOptions, + projectLocationOptions, + opportunityTypeOptions, + operatorOptions, + onClose, + onConfirm, +}: { + initialFilters: OpportunityExportFilters; + exporting: boolean; + exportError: string; + archiveTab: OpportunityArchiveTab; + stageOptions: OpportunityDictOption[]; + projectLocationOptions: OpportunityDictOption[]; + opportunityTypeOptions: OpportunityDictOption[]; + operatorOptions: OpportunityDictOption[]; + onClose: () => void; + onConfirm: (filters: OpportunityExportFilters) => void; +}) { + const [draftFilters, setDraftFilters] = useState(initialFilters); + const hasDraftFilters = Boolean( + draftFilters.keyword + || draftFilters.expectedStartDate + || draftFilters.expectedEndDate + || draftFilters.stage + || draftFilters.confidence + || draftFilters.projectLocation + || draftFilters.opportunityType + || draftFilters.operatorName + || draftFilters.hasSalesExpansion + || draftFilters.hasChannelExpansion, + ); + const handleFilterChange = (key: keyof OpportunityExportFilters, value: string) => { + setDraftFilters((current) => ({ ...current, [key]: value })); + }; + const renderOption = (option: OpportunityDictOption) => { + const value = option.value || option.label || ""; + const label = option.label || option.value || ""; + return value ? {label} : null; + }; + const toSearchableOptions = (options: OpportunityDictOption[], allLabel: string) => [ + { value: "", label: allLabel }, + ...options + .map((option) => { + const value = option.value || option.label || ""; + const label = option.label || option.value || ""; + return value ? { value, label } : null; + }) + .filter((option): option is { value: string; label: string } => Boolean(option)), + ]; + + return ( + + setDraftFilters({})} + disabled={!hasDraftFilters} + className="crm-btn crm-btn-secondary disabled:cursor-not-allowed disabled:opacity-60" + > + 清空条件 + + onConfirm(draftFilters)} + disabled={exporting} + className="crm-btn crm-btn-primary disabled:cursor-not-allowed disabled:opacity-60" + > + {exporting ? "导出中..." : "确认导出"} + + + )} + > + + + 关键词 + handleFilterChange("keyword", event.target.value)} + placeholder="搜索项目编号、项目名称、最终用户、负责人、售前" + className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" + /> + + + 预计下单开始日期 + handleFilterChange("expectedStartDate", event.target.value)} + className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" + /> + + + 预计下单结束日期 + handleFilterChange("expectedEndDate", event.target.value)} + className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" + /> + + + 项目阶段 + handleFilterChange("stage", value)} + /> + + + 项目把握度 + ({ value: option.value, label: option.label })), + ]} + placeholder="全部把握度" + sheetTitle="选择项目把握度" + className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50" + onChange={(value) => handleFilterChange("confidence", value)} + /> + + + 项目地 + handleFilterChange("projectLocation", value)} + /> + + + 建设类型 + handleFilterChange("opportunityType", value)} + /> + + + 运作方 + handleFilterChange("operatorName", value)} + /> + + + 新华三负责人 + handleFilterChange("hasSalesExpansion", value)} + /> + + + 拓展渠道 + handleFilterChange("hasChannelExpansion", value)} + /> + + + {exportError ? {exportError} : null} + + ); +} + type SearchableOption = { value: number | string; label: string; @@ -998,6 +1303,8 @@ export default function Opportunities() { const [submitting, setSubmitting] = useState(false); const [pushingOms, setPushingOms] = useState(false); const [exporting, setExporting] = useState(false); + const [exportFilterOpen, setExportFilterOpen] = useState(false); + const [exportFilters, setExportFilters] = useState({}); const [error, setError] = useState(""); const [exportError, setExportError] = useState(""); const [items, setItems] = useState([]); @@ -1016,7 +1323,7 @@ export default function Opportunities() { const [customCompetitorName, setCustomCompetitorName] = useState(""); const [fieldErrors, setFieldErrors] = useState>>({}); const [detailTab, setDetailTab] = useState<"sales" | "channel" | "followups">("sales"); - const hasForegroundModal = createOpen || editOpen || pushConfirmOpen; + const hasForegroundModal = createOpen || editOpen || pushConfirmOpen || exportFilterOpen; useEffect(() => { let cancelled = false; @@ -1209,20 +1516,24 @@ export default function Opportunities() { return "text-rose-600 dark:text-rose-400 bg-rose-50 dark:bg-rose-500/10 border-rose-200 dark:border-rose-500/20"; }; - const handleExport = async () => { + const handleExport = async (filters: OpportunityExportFilters) => { if (exporting) { return; } - if (visibleItems.length <= 0) { - setExportError(`当前${archiveTab === "active" ? "未归档" : "已归档"}商机暂无可导出数据`); - return; - } - setExporting(true); setExportError(""); + setExportFilters(filters); try { + const overview = await getOpportunityOverview("", "全部"); + const exportItems = (overview.items ?? []) + .filter((item) => (archiveTab === "active" ? !item.archived : Boolean(item.archived))) + .filter((item) => matchesOpportunityExportFilters(item, filters)); + if (exportItems.length <= 0) { + throw new Error(`当前筛选条件下暂无可导出的${archiveTab === "active" ? "未归档" : "已归档"}商机`); + } + const ExcelJS = await import("exceljs"); const workbook = new ExcelJS.Workbook(); const worksheet = workbook.addWorksheet("商机储备"); @@ -1255,7 +1566,7 @@ export default function Opportunities() { worksheet.addRow(headers); - visibleItems.forEach((item) => { + exportItems.forEach((item) => { const relatedSales = item.salesExpansionId ? salesExpansionOptions.find((option) => option.id === item.salesExpansionId) ?? null : null; @@ -1345,6 +1656,7 @@ export default function Opportunities() { const buffer = await workbook.xlsx.writeBuffer(); const filename = `商机储备_${archiveTab === "active" ? "未归档" : "已归档"}_${formatOpportunityExportFilenameTime()}.xlsx`; downloadOpportunityExcelFile(filename, buffer); + setExportFilterOpen(false); } catch (exportErr) { setExportError(exportErr instanceof Error ? exportErr.message : "导出失败,请稍后重试"); } finally { @@ -1571,7 +1883,10 @@ export default function Opportunities() { void handleExport()} + onClick={() => { + setExportError(""); + setExportFilterOpen(true); + }} disabled={exporting} className={cn("crm-btn-sm crm-btn-secondary flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-60", disableMobileMotion ? "active:scale-100" : "active:scale-95")} > @@ -1735,6 +2050,21 @@ export default function Opportunities() { + {exportFilterOpen ? ( + setExportFilterOpen(false)} + onConfirm={(filters) => void handleExport(filters)} + /> + ) : null} + {stageFilterOpen ? ( <> { + worksheet.addRow([ + index + 1, + normalizeWorkExportText(row.checkinDate), + normalizeWorkExportText(row.checkinTime), + normalizeWorkExportText(row.userName), + normalizeWorkExportText(row.deptName), + formatWorkBizType(row.bizType), + normalizeWorkExportText(row.bizName), + normalizeWorkExportText(row.locationText), + row.photoUrls?.map(normalizeWorkExportText).filter(Boolean).join("\n") || "", + normalizeWorkExportText(row.remark), + formatCheckInStatus(row.status), + ]); + }); + styleWorkExportWorksheet(worksheet, headers, ["现场照片", "备注", "打卡地点"]); + const buffer = await workbook.xlsx.writeBuffer(); + await downloadWorkExcelFile(`打卡记录_${formatWorkExportFilenameTime()}.xlsx`, buffer); +} + +async function exportDailyReportRowsToExcel(rows: WorkDailyReportExportRow[]) { + const ExcelJS = await import("exceljs"); + const workbook = new ExcelJS.Workbook(); + const summaryWorksheet = workbook.addWorksheet("日报汇总"); + const detailWorksheet = workbook.addWorksheet("日报明细"); + const summaryHeaders = [ + "序号", + "日报日期", + "提交时间", + "提交人", + "所属部门", + "今日工作内容", + "明日工作计划", + "今日工作条数", + "明日计划条数", + ]; + const detailHeaders = [ + "序号", + "日报日期", + "提交时间", + "提交人", + "明细序号", + "工作日期", + "跟进对象类型", + "跟进对象名称", + "工作内容/项目进展", + "后续规划", + ]; + + summaryWorksheet.addRow(summaryHeaders); + detailWorksheet.addRow(detailHeaders); + rows.forEach((row, index) => { + summaryWorksheet.addRow([ + index + 1, + normalizeWorkExportText(row.reportDate), + normalizeWorkExportText(row.submitTime), + normalizeWorkExportText(row.userName), + normalizeWorkExportText(row.deptName), + normalizeWorkExportText(row.workContent), + normalizeWorkExportText(row.tomorrowPlan), + row.lineItems?.length ?? 0, + row.planItems?.length ?? 0, + ]); + + const lineItems = row.lineItems?.length ? row.lineItems : [undefined]; + lineItems.forEach((lineItem, lineIndex) => { + detailWorksheet.addRow([ + detailWorksheet.rowCount, + normalizeWorkExportText(row.reportDate), + normalizeWorkExportText(row.submitTime), + normalizeWorkExportText(row.userName), + lineIndex + 1, + normalizeWorkExportText(lineItem?.workDate), + formatWorkBizType(lineItem?.bizType), + normalizeWorkExportText(lineItem?.bizName), + normalizeWorkExportDetailContent(lineItem), + normalizeWorkExportText(lineItem?.nextPlan || extractContentByLabel(lineItem?.content, "后续规划")), + ]); + }); + }); + + styleWorkExportWorksheet(summaryWorksheet, summaryHeaders, ["今日工作内容", "明日工作计划"]); + styleWorkExportWorksheet(detailWorksheet, detailHeaders, ["工作内容/项目进展", "后续规划"]); + const buffer = await workbook.xlsx.writeBuffer(); + await downloadWorkExcelFile(`销售日报_${formatWorkExportFilenameTime()}.xlsx`, buffer); +} + function getReportStatus(status?: string) { if (!status) { return "待提交"; @@ -131,6 +243,155 @@ function getReportStatus(status?: string) { return status === "reviewed" || status === "已点评" ? "已点评" : "已提交"; } +function normalizeWorkExportText(value?: string | number | null) { + if (value === null || value === undefined) { + return ""; + } + const normalized = String(value).replace(/\r\n/g, "\n").trim(); + return !normalized || normalized === "无" ? "" : normalized; +} + +function normalizeWorkExportDetailContent(lineItem?: WorkReportLineItem) { + return normalizeWorkExportText( + lineItem?.latestProgress + || extractContentByLabel(lineItem?.content, "项目最新进展") + || lineItem?.evaluationContent + || extractContentByLabel(lineItem?.content, "沟通内容") + || lineItem?.editorText + || lineItem?.content, + ); +} + +function formatWorkBizType(value?: string) { + if (value === "sales") { + return "销售人员拓展"; + } + if (value === "channel") { + return "渠道拓展"; + } + if (value === "opportunity") { + return "商机"; + } + return normalizeWorkExportText(value); +} + +function formatCheckInStatus(value?: string) { + if (value === "normal") { + return "正常"; + } + if (value === "abnormal") { + return "异常"; + } + if (value === "reissue") { + return "补卡"; + } + if (value === "updated") { + return "已更新"; + } + return normalizeWorkExportText(value); +} + +function formatWorkExportFilenameTime(date = new Date()) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hours = String(date.getHours()).padStart(2, "0"); + const minutes = String(date.getMinutes()).padStart(2, "0"); + return `${year}${month}${day}_${hours}${minutes}`; +} + +async function downloadWorkExcelFile(filename: string, content: BlobPart) { + const blob = new Blob([content], { type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }); + if (shouldUseDataUrlDownloadFallback()) { + const dataUrl = await readBlobAsDataUrl(blob); + triggerWorkExcelDownload(filename, dataUrl); + return; + } + + const objectUrl = window.URL.createObjectURL(blob); + triggerWorkExcelDownload(filename, objectUrl); + window.URL.revokeObjectURL(objectUrl); +} + +function triggerWorkExcelDownload(filename: string, url: string) { + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +} + +function shouldUseDataUrlDownloadFallback() { + if (typeof window === "undefined") { + return false; + } + const hostname = window.location.hostname; + const localHosts = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); + return window.location.protocol === "http:" && !localHosts.has(hostname); +} + +function readBlobAsDataUrl(blob: Blob) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === "string") { + resolve(reader.result); + } else { + reject(new Error("导出文件生成失败,请稍后重试")); + } + }; + reader.onerror = () => reject(reader.error ?? new Error("导出文件生成失败,请稍后重试")); + reader.readAsDataURL(blob); + }); +} + +function styleWorkExportWorksheet(worksheet: any, headers: string[], wrapHeaders: string[]) { + worksheet.views = [{ state: "frozen", ySplit: 1 }]; + worksheet.getRow(1).height = 24; + worksheet.getRow(1).font = { bold: true }; + worksheet.getRow(1).alignment = { vertical: "middle", horizontal: "center" }; + + headers.forEach((header, index) => { + const column = worksheet.getColumn(index + 1); + if (wrapHeaders.includes(header)) { + column.width = 32; + } else if (header.includes("内容") || header.includes("计划") || header.includes("地点")) { + column.width = 24; + } else if (header.includes("时间") || header.includes("日期")) { + column.width = 18; + } else { + column.width = 16; + } + }); + + worksheet.eachRow((row: any, rowNumber: number) => { + row.eachCell((cell: any, columnNumber: number) => { + cell.border = { + top: { style: "thin", color: { argb: "FFE2E8F0" } }, + left: { style: "thin", color: { argb: "FFE2E8F0" } }, + bottom: { style: "thin", color: { argb: "FFE2E8F0" } }, + right: { style: "thin", color: { argb: "FFE2E8F0" } }, + }; + cell.alignment = { + vertical: "top", + horizontal: rowNumber === 1 ? "center" : "left", + wrapText: wrapHeaders.includes(headers[columnNumber - 1]), + }; + }); + if (rowNumber > 1) { + const maxLines = headers.reduce((lineCount, header, index) => { + if (!wrapHeaders.includes(header)) { + return lineCount; + } + const text = normalizeWorkExportText(row.getCell(index + 1).value as string | number | null | undefined); + return Math.max(lineCount, text ? text.split("\n").length : 1); + }, 1); + row.height = Math.max(22, maxLines * 16); + } + }); +} + export default function Work() { const routerLocation = useLocation(); const isMobileViewport = useIsMobileViewport(); @@ -163,6 +424,10 @@ export default function Work() { const [historyLoadingMore, setHistoryLoadingMore] = useState(false); const [historyHasMore, setHistoryHasMore] = useState(false); const [historyPage, setHistoryPage] = useState(1); + const [exportingHistory, setExportingHistory] = useState(false); + const [historyExportError, setHistoryExportError] = useState(""); + const [historyExportFilters, setHistoryExportFilters] = useState({}); + const [historyExportModalOpen, setHistoryExportModalOpen] = useState(false); const [showHistoryBackToTop, setShowHistoryBackToTop] = useState(false); const [historyPresenterFilter, setHistoryPresenterFilter] = useState("all"); const [historyPresenterKeyword, setHistoryPresenterKeyword] = useState(""); @@ -864,6 +1129,37 @@ export default function Work() { } }; + const handleHistoryExport = async (filters: WorkExportQuery) => { + if (exportingHistory) { + return; + } + + setExportingHistory(true); + setHistoryExportError(""); + setHistoryExportFilters(filters); + + try { + if (historySection === "checkin") { + const rows = await getWorkCheckInExportData(filters); + if (!rows.length) { + throw new Error("当前筛选条件下暂无可导出数据"); + } + await exportCheckInRowsToExcel(rows); + } else { + const rows = await getWorkDailyReportExportData(filters); + if (!rows.length) { + throw new Error("当前筛选条件下暂无可导出数据"); + } + await exportDailyReportRowsToExcel(rows); + } + setHistoryExportModalOpen(false); + } catch (error) { + setHistoryExportError(error instanceof Error ? error.message : "导出失败,请稍后重试"); + } finally { + setExportingHistory(false); + } + }; + if (!activeWorkSection || !activeWorkMeta) { return ; } @@ -954,9 +1250,31 @@ export default function Work() { : "block lg:col-span-12 xl:col-span-12", )} > - - - {showEntryPanel ? : null} + + + + {!isMobileViewport && historyExportError ? ( + {historyExportError} + ) : null} + + + {!isMobileViewport ? ( + { + setHistoryExportError(""); + setHistoryExportModalOpen(true); + }} + disabled={exportingHistory} + className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-slate-200 bg-white text-slate-500 shadow-sm transition-colors hover:border-violet-200 hover:bg-violet-50 hover:text-violet-600 disabled:cursor-not-allowed disabled:border-slate-100 disabled:bg-slate-50 disabled:text-slate-300 dark:border-slate-700 dark:bg-slate-900/70 dark:text-slate-400 dark:hover:border-violet-500/40 dark:hover:bg-slate-800 dark:hover:text-violet-300 dark:disabled:border-slate-800 dark:disabled:bg-slate-900/50 dark:disabled:text-slate-700" + aria-label={exportingHistory ? "导出中" : "导出 Excel"} + title={exportingHistory ? "导出中" : "导出 Excel"} + > + + + ) : null} + {showEntryPanel ? : null} + 0 ? ( setHistoryPresenterPickerOpen(true)} - onPresenterFilterChange={(value) => { - setHistoryPresenterFilter(value); - setHistoryPresenterPickerOpen(false); - }} /> ) : null} @@ -1176,6 +1487,17 @@ export default function Work() { /> ) : null} + {historyExportModalOpen && !isMobileViewport ? ( + setHistoryExportModalOpen(false)} + onConfirm={(filters) => void handleHistoryExport(filters)} + /> + ) : null} + {previewPhoto ? ( ; - filteredCount: number; - totalCount: number; onOpenPresenterPicker: () => void; - onPresenterFilterChange: (value: string) => void; }) { const currentPresenterLabel = presenterFilter === "all" ? "全部人员" @@ -1646,6 +1960,144 @@ function HistoryToolbar({ ); } +function HistoryExportFilterModal({ + section, + initialFilters, + exporting, + exportError, + onClose, + onConfirm, +}: { + section: WorkSection; + initialFilters: WorkExportQuery; + exporting: boolean; + exportError: string; + onClose: () => void; + onConfirm: (filters: WorkExportQuery) => void; +}) { + const [draftFilters, setDraftFilters] = useState({ ...initialFilters, deptName: undefined, status: undefined }); + const hasDraftFilters = Boolean( + draftFilters.keyword + || draftFilters.startDate + || draftFilters.endDate + || draftFilters.bizType, + ); + const handleFilterChange = (key: keyof WorkExportQuery, value: string) => { + setDraftFilters((current) => ({ + ...current, + [key]: value, + })); + }; + + useEffect(() => { + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, []); + + return ( + + + + + + + 导出{getHistoryLabelBySection(section)}记录 + 选择条件后导出 Excel;不填条件则导出全部可见记录。 + + + + + + + + + + + 人员/对象/内容关键词 + handleFilterChange("keyword", event.target.value)} + placeholder="输入人员、对象、地点或内容关键词" + className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition-colors placeholder:text-slate-400 focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100 dark:placeholder:text-slate-500" + /> + + + 开始日期 + handleFilterChange("startDate", event.target.value)} + className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100" + aria-label="导出开始日期" + /> + + + 结束日期 + handleFilterChange("endDate", event.target.value)} + className="h-10 w-full rounded-xl border border-slate-200 bg-slate-50 px-3 text-sm text-slate-700 outline-none transition-colors focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-700 dark:bg-slate-800/70 dark:text-slate-100" + aria-label="导出结束日期" + /> + + + 关联对象类型 + handleFilterChange("bizType", value)} + /> + + + {exportError ? {exportError} : null} + + + + setDraftFilters({})} + disabled={!hasDraftFilters} + className="h-10 rounded-xl border border-slate-200 bg-white px-3 text-sm font-medium text-slate-600 transition-colors hover:border-violet-200 hover:text-violet-600 disabled:cursor-not-allowed disabled:text-slate-300 dark:border-slate-700 dark:bg-slate-900/60 dark:text-slate-300 dark:hover:border-violet-500/40 dark:hover:text-violet-200 dark:disabled:text-slate-600" + > + 清空条件 + + onConfirm(draftFilters)} + disabled={exporting} + className="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-violet-600 px-4 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-violet-700 disabled:cursor-not-allowed disabled:bg-violet-300 dark:bg-violet-500 dark:hover:bg-violet-400 dark:disabled:bg-violet-500/40" + > + + {exporting ? "导出中..." : "确认导出"} + + + + + ); +} + function HistoryPresenterPickerModal({ section, presenterFilter,
{historyExportError}
选择条件后导出 Excel;不填条件则导出全部可见记录。
{exportError}