From d917806dc266e7a2a07bf9851499439edc48b079 Mon Sep 17 00:00:00 2001 From: kangwenjing <1138819403@qq.com> Date: Fri, 26 Jun 2026 16:11:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=A5=E6=8A=A5=E5=8A=9F=E8=83=BD=E4=B8=8E?= =?UTF-8?q?=E6=97=A5=E5=BF=97=E6=8F=90=E9=86=92=E5=8A=9F=E8=83=BD=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReportReminderSchemaInitializer.java | 16 + .../crm/controller/DashboardController.java | 9 + .../unis/crm/controller/WorkController.java | 34 + .../crm/dto/dashboard/DashboardHomeDTO.java | 11 + .../dto/dashboard/DashboardMessageDTO.java | 124 +++ .../opportunity/OpportunityFollowUpDTO.java | 13 + .../dto/reminder/ReportReminderConfigDTO.java | 9 + .../reminder/ReportSubmitNotifyRuleDTO.java | 53 ++ .../work/CreateWorkDailyReportRequest.java | 13 +- .../unis/crm/dto/work/WorkDailyReportDTO.java | 9 + .../dto/work/WorkDailyReportExportDTO.java | 9 + .../unis/crm/dto/work/WorkHistoryItemDTO.java | 18 + .../crm/dto/work/WorkReportAttachmentDTO.java | 41 + .../crm/dto/work/WorkReportLineItemDTO.java | 21 + .../dto/work/WorkReportLineItemRequest.java | 25 +- .../crm/dto/work/WorkReportToUserDTO.java | 32 + .../dto/work/WorkTomorrowPlanItemRequest.java | 2 - .../com/unis/crm/mapper/DashboardMapper.java | 5 + .../unis/crm/mapper/OpportunityMapper.java | 21 +- .../java/com/unis/crm/mapper/WorkMapper.java | 21 + .../unis/crm/service/DashboardService.java | 2 + .../crm/service/ReportReminderService.java | 137 ++- .../com/unis/crm/service/WorkService.java | 11 + .../service/impl/DashboardServiceImpl.java | 38 + .../service/impl/OpportunityServiceImpl.java | 168 +++- .../crm/service/impl/WorkServiceImpl.java | 400 +++++++- backend/src/main/resources/application.yml | 2 +- .../mapper/dashboard/DashboardMapper.xml | 47 + .../mapper/opportunity/OpportunityMapper.xml | 29 + .../main/resources/mapper/work/WorkMapper.xml | 119 +++ .../impl/OpportunityServiceImplTest.java | 51 ++ .../crm/service/impl/WorkServiceImplTest.java | 152 +++- .../src/components/AttachmentPreviewModal.tsx | 185 ++++ frontend/src/lib/auth.ts | 89 ++ frontend/src/pages/Dashboard.tsx | 390 +++++++- frontend/src/pages/Opportunities.tsx | 43 +- frontend/src/pages/Work.tsx | 855 ++++++++++++++++-- frontend1/dist/assets/index-9PT8fQOk.css | 1 - frontend1/dist/index.html | 2 +- .../src/features/report-reminder/constants.ts | 1 + .../features/report-reminder/locales/en-US.ts | 16 +- .../features/report-reminder/locales/zh-CN.ts | 16 +- .../pages/report-reminder-settings/index.less | 34 + .../pages/report-reminder-settings/index.tsx | 205 ++++- .../src/features/report-reminder/types.ts | 9 + ..._reminder_and_work_report_upgrade_pg17.sql | 69 ++ sql/README.md | 10 + sql/init_full_pg17.sql | 29 + sql/init_report_reminder_pg17.sql | 2 + 49 files changed, 3420 insertions(+), 178 deletions(-) create mode 100644 backend/src/main/java/com/unis/crm/dto/dashboard/DashboardMessageDTO.java create mode 100644 backend/src/main/java/com/unis/crm/dto/reminder/ReportSubmitNotifyRuleDTO.java create mode 100644 backend/src/main/java/com/unis/crm/dto/work/WorkReportAttachmentDTO.java create mode 100644 backend/src/main/java/com/unis/crm/dto/work/WorkReportToUserDTO.java create mode 100644 frontend/src/components/AttachmentPreviewModal.tsx delete mode 100644 frontend1/dist/assets/index-9PT8fQOk.css create mode 100644 sql/20260624_report_reminder_and_work_report_upgrade_pg17.sql diff --git a/backend/src/main/java/com/unis/crm/common/ReportReminderSchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/ReportReminderSchemaInitializer.java index 5f89813b..37b97614 100644 --- a/backend/src/main/java/com/unis/crm/common/ReportReminderSchemaInitializer.java +++ b/backend/src/main/java/com/unis/crm/common/ReportReminderSchemaInitializer.java @@ -56,6 +56,7 @@ public class ReportReminderSchemaInitializer implements ApplicationRunner { submit_notify_target_type varchar(20) not null default 'USERS', submit_notify_user_ids text, submit_notify_role_ids text, + submit_notify_rules text, submit_notify_template text, exclude_submitter boolean not null default true, created_at timestamptz not null default now(), @@ -65,6 +66,7 @@ public class ReportReminderSchemaInitializer implements ApplicationRunner { """); statement.execute("alter table if exists report_reminder_config add column if not exists missing_report_template text"); statement.execute("alter table if exists report_reminder_config add column if not exists submit_notify_template text"); + statement.execute("alter table if exists report_reminder_config add column if not exists submit_notify_rules text"); statement.execute(""" create table if not exists wecom_user_mapping ( id bigint generated by default as identity primary key, @@ -102,6 +104,20 @@ public class ReportReminderSchemaInitializer implements ApplicationRunner { statement.execute("create index if not exists idx_wecom_message_log_tenant_sent_at on wecom_message_log (tenant_id, sent_at desc)"); statement.execute("create index if not exists idx_wecom_message_log_receiver_day on wecom_message_log (tenant_id, receiver_user_id, sent_at desc)"); statement.execute("create index if not exists idx_wecom_user_mapping_tenant_status on wecom_user_mapping (tenant_id, sync_status)"); + statement.execute(""" + do $$ + declare + v_sequence_name text; + begin + select pg_get_serial_sequence('wecom_message_log', 'id') into v_sequence_name; + if v_sequence_name is not null then + execute format( + 'select setval(%L, coalesce((select max(id) from wecom_message_log), 0) + 1, false)', + v_sequence_name + ); + end if; + end $$; + """); } catch (SQLException exception) { throw new IllegalStateException("Failed to initialize report reminder schema", exception); } diff --git a/backend/src/main/java/com/unis/crm/controller/DashboardController.java b/backend/src/main/java/com/unis/crm/controller/DashboardController.java index cdd90ccc..8df14c51 100644 --- a/backend/src/main/java/com/unis/crm/controller/DashboardController.java +++ b/backend/src/main/java/com/unis/crm/controller/DashboardController.java @@ -42,6 +42,15 @@ public class DashboardController { return ApiResponse.success(null); } + @PostMapping("/messages/{messageId}/read") + @Log(type = "工作台", value = "查阅日报消息") + public ApiResponse readMessage( + @RequestHeader("X-User-Id") @Min(1) Long userId, + @PathVariable("messageId") @Min(1) Long messageId) { + dashboardService.readMessage(CurrentUserUtils.requireCurrentUserId(userId), messageId); + return ApiResponse.success(null); + } + @GetMapping("/analytics-cards/{cardKey}") public ApiResponse getAnalyticsCardDetail( @RequestHeader("X-User-Id") @Min(1) Long userId, 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 e6962208..97139507 100644 --- a/backend/src/main/java/com/unis/crm/controller/WorkController.java +++ b/backend/src/main/java/com/unis/crm/controller/WorkController.java @@ -7,8 +7,11 @@ 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.dto.work.WorkOverviewDTO; +import com.unis.crm.dto.work.WorkReportAttachmentDTO; +import com.unis.crm.dto.work.WorkReportToUserDTO; import com.unis.crm.service.WorkService; import com.unisbase.common.annotation.Log; import jakarta.validation.Valid; @@ -49,6 +52,13 @@ public class WorkController { return ApiResponse.success(workService.getOverview(CurrentUserUtils.requireCurrentUserId(userId))); } + @GetMapping("/report-message-users") + public ApiResponse> listReportMessageUsers( + @RequestHeader("X-User-Id") Long userId, + @RequestParam(value = "keyword", required = false) String keyword) { + return ApiResponse.success(workService.listReportMessageUsers(CurrentUserUtils.requireCurrentUserId(userId), keyword)); + } + @GetMapping("/history") public ApiResponse getHistory( @RequestHeader("X-User-Id") Long userId, @@ -58,6 +68,13 @@ public class WorkController { return ApiResponse.success(workService.getHistory(CurrentUserUtils.requireCurrentUserId(userId), type, page, size)); } + @GetMapping("/history/reports/{reportId}") + public ApiResponse getReportHistoryItem( + @RequestHeader("X-User-Id") Long userId, + @PathVariable("reportId") Long reportId) { + return ApiResponse.success(workService.getReportHistoryItem(CurrentUserUtils.requireCurrentUserId(userId), reportId)); + } + @GetMapping("/checkins/export-data") public ApiResponse> exportCheckIns( @RequestHeader("X-User-Id") Long userId, @@ -134,6 +151,23 @@ public class WorkController { .body(resource); } + @PostMapping(path = "/report-attachments", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Log(type = "工作管理", value = "上传日报附件") + public ApiResponse uploadReportAttachment( + @RequestHeader("X-User-Id") Long userId, + @RequestPart("file") MultipartFile file) { + return ApiResponse.success(workService.uploadReportAttachment(CurrentUserUtils.requireCurrentUserId(userId), file)); + } + + @GetMapping("/report-attachments/{fileName:.+}") + public ResponseEntity getReportAttachment(@PathVariable("fileName") String fileName) { + Resource resource = workService.loadReportAttachment(fileName); + return ResponseEntity.ok() + .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) + .cacheControl(CacheControl.maxAge(7, TimeUnit.DAYS)) + .body(resource); + } + @PostMapping("/daily-reports") @Log(type = "工作管理", value = "提交日报") public ApiResponse saveDailyReport( diff --git a/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardHomeDTO.java b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardHomeDTO.java index 10a8670a..eb474957 100644 --- a/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardHomeDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardHomeDTO.java @@ -15,6 +15,7 @@ public class DashboardHomeDTO { private Boolean analyticsCardVisible; private List stats; private List todos; + private List messages; private List activities; private DashboardAnalyticsPanelDTO analyticsPanel; @@ -25,6 +26,7 @@ public class DashboardHomeDTO { Boolean statsCardVisible, Boolean todoCardVisible, Boolean activityCardVisible, Boolean analyticsCardVisible, List stats, List todos, + List messages, List activities, DashboardAnalyticsPanelDTO analyticsPanel) { this.userId = userId; this.realName = realName; @@ -37,6 +39,7 @@ public class DashboardHomeDTO { this.analyticsCardVisible = analyticsCardVisible; this.stats = stats; this.todos = todos; + this.messages = messages; this.activities = activities; this.analyticsPanel = analyticsPanel; } @@ -129,6 +132,14 @@ public class DashboardHomeDTO { this.todos = todos; } + public List getMessages() { + return messages; + } + + public void setMessages(List messages) { + this.messages = messages; + } + public List getActivities() { return activities; } diff --git a/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardMessageDTO.java b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardMessageDTO.java new file mode 100644 index 00000000..0981d69f --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/dashboard/DashboardMessageDTO.java @@ -0,0 +1,124 @@ +package com.unis.crm.dto.dashboard; + +import java.time.OffsetDateTime; + +public class DashboardMessageDTO { + + private Long id; + private Long reportId; + private Long senderUserId; + private String senderName; + private String reportDate; + private String bizType; + private Long bizId; + private String bizName; + private String content; + private String lineIndexes; + private OffsetDateTime createdAt; + private OffsetDateTime readAt; + private String timeText; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public Long getReportId() { + return reportId; + } + + public void setReportId(Long reportId) { + this.reportId = reportId; + } + + public Long getSenderUserId() { + return senderUserId; + } + + public void setSenderUserId(Long senderUserId) { + this.senderUserId = senderUserId; + } + + public String getSenderName() { + return senderName; + } + + public void setSenderName(String senderName) { + this.senderName = senderName; + } + + public String getReportDate() { + return reportDate; + } + + public void setReportDate(String reportDate) { + this.reportDate = reportDate; + } + + public String getBizType() { + return bizType; + } + + public void setBizType(String bizType) { + this.bizType = bizType; + } + + public Long getBizId() { + return bizId; + } + + public void setBizId(Long bizId) { + this.bizId = bizId; + } + + public String getBizName() { + return bizName; + } + + public void setBizName(String bizName) { + this.bizName = bizName; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } + + public String getLineIndexes() { + return lineIndexes; + } + + public void setLineIndexes(String lineIndexes) { + this.lineIndexes = lineIndexes; + } + + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + public OffsetDateTime getReadAt() { + return readAt; + } + + public void setReadAt(OffsetDateTime readAt) { + this.readAt = readAt; + } + + public String getTimeText() { + return timeText; + } + + public void setTimeText(String timeText) { + this.timeText = timeText; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java index f5105a4a..41c466e0 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityFollowUpDTO.java @@ -1,5 +1,9 @@ package com.unis.crm.dto.opportunity; +import com.unis.crm.dto.work.WorkReportAttachmentDTO; +import java.util.ArrayList; +import java.util.List; + public class OpportunityFollowUpDTO { private Long id; @@ -12,6 +16,7 @@ public class OpportunityFollowUpDTO { private String communicationContent; private String nextAction; private String user; + private List attachments = new ArrayList<>(); public Long getId() { return id; @@ -92,4 +97,12 @@ public class OpportunityFollowUpDTO { public void setUser(String user) { this.user = user; } + + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments; + } } diff --git a/backend/src/main/java/com/unis/crm/dto/reminder/ReportReminderConfigDTO.java b/backend/src/main/java/com/unis/crm/dto/reminder/ReportReminderConfigDTO.java index c6883e28..119e4ee1 100644 --- a/backend/src/main/java/com/unis/crm/dto/reminder/ReportReminderConfigDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/reminder/ReportReminderConfigDTO.java @@ -24,6 +24,7 @@ public class ReportReminderConfigDTO { private String submitNotifyTargetType = "USERS"; private List submitNotifyUserIds = new ArrayList<>(); private List submitNotifyRoleIds = new ArrayList<>(); + private List submitNotifyRules = new ArrayList<>(); private String submitNotifyTemplate = "【日报提交】{{submitterName}}已于{{submitTime}}提交{{reportDate}}日报,请及时查看。"; private boolean excludeSubmitter = true; @@ -179,6 +180,14 @@ public class ReportReminderConfigDTO { this.submitNotifyRoleIds = submitNotifyRoleIds; } + public List getSubmitNotifyRules() { + return submitNotifyRules; + } + + public void setSubmitNotifyRules(List submitNotifyRules) { + this.submitNotifyRules = submitNotifyRules; + } + public String getSubmitNotifyTemplate() { return submitNotifyTemplate; } diff --git a/backend/src/main/java/com/unis/crm/dto/reminder/ReportSubmitNotifyRuleDTO.java b/backend/src/main/java/com/unis/crm/dto/reminder/ReportSubmitNotifyRuleDTO.java new file mode 100644 index 00000000..26d4fb0f --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/reminder/ReportSubmitNotifyRuleDTO.java @@ -0,0 +1,53 @@ +package com.unis.crm.dto.reminder; + +import java.util.ArrayList; +import java.util.List; + +public class ReportSubmitNotifyRuleDTO { + + private List orgIds = new ArrayList<>(); + private List submitterUserIds = new ArrayList<>(); + private List submitterRoleIds = new ArrayList<>(); + private List userIds = new ArrayList<>(); + private List roleIds = new ArrayList<>(); + + public List getOrgIds() { + return orgIds; + } + + public void setOrgIds(List orgIds) { + this.orgIds = orgIds; + } + + public List getSubmitterUserIds() { + return submitterUserIds; + } + + public void setSubmitterUserIds(List submitterUserIds) { + this.submitterUserIds = submitterUserIds; + } + + public List getSubmitterRoleIds() { + return submitterRoleIds; + } + + public void setSubmitterRoleIds(List submitterRoleIds) { + this.submitterRoleIds = submitterRoleIds; + } + + public List getUserIds() { + return userIds; + } + + public void setUserIds(List userIds) { + this.userIds = userIds; + } + + public List getRoleIds() { + return roleIds; + } + + public void setRoleIds(List roleIds) { + this.roleIds = roleIds; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/work/CreateWorkDailyReportRequest.java b/backend/src/main/java/com/unis/crm/dto/work/CreateWorkDailyReportRequest.java index 738b9883..a95dfb72 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/CreateWorkDailyReportRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/work/CreateWorkDailyReportRequest.java @@ -17,11 +17,12 @@ public class CreateWorkDailyReportRequest { @Valid private List lineItems = new ArrayList<>(); - @NotBlank(message = "明日工作计划不能为空") + @Valid + private List attachments = new ArrayList<>(); + @Size(max = 4000, message = "明日工作计划不能超过4000字符") private String tomorrowPlan; - @NotEmpty(message = "请至少填写一条明日工作计划") @Valid private List planItems = new ArrayList<>(); @@ -60,6 +61,14 @@ public class CreateWorkDailyReportRequest { this.lineItems = lineItems; } + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments; + } + public List getPlanItems() { return planItems; } diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportDTO.java index 30660fa3..1439fbd1 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportDTO.java @@ -12,6 +12,7 @@ public class WorkDailyReportDTO { private Integer score; private String comment; private java.util.List lineItems = new java.util.ArrayList<>(); + private java.util.List attachments = new java.util.ArrayList<>(); private java.util.List planItems = new java.util.ArrayList<>(); public Long getId() { @@ -94,6 +95,14 @@ public class WorkDailyReportDTO { this.lineItems = lineItems; } + public java.util.List getAttachments() { + return attachments; + } + + public void setAttachments(java.util.List attachments) { + this.attachments = attachments; + } + public java.util.List getPlanItems() { return planItems; } 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 index cefcbc95..c27b4e00 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkDailyReportExportDTO.java @@ -20,6 +20,7 @@ public class WorkDailyReportExportDTO { private String createdAt; private String updatedAt; private List lineItems = new ArrayList<>(); + private List attachments = new ArrayList<>(); private List planItems = new ArrayList<>(); public String getReportDate() { @@ -142,6 +143,14 @@ public class WorkDailyReportExportDTO { this.lineItems = lineItems; } + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments; + } + public List getPlanItems() { return planItems; } diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkHistoryItemDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkHistoryItemDTO.java index ad6b5d72..057ddf1e 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/WorkHistoryItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkHistoryItemDTO.java @@ -13,6 +13,8 @@ public class WorkHistoryItemDTO { private Integer score; private String comment; private List photoUrls; + private List attachments; + private List lineItems; public Long getId() { return id; @@ -85,4 +87,20 @@ public class WorkHistoryItemDTO { public void setPhotoUrls(List photoUrls) { this.photoUrls = photoUrls; } + + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments; + } + + public List getLineItems() { + return lineItems; + } + + public void setLineItems(List lineItems) { + this.lineItems = lineItems; + } } diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkReportAttachmentDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkReportAttachmentDTO.java new file mode 100644 index 00000000..6b1edcdf --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkReportAttachmentDTO.java @@ -0,0 +1,41 @@ +package com.unis.crm.dto.work; + +public class WorkReportAttachmentDTO { + + private String name; + private String url; + private String contentType; + private Long size; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public Long getSize() { + return size; + } + + public void setSize(Long size) { + this.size = size; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemDTO.java index c29b16bc..d637a935 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemDTO.java @@ -1,5 +1,8 @@ package com.unis.crm.dto.work; +import java.util.ArrayList; +import java.util.List; + public class WorkReportLineItemDTO { private String workDate; @@ -15,6 +18,8 @@ public class WorkReportLineItemDTO { private String stage; private String communicationTime; private String communicationContent; + private List attachments = new ArrayList<>(); + private List toUsers = new ArrayList<>(); public String getWorkDate() { return workDate; @@ -119,4 +124,20 @@ public class WorkReportLineItemDTO { public void setCommunicationContent(String communicationContent) { this.communicationContent = communicationContent; } + + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments; + } + + public List getToUsers() { + return toUsers; + } + + public void setToUsers(List toUsers) { + this.toUsers = toUsers; + } } diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemRequest.java b/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemRequest.java index 40f126d3..50ce26dc 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkReportLineItemRequest.java @@ -1,8 +1,10 @@ package com.unis.crm.dto.work; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; +import jakarta.validation.Valid; +import java.util.ArrayList; +import java.util.List; public class WorkReportLineItemRequest { @@ -12,7 +14,6 @@ public class WorkReportLineItemRequest { @NotBlank(message = "跟进对象类型不能为空") private String bizType; - @NotNull(message = "跟进对象不能为空") private Long bizId; @Size(max = 200, message = "对象名称不能超过200字符") @@ -32,6 +33,10 @@ public class WorkReportLineItemRequest { private String stage; private String communicationTime; private String communicationContent; + @Valid + private List attachments = new ArrayList<>(); + @Valid + private List toUsers = new ArrayList<>(); public String getWorkDate() { return workDate; @@ -136,4 +141,20 @@ public class WorkReportLineItemRequest { public void setCommunicationContent(String communicationContent) { this.communicationContent = communicationContent; } + + public List getAttachments() { + return attachments; + } + + public void setAttachments(List attachments) { + this.attachments = attachments; + } + + public List getToUsers() { + return toUsers; + } + + public void setToUsers(List toUsers) { + this.toUsers = toUsers; + } } diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkReportToUserDTO.java b/backend/src/main/java/com/unis/crm/dto/work/WorkReportToUserDTO.java new file mode 100644 index 00000000..e7a17160 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkReportToUserDTO.java @@ -0,0 +1,32 @@ +package com.unis.crm.dto.work; + +public class WorkReportToUserDTO { + + private Long userId; + private String name; + private String username; + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/work/WorkTomorrowPlanItemRequest.java b/backend/src/main/java/com/unis/crm/dto/work/WorkTomorrowPlanItemRequest.java index 3b385f8f..711ccea4 100644 --- a/backend/src/main/java/com/unis/crm/dto/work/WorkTomorrowPlanItemRequest.java +++ b/backend/src/main/java/com/unis/crm/dto/work/WorkTomorrowPlanItemRequest.java @@ -1,11 +1,9 @@ package com.unis.crm.dto.work; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; public class WorkTomorrowPlanItemRequest { - @NotBlank(message = "明日工作计划内容不能为空") @Size(max = 200, message = "单条明日工作计划不能超过200字符") private String content; diff --git a/backend/src/main/java/com/unis/crm/mapper/DashboardMapper.java b/backend/src/main/java/com/unis/crm/mapper/DashboardMapper.java index ea9f93e2..b608b839 100644 --- a/backend/src/main/java/com/unis/crm/mapper/DashboardMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/DashboardMapper.java @@ -1,6 +1,7 @@ package com.unis.crm.mapper; import com.unis.crm.dto.dashboard.DashboardActivityDTO; +import com.unis.crm.dto.dashboard.DashboardMessageDTO; import com.unis.crm.dto.dashboard.DashboardStatDTO; import com.unis.crm.dto.dashboard.DashboardTodoDTO; import com.unis.crm.dto.dashboard.UserWelcomeDTO; @@ -30,8 +31,12 @@ public interface DashboardMapper { List selectTodos(@Param("userId") Long userId); + List selectMessages(@Param("userId") Long userId); + int markTodoDone(@Param("userId") Long userId, @Param("todoId") Long todoId); + int markMessageRead(@Param("userId") Long userId, @Param("messageId") Long messageId); + @DataScope(tableAlias = "a", ownerColumn = "owner_user_id") List selectLatestOpportunityActivities(@Param("userId") Long userId); diff --git a/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java b/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java index 7645efd4..b385ccc1 100644 --- a/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/OpportunityMapper.java @@ -30,21 +30,30 @@ public interface OpportunityMapper { @Param("typeCode") String typeCode, @Param("itemLabel") String itemLabel); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") List selectOpportunities( @Param("userId") Long userId, @Param("keyword") String keyword, - @Param("stage") String stage); + @Param("stage") String stage, + @Param("allDataAccess") boolean allDataAccess, + @Param("visibleOwnerUserIds") List visibleOwnerUserIds, + @Param("preSalesUserId") Long preSalesUserId, + @Param("preSalesUserNames") List preSalesUserNames); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") OpportunityItemDTO selectOpportunityDetail( @Param("userId") Long userId, - @Param("opportunityId") Long opportunityId); + @Param("opportunityId") Long opportunityId, + @Param("allDataAccess") boolean allDataAccess, + @Param("visibleOwnerUserIds") List visibleOwnerUserIds, + @Param("preSalesUserId") Long preSalesUserId, + @Param("preSalesUserNames") List preSalesUserNames); - @DataScope(tableAlias = "o", ownerColumn = "owner_user_id") List selectOpportunityFollowUps( @Param("userId") Long userId, - @Param("opportunityIds") List opportunityIds); + @Param("opportunityIds") List opportunityIds, + @Param("allDataAccess") boolean allDataAccess, + @Param("visibleOwnerUserIds") List visibleOwnerUserIds, + @Param("preSalesUserId") Long preSalesUserId, + @Param("preSalesUserNames") List preSalesUserNames); @DataScope(tableAlias = "c", ownerColumn = "owner_user_id") Long selectOwnedCustomerIdByName(@Param("userId") Long userId, @Param("customerName") String customerName); 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 66ce90cb..b2d8d416 100644 --- a/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java +++ b/backend/src/main/java/com/unis/crm/mapper/WorkMapper.java @@ -7,6 +7,7 @@ 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.WorkReportToUserDTO; import com.unis.crm.dto.work.WorkSuggestedActionDTO; import com.unisbase.annotation.DataScope; import java.time.LocalDate; @@ -23,12 +24,19 @@ public interface WorkMapper { List selectTodayWorkContentActions(@Param("userId") Long userId); + List selectReportMessageUsers( + @Param("tenantId") Long tenantId, + @Param("keyword") String keyword, + @Param("limit") int limit); + @DataScope(tableAlias = "c", ownerColumn = "user_id") List selectCheckInHistory(@Param("limit") int limit); @DataScope(tableAlias = "r", ownerColumn = "user_id") List selectReportHistory(@Param("limit") int limit); + WorkHistoryItemDTO selectReportHistoryById(@Param("userId") Long userId, @Param("reportId") Long reportId); + @DataScope(tableAlias = "c", ownerColumn = "user_id") List selectCheckInExportRows( @Param("startDate") LocalDate startDate, @@ -67,6 +75,19 @@ public interface WorkMapper { @Param("request") CreateWorkDailyReportRequest request, @Param("submitAt") java.time.OffsetDateTime submitAt); + int deleteReportMessages(@Param("reportId") Long reportId); + + int insertReportMessage( + @Param("reportId") Long reportId, + @Param("senderUserId") Long senderUserId, + @Param("receiverUserId") Long receiverUserId, + @Param("reportDate") LocalDate reportDate, + @Param("lineIndex") int lineIndex, + @Param("bizType") String bizType, + @Param("bizId") Long bizId, + @Param("bizName") String bizName, + @Param("content") String content); + int deleteGeneratedExpansionFollowUps(@Param("reportId") Long reportId); int deleteGeneratedOpportunityFollowUps(@Param("reportId") Long reportId); diff --git a/backend/src/main/java/com/unis/crm/service/DashboardService.java b/backend/src/main/java/com/unis/crm/service/DashboardService.java index 1a7f1e5d..ac04ceb5 100644 --- a/backend/src/main/java/com/unis/crm/service/DashboardService.java +++ b/backend/src/main/java/com/unis/crm/service/DashboardService.java @@ -9,5 +9,7 @@ public interface DashboardService { void completeTodo(Long userId, Long todoId); + void readMessage(Long userId, Long messageId); + DashboardAnalyticsCardDTO getAnalyticsCardDetail(Long userId, String cardKey, String dimension); } diff --git a/backend/src/main/java/com/unis/crm/service/ReportReminderService.java b/backend/src/main/java/com/unis/crm/service/ReportReminderService.java index 28ef55c8..43a29481 100644 --- a/backend/src/main/java/com/unis/crm/service/ReportReminderService.java +++ b/backend/src/main/java/com/unis/crm/service/ReportReminderService.java @@ -8,6 +8,7 @@ import com.unis.crm.common.UnauthorizedException; import com.unis.crm.dto.dashboardanalytics.BusinessCalendarStatusDTO; import com.unis.crm.dto.dashboardanalytics.BusinessCalendarSyncResultDTO; import com.unis.crm.dto.reminder.ReportReminderConfigDTO; +import com.unis.crm.dto.reminder.ReportSubmitNotifyRuleDTO; import com.unis.crm.dto.reminder.ReportReminderTestRequest; import com.unis.crm.dto.reminder.WecomAppConfigDTO; import com.unis.crm.dto.reminder.WecomConfigStatusDTO; @@ -165,12 +166,13 @@ public class ReportReminderService { submit_notify_target_type, submit_notify_user_ids, submit_notify_role_ids, + submit_notify_rules, submit_notify_template, exclude_submitter, created_at, updated_at ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) on conflict (tenant_id) do update set wecom_push_enabled = excluded.wecom_push_enabled, missing_report_enabled = excluded.missing_report_enabled, @@ -190,6 +192,7 @@ public class ReportReminderService { submit_notify_target_type = excluded.submit_notify_target_type, submit_notify_user_ids = excluded.submit_notify_user_ids, submit_notify_role_ids = excluded.submit_notify_role_ids, + submit_notify_rules = excluded.submit_notify_rules, submit_notify_template = excluded.submit_notify_template, exclude_submitter = excluded.exclude_submitter, updated_at = now() @@ -213,6 +216,7 @@ public class ReportReminderService { normalized.getSubmitNotifyTargetType(), writeJsonArray(normalized.getSubmitNotifyUserIds()), writeJsonArray(normalized.getSubmitNotifyRoleIds()), + writeJson(normalized.getSubmitNotifyRules()), normalized.getSubmitNotifyTemplate(), normalized.isExcludeSubmitter()); return true; @@ -294,7 +298,9 @@ public class ReportReminderService { if (submitter == null) { return; } - Set receiverIds = resolveSubmitNotifyUserIds(tenantId, config); + Set submitterOrgIds = queryUserOrgIds(tenantId, userId); + Set submitterRoleIds = queryUserRoleIds(tenantId, userId); + Set receiverIds = resolveSubmitNotifyUserIds(tenantId, config, userId, submitterOrgIds, submitterRoleIds); if (config.isExcludeSubmitter()) { receiverIds.remove(userId); } @@ -529,7 +535,23 @@ public class ReportReminderService { return targetIds; } - private Set resolveSubmitNotifyUserIds(Long tenantId, ReportReminderConfigDTO config) { + private Set resolveSubmitNotifyUserIds( + Long tenantId, + ReportReminderConfigDTO config, + Long submitterUserId, + Set submitterOrgIds, + Set submitterRoleIds) { + if (config.getSubmitNotifyRules() != null && !config.getSubmitNotifyRules().isEmpty()) { + Set receiverIds = new LinkedHashSet<>(); + for (ReportSubmitNotifyRuleDTO rule : config.getSubmitNotifyRules()) { + if (rule == null || !matchesSubmitNotifyRule(rule, submitterUserId, submitterOrgIds, submitterRoleIds)) { + continue; + } + receiverIds.addAll(filterPositiveIds(rule.getUserIds())); + receiverIds.addAll(queryUserIdsByRoleIds(tenantId, filterPositiveIds(rule.getRoleIds()))); + } + return receiverIds; + } String type = upper(trimToNull(config.getSubmitNotifyTargetType()), "USERS"); if ("ROLES".equals(type)) { return queryUserIdsByRoleIds(tenantId, filterPositiveIds(config.getSubmitNotifyRoleIds())); @@ -537,6 +559,59 @@ public class ReportReminderService { return new LinkedHashSet<>(filterPositiveIds(config.getSubmitNotifyUserIds())); } + private boolean matchesSubmitNotifyRule( + ReportSubmitNotifyRuleDTO rule, + Long submitterUserId, + Set submitterOrgIds, + Set submitterRoleIds) { + List ruleOrgIds = filterPositiveIds(rule.getOrgIds()); + List ruleUserIds = filterPositiveIds(rule.getSubmitterUserIds()); + List ruleRoleIds = filterPositiveIds(rule.getSubmitterRoleIds()); + if (ruleOrgIds.isEmpty() && ruleUserIds.isEmpty() && ruleRoleIds.isEmpty()) { + return true; + } + if (!ruleUserIds.isEmpty() && submitterUserId != null && ruleUserIds.contains(submitterUserId)) { + return true; + } + if (!ruleOrgIds.isEmpty() && submitterOrgIds != null && ruleOrgIds.stream().anyMatch(submitterOrgIds::contains)) { + return true; + } + return !ruleRoleIds.isEmpty() && submitterRoleIds != null && ruleRoleIds.stream().anyMatch(submitterRoleIds::contains); + } + + private Set queryUserOrgIds(Long tenantId, Long userId) { + if (tenantId == null || tenantId <= 0 || userId == null || userId <= 0) { + return new LinkedHashSet<>(); + } + return new LinkedHashSet<>(jdbcTemplate.queryForList(""" + select distinct org_id + from sys_tenant_user + where tenant_id = ? + and user_id = ? + and org_id is not null + and coalesce(is_deleted, 0) = 0 + order by org_id asc + """, Long.class, tenantId, userId)); + } + + private Set queryUserRoleIds(Long tenantId, Long userId) { + if (tenantId == null || tenantId <= 0 || userId == null || userId <= 0) { + return new LinkedHashSet<>(); + } + return new LinkedHashSet<>(jdbcTemplate.queryForList(""" + select distinct ur.role_id + from sys_user_role ur + join sys_role r on r.role_id = ur.role_id + left join sys_tenant_user tu on tu.user_id = ur.user_id and tu.tenant_id = r.tenant_id + where ur.user_id = ? + and r.tenant_id = ? + and coalesce(ur.is_deleted, 0) = 0 + and coalesce(r.is_deleted, 0) = 0 + and coalesce(tu.is_deleted, 0) = 0 + order by ur.role_id asc + """, Long.class, userId, tenantId)); + } + private Set queryAllUserIdsByTenant(Long tenantId) { return new LinkedHashSet<>(namedParameterJdbcTemplate.queryForList(""" select distinct u.user_id @@ -804,6 +879,7 @@ public class ReportReminderService { dto.setSubmitNotifyTargetType(trimToNull(resultSet.getString("submit_notify_target_type"))); dto.setSubmitNotifyUserIds(readLongList(resultSet.getString("submit_notify_user_ids"))); dto.setSubmitNotifyRoleIds(readLongList(resultSet.getString("submit_notify_role_ids"))); + dto.setSubmitNotifyRules(readSubmitNotifyRules(safeGetString(resultSet, "submit_notify_rules"))); dto.setSubmitNotifyTemplate(trimToNull(resultSet.getString("submit_notify_template"))); dto.setExcludeSubmitter(resultSet.getBoolean("exclude_submitter")); return normalizeReminderConfig(dto, tenantId); @@ -834,10 +910,44 @@ public class ReportReminderService { dto.setSubmitNotifyTargetType(upper(trimToNull(dto.getSubmitNotifyTargetType()), "USERS")); dto.setSubmitNotifyUserIds(filterPositiveIds(dto.getSubmitNotifyUserIds())); dto.setSubmitNotifyRoleIds(filterPositiveIds(dto.getSubmitNotifyRoleIds())); + dto.setSubmitNotifyRules(normalizeSubmitNotifyRules(dto)); dto.setSubmitNotifyTemplate(defaultText(trimToNull(dto.getSubmitNotifyTemplate()), "【日报提交】{{submitterName}}已于{{submitTime}}提交{{reportDate}}日报,请及时查看。")); return dto; } + private List normalizeSubmitNotifyRules(ReportReminderConfigDTO dto) { + List normalized = new ArrayList<>(); + if (dto.getSubmitNotifyRules() != null) { + for (ReportSubmitNotifyRuleDTO rule : dto.getSubmitNotifyRules()) { + if (rule == null) { + continue; + } + ReportSubmitNotifyRuleDTO next = new ReportSubmitNotifyRuleDTO(); + next.setOrgIds(filterPositiveIds(rule.getOrgIds())); + next.setSubmitterUserIds(filterPositiveIds(rule.getSubmitterUserIds())); + next.setSubmitterRoleIds(filterPositiveIds(rule.getSubmitterRoleIds())); + next.setUserIds(filterPositiveIds(rule.getUserIds())); + next.setRoleIds(filterPositiveIds(rule.getRoleIds())); + if (!next.getOrgIds().isEmpty() + || !next.getSubmitterUserIds().isEmpty() + || !next.getSubmitterRoleIds().isEmpty() + || !next.getUserIds().isEmpty() + || !next.getRoleIds().isEmpty()) { + normalized.add(next); + } + } + } + if (normalized.isEmpty()) { + ReportSubmitNotifyRuleDTO legacyRule = new ReportSubmitNotifyRuleDTO(); + legacyRule.setUserIds(filterPositiveIds(dto.getSubmitNotifyUserIds())); + legacyRule.setRoleIds(filterPositiveIds(dto.getSubmitNotifyRoleIds())); + if (!legacyRule.getUserIds().isEmpty() || !legacyRule.getRoleIds().isEmpty()) { + normalized.add(legacyRule); + } + } + return normalized; + } + private String buildMessageContent( String messageType, ReportReminderConfigDTO config, @@ -1145,6 +1255,27 @@ public class ReportReminderService { } } + private List readSubmitNotifyRules(String json) { + if (!StringUtils.hasText(json)) { + return new ArrayList<>(); + } + try { + List values = objectMapper.readValue(json, new TypeReference>() { + }); + return values == null ? new ArrayList<>() : values; + } catch (IOException ignored) { + return new ArrayList<>(); + } + } + + private String safeGetString(java.sql.ResultSet resultSet, String column) { + try { + return resultSet.getString(column); + } catch (java.sql.SQLException ignored) { + return null; + } + } + private String writeJsonArray(List values) { return writeJson(filterPositiveIds(values)); } 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 209453c3..498f39ad 100644 --- a/backend/src/main/java/com/unis/crm/service/WorkService.java +++ b/backend/src/main/java/com/unis/crm/service/WorkService.java @@ -4,8 +4,11 @@ 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.dto.work.WorkOverviewDTO; +import com.unis.crm.dto.work.WorkReportAttachmentDTO; +import com.unis.crm.dto.work.WorkReportToUserDTO; import java.math.BigDecimal; import java.time.LocalDate; import java.util.List; @@ -16,8 +19,12 @@ public interface WorkService { WorkOverviewDTO getOverview(Long userId); + List listReportMessageUsers(Long userId, String keyword); + WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size); + WorkHistoryItemDTO getReportHistoryItem(Long userId, Long reportId); + List exportCheckIns( Long userId, LocalDate startDate, @@ -45,4 +52,8 @@ public interface WorkService { String uploadCheckInPhoto(Long userId, MultipartFile file); Resource loadCheckInPhoto(String fileName); + + WorkReportAttachmentDTO uploadReportAttachment(Long userId, MultipartFile file); + + Resource loadReportAttachment(String fileName); } diff --git a/backend/src/main/java/com/unis/crm/service/impl/DashboardServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/DashboardServiceImpl.java index f2e0751a..fc3c9ba6 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/DashboardServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/DashboardServiceImpl.java @@ -6,6 +6,7 @@ import com.unis.crm.dto.dashboard.DashboardActivityDTO; import com.unis.crm.dto.dashboard.DashboardAnalyticsCardDTO; import com.unis.crm.dto.dashboard.DashboardAnalyticsPanelDTO; import com.unis.crm.dto.dashboard.DashboardHomeDTO; +import com.unis.crm.dto.dashboard.DashboardMessageDTO; import com.unis.crm.dto.dashboard.DashboardStatDTO; import com.unis.crm.dto.dashboard.DashboardTodoDTO; import com.unis.crm.dto.dashboard.UserWelcomeDTO; @@ -73,10 +74,12 @@ public class DashboardServiceImpl implements DashboardService { stats = List.of(); } List todos = todoCardVisible ? dashboardMapper.selectTodos(userId) : List.of(); + List messages = loadMessages(userId); List activities = activityCardVisible ? loadLatestActivities(userId) : List.of(); DashboardAnalyticsPanelDTO analyticsPanel = analyticsCardVisible ? dashboardAnalyticsConfigService.getDashboardPanel(userId) : null; + enrichMessageTimeText(messages); enrichActivityTimeText(activities); long onboardingDays = 0; @@ -97,6 +100,7 @@ public class DashboardServiceImpl implements DashboardService { analyticsCardVisible, stats, todos, + messages, activities, analyticsPanel ); @@ -116,6 +120,20 @@ public class DashboardServiceImpl implements DashboardService { } } + @Override + public void readMessage(Long userId, Long messageId) { + if (userId == null) { + throw new BusinessException("未获取到当前登录用户,禁止操作他人数据"); + } + if (messageId == null || messageId <= 0) { + throw new BusinessException("消息不存在"); + } + int updated = dashboardMapper.markMessageRead(userId, messageId); + if (updated <= 0) { + throw new BusinessException("消息不存在或无权查阅"); + } + } + @Override public DashboardAnalyticsCardDTO getAnalyticsCardDetail(Long userId, String cardKey, String dimension) { if (userId == null) { @@ -171,6 +189,15 @@ public class DashboardServiceImpl implements DashboardService { return activities; } + private List loadMessages(Long userId) { + try { + List messages = dashboardMapper.selectMessages(userId); + return messages == null ? List.of() : messages; + } catch (Exception ignored) { + return List.of(); + } + } + private void addStatIfPresent(List stats, DashboardStatDTO stat) { if (stat != null) { stats.add(stat); @@ -188,6 +215,17 @@ public class DashboardServiceImpl implements DashboardService { } } + private void enrichMessageTimeText(List messages) { + if (messages == null || messages.isEmpty()) { + return; + } + + OffsetDateTime now = OffsetDateTime.now(); + for (DashboardMessageDTO message : messages) { + message.setTimeText(formatRelativeTime(message.getCreatedAt(), now)); + } + } + private String formatRelativeTime(OffsetDateTime createdAt, OffsetDateTime now) { if (createdAt == null) { return ""; diff --git a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java index 10215301..0a1525ca 100644 --- a/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java +++ b/backend/src/main/java/com/unis/crm/service/impl/OpportunityServiceImpl.java @@ -1,6 +1,8 @@ package com.unis.crm.service.impl; import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.unis.crm.common.BusinessException; import com.unis.crm.dto.opportunity.CurrentUserAccountDTO; import com.unis.crm.dto.opportunity.CreateOpportunityFollowUpRequest; @@ -16,14 +18,24 @@ import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO; import com.unis.crm.dto.opportunity.OpportunityOverviewDTO; import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest; import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest; +import com.unis.crm.dto.work.WorkReportAttachmentDTO; import com.unis.crm.mapper.OpportunityMapper; import com.unis.crm.service.OmsClient; import com.unis.crm.service.OpportunityService; +import com.unisbase.dto.DataScopeRuleDTO; +import com.unisbase.service.DataScopeService; +import com.unisbase.spi.UnisBaseTenantProvider; +import java.io.IOException; import java.math.BigDecimal; +import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -40,14 +52,28 @@ public class OpportunityServiceImpl implements OpportunityService { private static final String SYS_IS_TYPE_CODE = "sys_is"; private static final String ARCHIVED_OPPORTUNITY_EDIT_MESSAGE = "已签单商机不允许编辑"; private static final String ARCHIVED_OPPORTUNITY_PUSH_MESSAGE = "已签单商机不允许推送"; + private static final String REPORT_ATTACHMENT_METADATA_PREFIX = "[[WORK_REPORT_ATTACHMENTS]]"; + private static final String REPORT_ATTACHMENT_METADATA_SUFFIX = "[[/WORK_REPORT_ATTACHMENTS]]"; + private static final Pattern REPORT_ATTACHMENT_METADATA_PATTERN = Pattern.compile("\\[\\[WORK_REPORT_ATTACHMENTS]](.*?)\\[\\[/WORK_REPORT_ATTACHMENTS]]", Pattern.DOTALL); private static final Logger log = LoggerFactory.getLogger(OpportunityServiceImpl.class); private final OpportunityMapper opportunityMapper; private final OmsClient omsClient; + private final ObjectMapper objectMapper; + private final DataScopeService dataScopeService; + private final UnisBaseTenantProvider tenantProvider; - public OpportunityServiceImpl(OpportunityMapper opportunityMapper, OmsClient omsClient) { + public OpportunityServiceImpl( + OpportunityMapper opportunityMapper, + OmsClient omsClient, + ObjectMapper objectMapper, + DataScopeService dataScopeService, + UnisBaseTenantProvider tenantProvider) { this.opportunityMapper = opportunityMapper; this.omsClient = omsClient; + this.objectMapper = objectMapper; + this.dataScopeService = dataScopeService; + this.tenantProvider = tenantProvider; } @Override @@ -97,8 +123,16 @@ public class OpportunityServiceImpl implements OpportunityService { public OpportunityOverviewDTO getOverview(Long userId, String keyword, String stage) { String normalizedKeyword = normalizeKeyword(keyword); String normalizedStage = normalizeStage(stage); - List items = opportunityMapper.selectOpportunities(userId, normalizedKeyword, normalizedStage); - attachFollowUps(userId, items); + OpportunityVisibility visibility = resolveOpportunityVisibility(userId); + List items = opportunityMapper.selectOpportunities( + userId, + normalizedKeyword, + normalizedStage, + visibility.allDataAccess(), + visibility.visibleOwnerUserIds(), + visibility.preSalesUserId(), + visibility.preSalesUserNames()); + attachFollowUps(userId, items, visibility); return new OpportunityOverviewDTO(items); } @@ -108,12 +142,19 @@ public class OpportunityServiceImpl implements OpportunityService { throw new BusinessException("商机不存在"); } - OpportunityItemDTO item = opportunityMapper.selectOpportunityDetail(userId, opportunityId); + OpportunityVisibility visibility = resolveOpportunityVisibility(userId); + OpportunityItemDTO item = opportunityMapper.selectOpportunityDetail( + userId, + opportunityId, + visibility.allDataAccess(), + visibility.visibleOwnerUserIds(), + visibility.preSalesUserId(), + visibility.preSalesUserNames()); if (item == null) { throw new BusinessException("未找到商机详情"); } - attachFollowUps(userId, List.of(item)); + attachFollowUps(userId, List.of(item), visibility); return item; } @@ -238,7 +279,7 @@ public class OpportunityServiceImpl implements OpportunityService { return Long.valueOf(inserted); } - private void attachFollowUps(Long userId, List items) { + private void attachFollowUps(Long userId, List items, OpportunityVisibility visibility) { List opportunityIds = items.stream() .map(OpportunityItemDTO::getId) .filter(Objects::nonNull) @@ -248,7 +289,13 @@ public class OpportunityServiceImpl implements OpportunityService { } Map> grouped = opportunityMapper - .selectOpportunityFollowUps(userId, opportunityIds) + .selectOpportunityFollowUps( + userId, + opportunityIds, + visibility.allDataAccess(), + visibility.visibleOwnerUserIds(), + visibility.preSalesUserId(), + visibility.preSalesUserNames()) .stream() .peek(this::fillFollowUpDisplayFields) .collect(Collectors.groupingBy(OpportunityFollowUpDTO::getOpportunityId)); @@ -258,7 +305,61 @@ public class OpportunityServiceImpl implements OpportunityService { } } + private OpportunityVisibility resolveOpportunityVisibility(Long userId) { + if (userId == null || userId <= 0) { + return new OpportunityVisibility(false, List.of(), userId, List.of()); + } + List preSalesUserNames = resolveCurrentUserPreSalesNames(userId); + Long tenantId = tenantProvider == null ? null : tenantProvider.getCurrentTenantId(); + if (tenantId == null || dataScopeService == null) { + return new OpportunityVisibility(false, List.of(userId), userId, preSalesUserNames); + } + + DataScopeRuleDTO rule = dataScopeService.resolveUserScope(userId, tenantId); + if (rule == null) { + return new OpportunityVisibility(false, List.of(userId), userId, preSalesUserNames); + } + if (rule.isAllAccess()) { + return new OpportunityVisibility(true, List.of(), userId, preSalesUserNames); + } + return new OpportunityVisibility(false, normalizeVisibleOwnerUserIds(rule.getCreatorUserIds()), userId, preSalesUserNames); + } + + private List resolveCurrentUserPreSalesNames(Long userId) { + CurrentUserAccountDTO currentUser = opportunityMapper.selectCurrentUserAccount(userId); + if (currentUser == null) { + return List.of(); + } + Set names = new LinkedHashSet<>(); + addNormalizedName(names, currentUser.getDisplayName()); + addNormalizedName(names, currentUser.getUsername()); + return List.copyOf(names); + } + + private void addNormalizedName(Set names, String value) { + String normalized = normalizeOptionalText(value); + if (normalized != null) { + names.add(normalized); + } + } + + private List normalizeVisibleOwnerUserIds(List ownerUserIds) { + if (ownerUserIds == null || ownerUserIds.isEmpty()) { + return List.of(); + } + Set normalizedIds = new LinkedHashSet<>(); + for (Long ownerUserId : ownerUserIds) { + if (ownerUserId != null && ownerUserId > 0) { + normalizedIds.add(ownerUserId); + } + } + return List.copyOf(normalizedIds); + } + private void fillFollowUpDisplayFields(OpportunityFollowUpDTO followUp) { + AttachmentMetadata attachmentMetadata = extractReportAttachmentMetadata(followUp.getContent()); + followUp.setContent(attachmentMetadata.cleanText()); + followUp.setAttachments(attachmentMetadata.attachments()); if (isBlank(followUp.getType())) { followUp.setType("无"); } @@ -277,6 +378,49 @@ public class OpportunityServiceImpl implements OpportunityService { } } + private AttachmentMetadata extractReportAttachmentMetadata(String rawText) { + String normalized = rawText == null ? "" : rawText; + Matcher matcher = REPORT_ATTACHMENT_METADATA_PATTERN.matcher(normalized); + List attachments = new ArrayList<>(); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + String rawJson = normalizeOptionalText(matcher.group(1)); + if (rawJson != null) { + try { + attachments = objectMapper.readValue(rawJson, new TypeReference>() {}); + } catch (IOException ignored) { + attachments = new ArrayList<>(); + } + } + matcher.appendReplacement(buffer, ""); + } + matcher.appendTail(buffer); + return new AttachmentMetadata(buffer.toString().trim(), normalizeReportAttachments(attachments)); + } + + private List normalizeReportAttachments(List attachments) { + List normalizedAttachments = new ArrayList<>(); + if (attachments == null) { + return normalizedAttachments; + } + for (WorkReportAttachmentDTO attachment : attachments) { + if (attachment == null || isBlank(attachment.getUrl()) || !attachment.getUrl().startsWith("/api/work/report-attachments/")) { + continue; + } + boolean exists = normalizedAttachments.stream().anyMatch(item -> attachment.getUrl().equals(item.getUrl())); + if (exists) { + continue; + } + WorkReportAttachmentDTO normalized = new WorkReportAttachmentDTO(); + normalized.setUrl(attachment.getUrl()); + normalized.setName(normalizeOptionalText(attachment.getName())); + normalized.setContentType(normalizeOptionalText(attachment.getContentType())); + normalized.setSize(attachment.getSize() == null || attachment.getSize() < 0 ? null : attachment.getSize()); + normalizedAttachments.add(normalized); + } + return normalizedAttachments; + } + private void applyStructuredFollowUpSummary(OpportunityFollowUpDTO followUp) { if (followUp == null) { return; @@ -905,4 +1049,14 @@ public class OpportunityServiceImpl implements OpportunityService { private record CommunicationRecord(String time, String content) { } + + private record AttachmentMetadata(String cleanText, List attachments) { + } + + private record OpportunityVisibility( + boolean allDataAccess, + List visibleOwnerUserIds, + Long preSalesUserId, + List preSalesUserNames) { + } } 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 2f5d0a2a..a0ea7b04 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 @@ -13,15 +13,18 @@ import com.unis.crm.dto.work.WorkHistoryItemDTO; import com.unis.crm.dto.work.WorkHistoryPageDTO; import com.unis.crm.dto.work.WorkReportLineItemDTO; import com.unis.crm.dto.work.WorkReportLineItemRequest; +import com.unis.crm.dto.work.WorkReportToUserDTO; import com.unis.crm.dto.work.WorkTomorrowPlanItemDTO; import com.unis.crm.dto.work.WorkTomorrowPlanItemRequest; import com.unis.crm.dto.work.WorkOverviewDTO; +import com.unis.crm.dto.work.WorkReportAttachmentDTO; import com.unis.crm.dto.work.WorkSuggestedActionDTO; import com.unis.crm.mapper.OpportunityMapper; import com.unis.crm.mapper.ProfileMapper; import com.unis.crm.mapper.WorkMapper; import com.unis.crm.service.ReportReminderService; import com.unis.crm.service.WorkService; +import com.unisbase.spi.UnisBaseTenantProvider; import java.io.IOException; import java.math.BigDecimal; import java.net.URI; @@ -74,6 +77,9 @@ public class WorkServiceImpl implements WorkService { private static final String PLAN_ITEMS_METADATA_PREFIX = "[[WORK_PLAN_ITEMS]]"; private static final String PLAN_ITEMS_METADATA_SUFFIX = "[[/WORK_PLAN_ITEMS]]"; private static final Pattern PLAN_ITEMS_METADATA_PATTERN = Pattern.compile("\\[\\[WORK_PLAN_ITEMS]](.*?)\\[\\[/WORK_PLAN_ITEMS]]", Pattern.DOTALL); + private static final String REPORT_ATTACHMENT_METADATA_PREFIX = "[[WORK_REPORT_ATTACHMENTS]]"; + private static final String REPORT_ATTACHMENT_METADATA_SUFFIX = "[[/WORK_REPORT_ATTACHMENTS]]"; + private static final Pattern REPORT_ATTACHMENT_METADATA_PATTERN = Pattern.compile("\\[\\[WORK_REPORT_ATTACHMENTS]](.*?)\\[\\[/WORK_REPORT_ATTACHMENTS]]", Pattern.DOTALL); private static final String ONLY_SEE_ROLE_CODE = "only_see"; private static final String WORK_REPORT_FOLLOW_UP_TYPE = "工作日报"; private static final String LEGACY_NEXT_PLAN_LABEL = "后续规划"; @@ -81,6 +87,7 @@ public class WorkServiceImpl implements WorkService { private static final String OPPORTUNITY_STAGE_LABEL = "项目阶段"; private static final String OPPORTUNITY_STAGE_TYPE_CODE = "sj_xmjd"; private static final int EXPORT_LIMIT = 5000; + private static final long REPORT_ATTACHMENT_MAX_SIZE_BYTES = 20L * 1024 * 1024; 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"; @@ -95,8 +102,10 @@ public class WorkServiceImpl implements WorkService { private final ProfileMapper profileMapper; private final HttpClient httpClient; private final ObjectMapper objectMapper; + private final UnisBaseTenantProvider tenantProvider; private final ReportReminderService reportReminderService; private final Path checkInPhotoDirectory; + private final Path reportAttachmentDirectory; private final String tencentMapKey; private final Map locationNameCache = new ConcurrentHashMap<>(); @@ -106,6 +115,7 @@ public class WorkServiceImpl implements WorkService { ProfileMapper profileMapper, ReportReminderService reportReminderService, ObjectMapper objectMapper, + UnisBaseTenantProvider tenantProvider, @Value("${unisbase.app.upload-path}") String uploadPath, @Value("${unisbase.app.tencent-map.key:}") String tencentMapKey) { this.workMapper = workMapper; @@ -113,10 +123,12 @@ public class WorkServiceImpl implements WorkService { this.profileMapper = profileMapper; this.reportReminderService = reportReminderService; this.objectMapper = objectMapper; + this.tenantProvider = tenantProvider; this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(8)) .build(); this.checkInPhotoDirectory = Paths.get(uploadPath, "work-checkin"); + this.reportAttachmentDirectory = Paths.get(uploadPath, "work-report-attachments"); this.tencentMapKey = normalizeOptionalText(tencentMapKey); } @@ -131,6 +143,12 @@ public class WorkServiceImpl implements WorkService { return new WorkOverviewDTO(todayCheckIn, todayReport, suggestedWorkContent, List.of()); } + @Override + public List listReportMessageUsers(Long userId, String keyword) { + requireUser(userId); + return workMapper.selectReportMessageUsers(resolveCurrentTenantId(), normalizeOptionalText(keyword), 50); + } + @Override public WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size) { requireUser(userId); @@ -152,6 +170,20 @@ public class WorkServiceImpl implements WorkService { return new WorkHistoryPageDTO(pagedItems, hasMore, safePage, safeSize); } + @Override + public WorkHistoryItemDTO getReportHistoryItem(Long userId, Long reportId) { + requireUser(userId); + if (reportId == null || reportId <= 0) { + throw new BusinessException("日报不存在"); + } + WorkHistoryItemDTO item = workMapper.selectReportHistoryById(userId, reportId); + if (item == null) { + throw new BusinessException("日报不存在或无权查看"); + } + normalizeHistoryMetadata(List.of(item)); + return item; + } + @Override public List exportCheckIns( Long userId, @@ -276,6 +308,7 @@ public class WorkServiceImpl implements WorkService { request, currentReport); syncTomorrowPlanTodo(userId, reportId, request.getPlanItems()); + syncReportMessages(userId, reportId, resolveReportDate(currentReport), request); Long finalReportId = reportId; WorkDailyReportDTO finalCurrentReport = currentReport; if (TransactionSynchronizationManager.isSynchronizationActive()) { @@ -369,12 +402,75 @@ public class WorkServiceImpl implements WorkService { } } + @Override + public WorkReportAttachmentDTO uploadReportAttachment(Long userId, MultipartFile file) { + requireUser(userId); + ensureWorkWriteAllowed(userId, "当前角色仅可查看日报历史记录"); + if (file == null || file.isEmpty()) { + throw new BusinessException("请先选择附件"); + } + if (file.getSize() > REPORT_ATTACHMENT_MAX_SIZE_BYTES) { + throw new BusinessException("单个附件不能超过20MB"); + } + + String originalName = normalizeAttachmentName(file.getOriginalFilename()); + String contentType = normalizeOptionalText(file.getContentType()); + String extension = resolveAttachmentExtension(contentType, originalName); + String fileName = userId + "-" + UUID.randomUUID().toString().replace("-", "") + extension; + + try { + Files.createDirectories(reportAttachmentDirectory); + Path targetPath = reportAttachmentDirectory.resolve(fileName).normalize(); + if (!targetPath.startsWith(reportAttachmentDirectory)) { + throw new BusinessException("附件路径非法"); + } + Files.copy(file.getInputStream(), targetPath, StandardCopyOption.REPLACE_EXISTING); + WorkReportAttachmentDTO attachment = new WorkReportAttachmentDTO(); + attachment.setName(originalName); + attachment.setUrl("/api/work/report-attachments/" + fileName); + attachment.setContentType(contentType); + attachment.setSize(file.getSize()); + return attachment; + } catch (IOException exception) { + throw new BusinessException("附件上传失败,请稍后重试"); + } + } + + @Override + public Resource loadReportAttachment(String fileName) { + String normalizedFileName = normalizeOptionalText(fileName); + if (normalizedFileName == null || normalizedFileName.contains("..") || normalizedFileName.contains("/") || normalizedFileName.contains("\\")) { + throw new BusinessException("附件不存在"); + } + + try { + Path filePath = reportAttachmentDirectory.resolve(normalizedFileName).normalize(); + if (!filePath.startsWith(reportAttachmentDirectory) || !Files.exists(filePath)) { + throw new BusinessException("附件不存在"); + } + return new UrlResource(filePath.toUri()); + } catch (IOException exception) { + throw new BusinessException("附件读取失败"); + } + } + private void requireUser(Long userId) { if (userId == null || userId <= 0) { throw new BusinessException("未获取到当前登录用户"); } } + private Long resolveCurrentTenantId() { + if (tenantProvider == null) { + return null; + } + try { + return tenantProvider.getCurrentTenantId(); + } catch (Exception ignored) { + return null; + } + } + private void ensureWorkWriteAllowed(Long userId, String deniedMessage) { List roleCodes = profileMapper.selectUserRoleCodes(userId); boolean onlySee = roleCodes != null && roleCodes.stream() @@ -632,6 +728,167 @@ public class WorkServiceImpl implements WorkService { return normalizedUrls; } + private List normalizeReportAttachments(List attachments) { + List normalizedAttachments = new ArrayList<>(); + if (attachments == null) { + return normalizedAttachments; + } + for (WorkReportAttachmentDTO attachment : attachments) { + if (attachment == null) { + continue; + } + String url = normalizeOptionalText(attachment.getUrl()); + if (url == null || !url.startsWith("/api/work/report-attachments/")) { + continue; + } + boolean exists = normalizedAttachments.stream().anyMatch(item -> url.equals(item.getUrl())); + if (exists) { + continue; + } + WorkReportAttachmentDTO normalized = new WorkReportAttachmentDTO(); + normalized.setUrl(url); + normalized.setName(normalizeAttachmentName(attachment.getName())); + normalized.setContentType(normalizeOptionalText(attachment.getContentType())); + normalized.setSize(attachment.getSize() == null || attachment.getSize() < 0 ? null : attachment.getSize()); + normalizedAttachments.add(normalized); + } + return normalizedAttachments; + } + + private List normalizeReportToUsers(List toUsers) { + List normalizedUsers = new ArrayList<>(); + if (toUsers == null) { + return normalizedUsers; + } + for (WorkReportToUserDTO user : toUsers) { + if (user == null || user.getUserId() == null || user.getUserId() <= 0) { + continue; + } + boolean exists = normalizedUsers.stream().anyMatch(item -> user.getUserId().equals(item.getUserId())); + if (exists) { + continue; + } + WorkReportToUserDTO normalized = new WorkReportToUserDTO(); + normalized.setUserId(user.getUserId()); + normalized.setName(firstNonBlank(user.getName(), user.getUsername(), "用户" + user.getUserId())); + normalized.setUsername(normalizeOptionalText(user.getUsername())); + normalizedUsers.add(normalized); + } + return normalizedUsers; + } + + private boolean isReportToLine(String value) { + String normalized = value == null ? "" : value.trim(); + return normalized.matches("(?i)^to\\s*[::].*"); + } + + private String stripReportToLines(String value) { + String normalized = value == null ? "" : value.replace("\r", ""); + List lines = new ArrayList<>(); + for (String line : normalized.split("\n")) { + if (!isReportToLine(line)) { + lines.add(line); + } + } + return String.join("\n", lines).trim(); + } + + private String buildReportToLine(List toUsers) { + List normalizedUsers = normalizeReportToUsers(toUsers); + if (normalizedUsers.isEmpty()) { + return null; + } + List names = normalizedUsers.stream() + .map(WorkReportToUserDTO::getName) + .map(this::normalizeOptionalText) + .filter(name -> name != null) + .toList(); + return names.isEmpty() ? null : "To:" + String.join("、", names); + } + + private String appendReportToLine(String value, List toUsers) { + String cleanText = stripReportToLines(value); + String toLine = buildReportToLine(toUsers); + if (toLine == null) { + return cleanText; + } + return cleanText == null || cleanText.isBlank() ? toLine : cleanText + "\n" + toLine; + } + + private List collectReportAttachments(List lineItems) { + List attachments = new ArrayList<>(); + if (lineItems == null) { + return attachments; + } + for (WorkReportLineItemDTO lineItem : lineItems) { + if (lineItem == null || lineItem.getAttachments() == null) { + continue; + } + for (WorkReportAttachmentDTO attachment : lineItem.getAttachments()) { + if (attachment == null || normalizeOptionalText(attachment.getUrl()) == null) { + continue; + } + boolean exists = attachments.stream().anyMatch(item -> attachment.getUrl().equals(item.getUrl())); + if (!exists) { + attachments.add(attachment); + } + } + } + return attachments; + } + + private String buildAttachmentSummary(List attachments) { + int count = attachments == null ? 0 : attachments.size(); + return count > 0 ? ";附件:" + count + "个" : ""; + } + + private String appendReportAttachmentMetadata(String content, List attachments) { + List normalizedAttachments = normalizeReportAttachments(attachments); + if (normalizedAttachments.isEmpty()) { + return content; + } + try { + String json = objectMapper.writeValueAsString(normalizedAttachments); + return (content == null ? "" : content) + "\n" + REPORT_ATTACHMENT_METADATA_PREFIX + json + REPORT_ATTACHMENT_METADATA_SUFFIX; + } catch (IOException exception) { + throw new BusinessException("日报附件编码失败"); + } + } + + private AttachmentMetadata extractReportAttachmentMetadata(String rawText) { + String normalized = rawText == null ? "" : rawText; + Matcher matcher = REPORT_ATTACHMENT_METADATA_PATTERN.matcher(normalized); + List attachments = new ArrayList<>(); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + String rawJson = normalizeOptionalText(matcher.group(1)); + if (rawJson != null) { + try { + attachments = objectMapper.readValue(rawJson, new TypeReference>() {}); + } catch (IOException ignored) { + attachments = new ArrayList<>(); + } + } + matcher.appendReplacement(buffer, ""); + } + matcher.appendTail(buffer); + return new AttachmentMetadata(buffer.toString().trim(), normalizeReportAttachments(attachments)); + } + + private String normalizeAttachmentName(String value) { + String normalized = normalizeOptionalText(value); + if (normalized == null) { + return "附件"; + } + normalized = normalized.replace("\\", "/"); + int slashIndex = normalized.lastIndexOf('/'); + if (slashIndex >= 0 && slashIndex < normalized.length() - 1) { + normalized = normalized.substring(slashIndex + 1); + } + normalized = normalized.replaceAll("[\\r\\n\\t]", " ").trim(); + return normalized.isEmpty() ? "附件" : normalized.substring(0, Math.min(normalized.length(), 180)); + } + private String appendPhotoMetadata(String remark, List photoUrls) { if (photoUrls == null || photoUrls.isEmpty()) { return remark; @@ -654,8 +911,10 @@ public class WorkServiceImpl implements WorkService { return; } ReportLineMetadata metadata = extractReportLineMetadata(todayReport.getWorkContent()); - todayReport.setWorkContent(stripVisitTimeFromReportText(metadata.cleanText())); + AttachmentMetadata attachmentMetadata = extractReportAttachmentMetadata(metadata.cleanText()); + todayReport.setWorkContent(stripVisitTimeFromReportText(attachmentMetadata.cleanText())); todayReport.setLineItems(metadata.lineItems()); + todayReport.setAttachments(attachmentMetadata.attachments()); PlanItemMetadata planMetadata = extractPlanItemMetadata(todayReport.getTomorrowPlan()); todayReport.setTomorrowPlan(planMetadata.cleanText()); todayReport.setPlanItems(planMetadata.planItems()); @@ -671,9 +930,12 @@ public class WorkServiceImpl implements WorkService { } PhotoMetadata photoMetadata = extractPhotoMetadata(historyItem.getContent()); ReportLineMetadata reportLineMetadata = extractReportLineMetadata(photoMetadata.cleanText()); - PlanItemMetadata planMetadata = extractPlanItemMetadata(reportLineMetadata.cleanText()); + AttachmentMetadata attachmentMetadata = extractReportAttachmentMetadata(reportLineMetadata.cleanText()); + PlanItemMetadata planMetadata = extractPlanItemMetadata(attachmentMetadata.cleanText()); historyItem.setContent(stripVisitTimeFromReportText(planMetadata.cleanText())); historyItem.setPhotoUrls(photoMetadata.photoUrls()); + historyItem.setLineItems(reportLineMetadata.lineItems()); + historyItem.setAttachments(attachmentMetadata.attachments()); } } @@ -700,8 +962,10 @@ public class WorkServiceImpl implements WorkService { continue; } ReportLineMetadata reportLineMetadata = extractReportLineMetadata(row.getWorkContent()); - row.setWorkContent(stripVisitTimeFromReportText(reportLineMetadata.cleanText())); + AttachmentMetadata attachmentMetadata = extractReportAttachmentMetadata(reportLineMetadata.cleanText()); + row.setWorkContent(stripVisitTimeFromReportText(attachmentMetadata.cleanText())); row.setLineItems(reportLineMetadata.lineItems()); + row.setAttachments(attachmentMetadata.attachments()); PlanItemMetadata planItemMetadata = extractPlanItemMetadata(row.getTomorrowPlan()); row.setTomorrowPlan(planItemMetadata.cleanText()); row.setPlanItems(planItemMetadata.planItems()); @@ -809,8 +1073,10 @@ public class WorkServiceImpl implements WorkService { item.setStage(normalizeOptionalText(item.getStage())); item.setCommunicationTime(normalizeOptionalText(item.getCommunicationTime())); item.setCommunicationContent(normalizeOptionalText(item.getCommunicationContent())); + item.setAttachments(normalizeReportAttachments(item.getAttachments())); + item.setToUsers(normalizeReportToUsers(item.getToUsers())); hydrateLineItemFromEditorText(item); - if (item.getWorkDate() == null || item.getContent() == null || item.getBizId() == null || item.getEditorText() == null) { + if (item.getWorkDate() == null || item.getContent() == null || item.getEditorText() == null) { throw new BusinessException("请完整填写每一条今日工作内容"); } normalizedItems.add(item); @@ -818,24 +1084,27 @@ public class WorkServiceImpl implements WorkService { if (normalizedItems.isEmpty()) { throw new BusinessException("请至少填写一条今日工作内容"); } + request.setAttachments(normalizeReportAttachments(request.getAttachments())); List normalizedPlanItems = new ArrayList<>(); - for (WorkTomorrowPlanItemRequest item : request.getPlanItems()) { + List planItems = request.getPlanItems() == null ? List.of() : request.getPlanItems(); + for (WorkTomorrowPlanItemRequest item : planItems) { if (item == null) { continue; } item.setContent(normalizeOptionalText(item.getContent())); if (item.getContent() == null) { - throw new BusinessException("请完整填写每一条明日工作计划"); + continue; } normalizedPlanItems.add(item); } - if (normalizedPlanItems.isEmpty()) { - throw new BusinessException("请至少填写一条明日工作计划"); - } request.setLineItems(normalizedItems); request.setPlanItems(normalizedPlanItems); - request.setWorkContent(appendReportLineMetadata(buildReportPlainText(normalizedItems), normalizedItems)); - request.setTomorrowPlan(appendPlanItemMetadata(buildTomorrowPlanPlainText(normalizedPlanItems), normalizedPlanItems)); + request.setWorkContent(appendReportAttachmentMetadata( + appendReportLineMetadata(buildReportPlainText(normalizedItems), normalizedItems), + request.getAttachments())); + request.setTomorrowPlan(normalizedPlanItems.isEmpty() + ? "" + : appendPlanItemMetadata(buildTomorrowPlanPlainText(normalizedPlanItems), normalizedPlanItems)); } private String buildReportPlainText(List lineItems) { @@ -848,9 +1117,14 @@ public class WorkServiceImpl implements WorkService { case "opportunity" -> "商机"; default -> "业务对象"; }; + String attachmentSummary = buildAttachmentSummary(item.getAttachments()); + if (!hasLinkedReportTarget(item)) { + lines.add((index + 1) + ". " + item.getWorkDate() + " " + item.getContent().replace("\n", ";") + attachmentSummary); + continue; + } String targetName = item.getBizName() != null ? item.getBizName() : ("ID-" + item.getBizId()); lines.add((index + 1) + ". " + item.getWorkDate() + " 跟进" + targetLabel + "“" + targetName + "”:" - + item.getContent().replace("\n", ";")); + + item.getContent().replace("\n", ";") + attachmentSummary); } return String.join("\n", lines); } @@ -861,8 +1135,23 @@ public class WorkServiceImpl implements WorkService { } String objectName = firstNonBlank(item.getBizName(), "ID-" + item.getBizId()); String editorText = item.getEditorText(); + if (!hasLinkedReportTarget(item)) { + String manualText = stripReportToLines(firstNonBlank(editorText, item.getContent())); + item.setBizId(null); + item.setBizName(null); + item.setEditorText(appendReportToLine(manualText, item.getToUsers())); + item.setContent(manualText); + item.setVisitStartTime(null); + item.setEvaluationContent(null); + item.setNextPlan(null); + item.setLatestProgress(null); + item.setStage(null); + item.setCommunicationTime(null); + item.setCommunicationContent(null); + return; + } if (editorText == null) { - editorText = buildEditorText(item.getBizType(), objectName, extractLineFieldValues(item)); + editorText = buildEditorText(item.getBizType(), objectName, extractLineFieldValues(item), item.getToUsers()); } Map fieldValues = parseEditorTextFields(editorText); if ("opportunity".equals(item.getBizType())) { @@ -873,7 +1162,7 @@ public class WorkServiceImpl implements WorkService { fieldValues.put(OPPORTUNITY_NEXT_PLAN_LABEL, nextPlan); } } - item.setEditorText(buildEditorText(item.getBizType(), objectName, fieldValues)); + item.setEditorText(buildEditorText(item.getBizType(), objectName, fieldValues, item.getToUsers())); if ("sales".equals(item.getBizType()) || "channel".equals(item.getBizType())) { item.setVisitStartTime(buildReportDateTimeText(item.getWorkDate())); item.setEvaluationContent(fieldValues.get("沟通内容")); @@ -889,7 +1178,7 @@ public class WorkServiceImpl implements WorkService { fieldValues.get(OPPORTUNITY_STAGE_LABEL), item.getStage()))); item.setNextPlan(fieldValues.get(OPPORTUNITY_NEXT_PLAN_LABEL)); - item.setEditorText(buildEditorText(item.getBizType(), objectName, fieldValues)); + item.setEditorText(buildEditorText(item.getBizType(), objectName, fieldValues, item.getToUsers())); item.setContent(buildOpportunityLineContent(item)); } @@ -947,6 +1236,10 @@ public class WorkServiceImpl implements WorkService { return fieldValues; } + private boolean hasLinkedReportTarget(WorkReportLineItemRequest item) { + return item != null && item.getBizId() != null && item.getBizId() > 0 && item.getBizName() != null; + } + private Map parseEditorTextFields(String editorText) { Map fieldValues = new LinkedHashMap<>(); String currentField = null; @@ -955,11 +1248,11 @@ public class WorkServiceImpl implements WorkService { } for (String rawLine : editorText.replace("\r", "").split("\n")) { String line = rawLine == null ? "" : rawLine; - if (line.startsWith("@")) { + if (isReportToLine(line)) { currentField = null; continue; } - if (line.startsWith("# ")) { + if (line.startsWith("# ") || line.startsWith("+ ")) { int separatorIndex = line.indexOf(':'); if (separatorIndex < 0) { separatorIndex = line.indexOf(':'); @@ -971,6 +1264,10 @@ public class WorkServiceImpl implements WorkService { continue; } } + if (line.startsWith("#") || line.startsWith("@")) { + currentField = null; + continue; + } if (currentField != null) { String currentValue = fieldValues.get(currentField); String appended = normalizeOptionalText(line); @@ -984,11 +1281,19 @@ public class WorkServiceImpl implements WorkService { } private String buildEditorText(String bizType, String bizName, Map fieldValues) { + return buildEditorText(bizType, bizName, fieldValues, List.of()); + } + + private String buildEditorText(String bizType, String bizName, Map fieldValues, List toUsers) { List lines = new ArrayList<>(); - lines.add("@" + getBizTypeLabel(bizType) + " " + firstNonBlank(bizName, "未选择对象")); + lines.add("#" + getBizTypeLabel(bizType) + " " + firstNonBlank(bizName, "未选择对象")); for (String fieldLabel : getEditorFieldLabels(bizType)) { String fieldValue = fieldValues == null ? null : normalizeOptionalText(fieldValues.get(fieldLabel)); - lines.add("# " + fieldLabel + ":" + (fieldValue == null ? "" : fieldValue)); + lines.add("+ " + fieldLabel + ":" + (fieldValue == null ? "" : fieldValue)); + } + String toLine = buildReportToLine(toUsers); + if (toLine != null) { + lines.add(toLine); } return String.join("\n", lines); } @@ -1079,6 +1384,9 @@ public class WorkServiceImpl implements WorkService { OffsetDateTime followUpTime = resolveReportSubmitTime(currentReport); for (WorkReportLineItemRequest item : request.getLineItems()) { + if (!hasLinkedReportTarget(item)) { + continue; + } if ("opportunity".equals(item.getBizType())) { workMapper.deleteDailyReportOpportunityFollowUps( item.getBizId(), @@ -1090,7 +1398,7 @@ public class WorkServiceImpl implements WorkService { userId, followUpTime, WORK_REPORT_FOLLOW_UP_TYPE, - item.getContent(), + appendReportAttachmentMetadata(item.getContent(), item.getAttachments()), resolveOpportunityNextAction(item)); workMapper.updateOpportunitySnapshot( item.getBizId(), @@ -1239,6 +1547,27 @@ public class WorkServiceImpl implements WorkService { return ".jpg"; } + private String resolveAttachmentExtension(String contentType, String originalFileName) { + String originalName = normalizeOptionalText(originalFileName); + if (originalName != null) { + int index = originalName.lastIndexOf('.'); + if (index >= 0 && index < originalName.length() - 1) { + String extension = originalName.substring(index + 1).toLowerCase().replaceAll("[^a-z0-9]", ""); + if (!extension.isEmpty() && extension.length() <= 12) { + return "." + extension; + } + } + } + String normalizedContentType = normalizeOptionalText(contentType); + if ("application/pdf".equalsIgnoreCase(normalizedContentType)) { + return ".pdf"; + } + if (normalizedContentType != null && normalizedContentType.startsWith("image/")) { + return resolveFileExtension(normalizedContentType, originalFileName); + } + return ".bin"; + } + private String textValue(JsonNode node, String fieldName) { if (node == null || node.isMissingNode()) { return null; @@ -1387,6 +1716,35 @@ public class WorkServiceImpl implements WorkService { } } + private void syncReportMessages(Long senderUserId, Long reportId, LocalDate reportDate, CreateWorkDailyReportRequest request) { + workMapper.deleteReportMessages(reportId); + if (request == null || request.getLineItems() == null) { + return; + } + for (int index = 0; index < request.getLineItems().size(); index++) { + WorkReportLineItemRequest item = request.getLineItems().get(index); + if (item == null || item.getToUsers() == null || item.getToUsers().isEmpty()) { + continue; + } + String content = normalizeOptionalText(item.getContent()); + if (content == null) { + continue; + } + for (WorkReportToUserDTO toUser : normalizeReportToUsers(item.getToUsers())) { + workMapper.insertReportMessage( + reportId, + senderUserId, + toUser.getUserId(), + reportDate, + index, + item.getBizType(), + hasLinkedReportTarget(item) ? item.getBizId() : null, + hasLinkedReportTarget(item) ? item.getBizName() : null, + content); + } + } + } + private String buildTomorrowPlanTodoTitle(String content) { String normalized = normalizeOptionalText(content); if (normalized == null) { @@ -1831,6 +2189,8 @@ public class WorkServiceImpl implements WorkService { private record PhotoMetadata(String cleanText, List photoUrls) {} + private record AttachmentMetadata(String cleanText, List attachments) {} + private record ReportLineMetadata(String cleanText, List lineItems) {} private record PlanItemMetadata(String cleanText, List planItems) {} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 0999bc43..c911ad73 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -11,7 +11,7 @@ spring: max-file-size: 20MB max-request-size: 25MB datasource: - url: jdbc:postgresql://127.0.0.1:5432/nex_auth + url: jdbc:postgresql://127.0.0.1:5432/unis_crm username: postgres password: 199628 driver-class-name: org.postgresql.Driver diff --git a/backend/src/main/resources/mapper/dashboard/DashboardMapper.xml b/backend/src/main/resources/mapper/dashboard/DashboardMapper.xml index 0db27aef..0566e758 100644 --- a/backend/src/main/resources/mapper/dashboard/DashboardMapper.xml +++ b/backend/src/main/resources/mapper/dashboard/DashboardMapper.xml @@ -107,6 +107,39 @@ id desc + + update work_todo set status = 'done', @@ -116,6 +149,20 @@ and status in ('todo', 'done') + + update work_report_message + set read_at = coalesce(read_at, now()), + updated_at = now() + where report_id = ( + select m.report_id + from work_report_message m + where m.id = #{messageId} + and m.receiver_user_id = #{userId} + limit 1 + ) + and receiver_user_id = #{userId} + + select item_label as label, @@ -189,12 +214,14 @@ or coalesce(o.project_location, '') ilike concat('%', #{keyword}, '%') or coalesce(o.operator_name, '') ilike concat('%', #{keyword}, '%') or coalesce(operator_dict.item_label, '') ilike concat('%', #{keyword}, '%') + or coalesce(o.pre_sales_name, '') ilike concat('%', #{keyword}, '%') or coalesce(o.competitor_name, '') ilike concat('%', #{keyword}, '%') ) and o.stage = #{stage} + order by coalesce(o.updated_at, o.created_at) desc, o.id desc @@ -336,6 +363,7 @@ and coalesce(operator_dict.is_deleted, 0) = 0 where 1 = 1 and o.id = #{opportunityId} + limit 1 @@ -355,6 +383,7 @@ #{id} + order by f.followup_time desc, f.id desc diff --git a/backend/src/main/resources/mapper/work/WorkMapper.xml b/backend/src/main/resources/mapper/work/WorkMapper.xml index 8606be67..15606bc3 100644 --- a/backend/src/main/resources/mapper/work/WorkMapper.xml +++ b/backend/src/main/resources/mapper/work/WorkMapper.xml @@ -168,6 +168,34 @@ order by action_time asc, group_name asc, detail asc + + + + setUserPicker((current) => current ? { ...current, query: event.target.value } : current)} + placeholder="搜索系统用户" + className="crm-input-text min-h-11 w-full rounded-xl border border-slate-200 bg-white py-2.5 pl-10 pr-3 text-base text-slate-900 outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50 dark:text-white md:text-sm" + /> + + + +
+ {reportMessageUsersLoading && !userPickerOptions.length ? ( +
+ 正在加载系统用户... +
+ ) : userPickerOptions.length ? ( +
+ {userPickerOptions.map((user) => ( + + ))} +
+ ) : ( +
+ {reportMessageUsersError || "没有找到匹配用户"} +
+ )} +
+ +
+ + 已选择 {userPickerSelectedCount} 人 + + +
+ + + ) : null} + {objectPicker ? (
); } @@ -3221,12 +3627,14 @@ function HistoryCard({ index, onOpen, onPreviewPhoto, + onPreviewAttachment, disableMobileMotion, }: { item: WorkHistoryItem; index: number; onOpen: () => void; onPreviewPhoto: (url: string, alt: string) => void; + onPreviewAttachment: (attachment: WorkReportAttachment) => void; disableMobileMotion: boolean; }) { const historyPresenter = extractHistoryPresenter(item.content); @@ -3305,6 +3713,11 @@ function HistoryCard({ ))} ) : null} + {item.attachments?.length ? ( +
+ +
+ ) : null} {item.comment ? (

主管点评:

@@ -3317,6 +3730,53 @@ function HistoryCard({ ); } +function ReportAttachmentList({ + attachments, + onPreview, + onRemove, +}: { + attachments?: WorkReportAttachment[]; + onPreview: (attachment: WorkReportAttachment) => void; + onRemove?: (attachment: WorkReportAttachment) => void; +}) { + if (!attachments?.length) { + return null; + } + + return ( +
+ {attachments.map((attachment) => ( +
+ + {onRemove ? ( + + ) : null} +
+ ))} +
+ ); +} + function CheckInPanel({ loading, checkInForm, @@ -3397,7 +3857,7 @@ function CheckInPanel({ {checkInForm.bizName || "点击选择本次打卡关联对象"}

- + @@ -3544,9 +4004,14 @@ function ReportPanel({ onAddReportLine, onRemoveReportLine, onOpenObjectPicker, + onOpenUserPicker, onReportLineKeyDown, onReportLineChange, onReportLineStageChange, + uploadingReportAttachmentIndex, + onReportAttachmentChange, + onRemoveReportAttachment, + onPreviewAttachment, onAddPlanItem, onPlanItemChange, onRemovePlanItem, @@ -3564,10 +4029,15 @@ function ReportPanel({ reportOpportunityStageOptions: OpportunityDictOption[]; onAddReportLine: () => void; onRemoveReportLine: (index: number) => void; - onOpenObjectPicker: (mode: PickerMode, lineIndex?: number, bizType?: BizType) => void; + onOpenObjectPicker: (lineIndex: number, bizType: BizType) => void; + onOpenUserPicker: (lineIndex: number) => void; onReportLineKeyDown: (index: number, event: KeyboardEvent) => void; onReportLineChange: (index: number, value: string) => void; onReportLineStageChange: (index: number, value: string) => void; + uploadingReportAttachmentIndex: number | null; + onReportAttachmentChange: (index: number, event: ChangeEvent) => void; + onRemoveReportAttachment: (lineIndex: number, attachmentUrl: string) => void; + onPreviewAttachment: (attachment: WorkReportAttachment) => void; onAddPlanItem: () => void; onPlanItemChange: (index: number, value: string) => void; onRemovePlanItem: (index: number) => void; @@ -3581,6 +4051,7 @@ function ReportPanel({ const [editingReportLineIndex, setEditingReportLineIndex] = useState(null); const [editingPlanItemIndex, setEditingPlanItemIndex] = useState(null); const reportLineTextareaRefs = useRef<(HTMLTextAreaElement | null)[]>([]); + const reportAttachmentInputRefs = useRef<(HTMLInputElement | null)[]>([]); const planItemInputRefs = useRef<(HTMLInputElement | null)[]>([]); const previousReportLineCountRef = useRef(reportForm.lineItems.length); const previousPlanItemCountRef = useRef(reportForm.planItems.length); @@ -3692,7 +4163,7 @@ function ReportPanel({
-

销售日报

+

日报

{loading ? "加载中..." : getReportStatus(reportStatus)} @@ -3707,7 +4178,7 @@ function ReportPanel({ 今日工作内容

- 输入 @ 选择对象,系统会自动补齐 #字段。 + 输入 # 选择对象,系统会自动补齐 +字段;也可以直接填写文字。

@@ -3728,18 +4199,33 @@ function ReportPanel({
{reportForm.lineItems.map((item, index) => { const isEditing = editingReportLineIndex === index; - const collapsedPreviewLines = buildCollapsedPreviewLines(item.editorText, "先输入 @ 选择对象,系统会自动生成固定字段。"); + const collapsedPreviewLines = buildCollapsedPreviewLines(item.editorText, "输入工作内容,或输入 # 选择对象生成字段。"); const opportunityStageValue = resolveOpportunityStageCode(item.stage, reportOpportunityStageOptions) || item.stage || ""; const opportunityStageLabel = resolveOpportunityStageLabel(item.stage, reportOpportunityStageOptions); const objectLine = item.bizName ? buildEditorMentionLine(item.bizType, item.bizName) : ""; - const detailPreviewLines = item.bizName ? collapsedPreviewLines.slice(1) : collapsedPreviewLines; + const hasManualText = !item.bizName && Boolean(item.editorText?.trim()); + const manualPreviewLine = hasManualText ? collapsedPreviewLines[0] : ""; + const detailPreviewLines = item.bizName + ? collapsedPreviewLines.slice(1) + : hasManualText + ? collapsedPreviewLines.slice(1) + : []; const editorBodyText = item.editorText ? item.editorText.replace(/\r/g, "").split("\n").slice(1).join("\n") : ""; + const attachments = item.attachments ?? []; + const isUploadingAttachment = uploadingReportAttachmentIndex === index; return ( -
+
+ + {index + 1} + +
{isEditing ? ( -
+
{item.bizName ? (

@@ -3792,7 +4278,7 @@ function ReportPanel({

) : null}