日报功能与日志提醒功能优化

main
kangwenjing 2026-06-26 16:11:55 +08:00
parent fec99908ed
commit d917806dc2
49 changed files with 3420 additions and 178 deletions

View File

@ -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);
}

View File

@ -42,6 +42,15 @@ public class DashboardController {
return ApiResponse.success(null);
}
@PostMapping("/messages/{messageId}/read")
@Log(type = "工作台", value = "查阅日报消息")
public ApiResponse<Void> 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<DashboardAnalyticsCardDTO> getAnalyticsCardDetail(
@RequestHeader("X-User-Id") @Min(1) Long userId,

View File

@ -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<List<WorkReportToUserDTO>> 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<WorkHistoryPageDTO> 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<WorkHistoryItemDTO> 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<List<WorkCheckInExportDTO>> 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<WorkReportAttachmentDTO> 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<Resource> 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<Long> saveDailyReport(

View File

@ -15,6 +15,7 @@ public class DashboardHomeDTO {
private Boolean analyticsCardVisible;
private List<DashboardStatDTO> stats;
private List<DashboardTodoDTO> todos;
private List<DashboardMessageDTO> messages;
private List<DashboardActivityDTO> activities;
private DashboardAnalyticsPanelDTO analyticsPanel;
@ -25,6 +26,7 @@ public class DashboardHomeDTO {
Boolean statsCardVisible, Boolean todoCardVisible, Boolean activityCardVisible,
Boolean analyticsCardVisible,
List<DashboardStatDTO> stats, List<DashboardTodoDTO> todos,
List<DashboardMessageDTO> messages,
List<DashboardActivityDTO> 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<DashboardMessageDTO> getMessages() {
return messages;
}
public void setMessages(List<DashboardMessageDTO> messages) {
this.messages = messages;
}
public List<DashboardActivityDTO> getActivities() {
return activities;
}

View File

@ -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;
}
}

View File

@ -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<WorkReportAttachmentDTO> 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<WorkReportAttachmentDTO> getAttachments() {
return attachments;
}
public void setAttachments(List<WorkReportAttachmentDTO> attachments) {
this.attachments = attachments;
}
}

View File

@ -24,6 +24,7 @@ public class ReportReminderConfigDTO {
private String submitNotifyTargetType = "USERS";
private List<Long> submitNotifyUserIds = new ArrayList<>();
private List<Long> submitNotifyRoleIds = new ArrayList<>();
private List<ReportSubmitNotifyRuleDTO> 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<ReportSubmitNotifyRuleDTO> getSubmitNotifyRules() {
return submitNotifyRules;
}
public void setSubmitNotifyRules(List<ReportSubmitNotifyRuleDTO> submitNotifyRules) {
this.submitNotifyRules = submitNotifyRules;
}
public String getSubmitNotifyTemplate() {
return submitNotifyTemplate;
}

View File

@ -0,0 +1,53 @@
package com.unis.crm.dto.reminder;
import java.util.ArrayList;
import java.util.List;
public class ReportSubmitNotifyRuleDTO {
private List<Long> orgIds = new ArrayList<>();
private List<Long> submitterUserIds = new ArrayList<>();
private List<Long> submitterRoleIds = new ArrayList<>();
private List<Long> userIds = new ArrayList<>();
private List<Long> roleIds = new ArrayList<>();
public List<Long> getOrgIds() {
return orgIds;
}
public void setOrgIds(List<Long> orgIds) {
this.orgIds = orgIds;
}
public List<Long> getSubmitterUserIds() {
return submitterUserIds;
}
public void setSubmitterUserIds(List<Long> submitterUserIds) {
this.submitterUserIds = submitterUserIds;
}
public List<Long> getSubmitterRoleIds() {
return submitterRoleIds;
}
public void setSubmitterRoleIds(List<Long> submitterRoleIds) {
this.submitterRoleIds = submitterRoleIds;
}
public List<Long> getUserIds() {
return userIds;
}
public void setUserIds(List<Long> userIds) {
this.userIds = userIds;
}
public List<Long> getRoleIds() {
return roleIds;
}
public void setRoleIds(List<Long> roleIds) {
this.roleIds = roleIds;
}
}

View File

@ -17,11 +17,12 @@ public class CreateWorkDailyReportRequest {
@Valid
private List<WorkReportLineItemRequest> lineItems = new ArrayList<>();
@NotBlank(message = "明日工作计划不能为空")
@Valid
private List<WorkReportAttachmentDTO> attachments = new ArrayList<>();
@Size(max = 4000, message = "明日工作计划不能超过4000字符")
private String tomorrowPlan;
@NotEmpty(message = "请至少填写一条明日工作计划")
@Valid
private List<WorkTomorrowPlanItemRequest> planItems = new ArrayList<>();
@ -60,6 +61,14 @@ public class CreateWorkDailyReportRequest {
this.lineItems = lineItems;
}
public List<WorkReportAttachmentDTO> getAttachments() {
return attachments;
}
public void setAttachments(List<WorkReportAttachmentDTO> attachments) {
this.attachments = attachments;
}
public List<WorkTomorrowPlanItemRequest> getPlanItems() {
return planItems;
}

View File

@ -12,6 +12,7 @@ public class WorkDailyReportDTO {
private Integer score;
private String comment;
private java.util.List<WorkReportLineItemDTO> lineItems = new java.util.ArrayList<>();
private java.util.List<WorkReportAttachmentDTO> attachments = new java.util.ArrayList<>();
private java.util.List<WorkTomorrowPlanItemDTO> planItems = new java.util.ArrayList<>();
public Long getId() {
@ -94,6 +95,14 @@ public class WorkDailyReportDTO {
this.lineItems = lineItems;
}
public java.util.List<WorkReportAttachmentDTO> getAttachments() {
return attachments;
}
public void setAttachments(java.util.List<WorkReportAttachmentDTO> attachments) {
this.attachments = attachments;
}
public java.util.List<WorkTomorrowPlanItemDTO> getPlanItems() {
return planItems;
}

View File

@ -20,6 +20,7 @@ public class WorkDailyReportExportDTO {
private String createdAt;
private String updatedAt;
private List<WorkReportLineItemDTO> lineItems = new ArrayList<>();
private List<WorkReportAttachmentDTO> attachments = new ArrayList<>();
private List<WorkTomorrowPlanItemDTO> planItems = new ArrayList<>();
public String getReportDate() {
@ -142,6 +143,14 @@ public class WorkDailyReportExportDTO {
this.lineItems = lineItems;
}
public List<WorkReportAttachmentDTO> getAttachments() {
return attachments;
}
public void setAttachments(List<WorkReportAttachmentDTO> attachments) {
this.attachments = attachments;
}
public List<WorkTomorrowPlanItemDTO> getPlanItems() {
return planItems;
}

View File

@ -13,6 +13,8 @@ public class WorkHistoryItemDTO {
private Integer score;
private String comment;
private List<String> photoUrls;
private List<WorkReportAttachmentDTO> attachments;
private List<WorkReportLineItemDTO> lineItems;
public Long getId() {
return id;
@ -85,4 +87,20 @@ public class WorkHistoryItemDTO {
public void setPhotoUrls(List<String> photoUrls) {
this.photoUrls = photoUrls;
}
public List<WorkReportAttachmentDTO> getAttachments() {
return attachments;
}
public void setAttachments(List<WorkReportAttachmentDTO> attachments) {
this.attachments = attachments;
}
public List<WorkReportLineItemDTO> getLineItems() {
return lineItems;
}
public void setLineItems(List<WorkReportLineItemDTO> lineItems) {
this.lineItems = lineItems;
}
}

View File

@ -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;
}
}

View File

@ -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<WorkReportAttachmentDTO> attachments = new ArrayList<>();
private List<WorkReportToUserDTO> 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<WorkReportAttachmentDTO> getAttachments() {
return attachments;
}
public void setAttachments(List<WorkReportAttachmentDTO> attachments) {
this.attachments = attachments;
}
public List<WorkReportToUserDTO> getToUsers() {
return toUsers;
}
public void setToUsers(List<WorkReportToUserDTO> toUsers) {
this.toUsers = toUsers;
}
}

View File

@ -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<WorkReportAttachmentDTO> attachments = new ArrayList<>();
@Valid
private List<WorkReportToUserDTO> 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<WorkReportAttachmentDTO> getAttachments() {
return attachments;
}
public void setAttachments(List<WorkReportAttachmentDTO> attachments) {
this.attachments = attachments;
}
public List<WorkReportToUserDTO> getToUsers() {
return toUsers;
}
public void setToUsers(List<WorkReportToUserDTO> toUsers) {
this.toUsers = toUsers;
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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<DashboardTodoDTO> selectTodos(@Param("userId") Long userId);
List<DashboardMessageDTO> 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<DashboardActivityDTO> selectLatestOpportunityActivities(@Param("userId") Long userId);

View File

@ -30,21 +30,30 @@ public interface OpportunityMapper {
@Param("typeCode") String typeCode,
@Param("itemLabel") String itemLabel);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<OpportunityItemDTO> selectOpportunities(
@Param("userId") Long userId,
@Param("keyword") String keyword,
@Param("stage") String stage);
@Param("stage") String stage,
@Param("allDataAccess") boolean allDataAccess,
@Param("visibleOwnerUserIds") List<Long> visibleOwnerUserIds,
@Param("preSalesUserId") Long preSalesUserId,
@Param("preSalesUserNames") List<String> 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<Long> visibleOwnerUserIds,
@Param("preSalesUserId") Long preSalesUserId,
@Param("preSalesUserNames") List<String> preSalesUserNames);
@DataScope(tableAlias = "o", ownerColumn = "owner_user_id")
List<OpportunityFollowUpDTO> selectOpportunityFollowUps(
@Param("userId") Long userId,
@Param("opportunityIds") List<Long> opportunityIds);
@Param("opportunityIds") List<Long> opportunityIds,
@Param("allDataAccess") boolean allDataAccess,
@Param("visibleOwnerUserIds") List<Long> visibleOwnerUserIds,
@Param("preSalesUserId") Long preSalesUserId,
@Param("preSalesUserNames") List<String> preSalesUserNames);
@DataScope(tableAlias = "c", ownerColumn = "owner_user_id")
Long selectOwnedCustomerIdByName(@Param("userId") Long userId, @Param("customerName") String customerName);

View File

@ -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<WorkSuggestedActionDTO> selectTodayWorkContentActions(@Param("userId") Long userId);
List<WorkReportToUserDTO> selectReportMessageUsers(
@Param("tenantId") Long tenantId,
@Param("keyword") String keyword,
@Param("limit") int limit);
@DataScope(tableAlias = "c", ownerColumn = "user_id")
List<WorkHistoryItemDTO> selectCheckInHistory(@Param("limit") int limit);
@DataScope(tableAlias = "r", ownerColumn = "user_id")
List<WorkHistoryItemDTO> selectReportHistory(@Param("limit") int limit);
WorkHistoryItemDTO selectReportHistoryById(@Param("userId") Long userId, @Param("reportId") Long reportId);
@DataScope(tableAlias = "c", ownerColumn = "user_id")
List<WorkCheckInExportDTO> 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);

View File

@ -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);
}

View File

@ -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<Long> receiverIds = resolveSubmitNotifyUserIds(tenantId, config);
Set<Long> submitterOrgIds = queryUserOrgIds(tenantId, userId);
Set<Long> submitterRoleIds = queryUserRoleIds(tenantId, userId);
Set<Long> receiverIds = resolveSubmitNotifyUserIds(tenantId, config, userId, submitterOrgIds, submitterRoleIds);
if (config.isExcludeSubmitter()) {
receiverIds.remove(userId);
}
@ -529,7 +535,23 @@ public class ReportReminderService {
return targetIds;
}
private Set<Long> resolveSubmitNotifyUserIds(Long tenantId, ReportReminderConfigDTO config) {
private Set<Long> resolveSubmitNotifyUserIds(
Long tenantId,
ReportReminderConfigDTO config,
Long submitterUserId,
Set<Long> submitterOrgIds,
Set<Long> submitterRoleIds) {
if (config.getSubmitNotifyRules() != null && !config.getSubmitNotifyRules().isEmpty()) {
Set<Long> 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<Long> submitterOrgIds,
Set<Long> submitterRoleIds) {
List<Long> ruleOrgIds = filterPositiveIds(rule.getOrgIds());
List<Long> ruleUserIds = filterPositiveIds(rule.getSubmitterUserIds());
List<Long> 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<Long> 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<Long> 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<Long> 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<ReportSubmitNotifyRuleDTO> normalizeSubmitNotifyRules(ReportReminderConfigDTO dto) {
List<ReportSubmitNotifyRuleDTO> 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<ReportSubmitNotifyRuleDTO> readSubmitNotifyRules(String json) {
if (!StringUtils.hasText(json)) {
return new ArrayList<>();
}
try {
List<ReportSubmitNotifyRuleDTO> values = objectMapper.readValue(json, new TypeReference<List<ReportSubmitNotifyRuleDTO>>() {
});
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<Long> values) {
return writeJson(filterPositiveIds(values));
}

View File

@ -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<WorkReportToUserDTO> listReportMessageUsers(Long userId, String keyword);
WorkHistoryPageDTO getHistory(Long userId, String type, int page, int size);
WorkHistoryItemDTO getReportHistoryItem(Long userId, Long reportId);
List<WorkCheckInExportDTO> 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);
}

View File

@ -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<DashboardTodoDTO> todos = todoCardVisible ? dashboardMapper.selectTodos(userId) : List.of();
List<DashboardMessageDTO> messages = loadMessages(userId);
List<DashboardActivityDTO> 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<DashboardMessageDTO> loadMessages(Long userId) {
try {
List<DashboardMessageDTO> messages = dashboardMapper.selectMessages(userId);
return messages == null ? List.of() : messages;
} catch (Exception ignored) {
return List.of();
}
}
private void addStatIfPresent(List<DashboardStatDTO> stats, DashboardStatDTO stat) {
if (stat != null) {
stats.add(stat);
@ -188,6 +215,17 @@ public class DashboardServiceImpl implements DashboardService {
}
}
private void enrichMessageTimeText(List<DashboardMessageDTO> 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 "";

View File

@ -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<OpportunityItemDTO> items = opportunityMapper.selectOpportunities(userId, normalizedKeyword, normalizedStage);
attachFollowUps(userId, items);
OpportunityVisibility visibility = resolveOpportunityVisibility(userId);
List<OpportunityItemDTO> 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<OpportunityItemDTO> items) {
private void attachFollowUps(Long userId, List<OpportunityItemDTO> items, OpportunityVisibility visibility) {
List<Long> opportunityIds = items.stream()
.map(OpportunityItemDTO::getId)
.filter(Objects::nonNull)
@ -248,7 +289,13 @@ public class OpportunityServiceImpl implements OpportunityService {
}
Map<Long, List<OpportunityFollowUpDTO>> 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<String> 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<String> resolveCurrentUserPreSalesNames(Long userId) {
CurrentUserAccountDTO currentUser = opportunityMapper.selectCurrentUserAccount(userId);
if (currentUser == null) {
return List.of();
}
Set<String> names = new LinkedHashSet<>();
addNormalizedName(names, currentUser.getDisplayName());
addNormalizedName(names, currentUser.getUsername());
return List.copyOf(names);
}
private void addNormalizedName(Set<String> names, String value) {
String normalized = normalizeOptionalText(value);
if (normalized != null) {
names.add(normalized);
}
}
private List<Long> normalizeVisibleOwnerUserIds(List<Long> ownerUserIds) {
if (ownerUserIds == null || ownerUserIds.isEmpty()) {
return List.of();
}
Set<Long> 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<WorkReportAttachmentDTO> 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<List<WorkReportAttachmentDTO>>() {});
} catch (IOException ignored) {
attachments = new ArrayList<>();
}
}
matcher.appendReplacement(buffer, "");
}
matcher.appendTail(buffer);
return new AttachmentMetadata(buffer.toString().trim(), normalizeReportAttachments(attachments));
}
private List<WorkReportAttachmentDTO> normalizeReportAttachments(List<WorkReportAttachmentDTO> attachments) {
List<WorkReportAttachmentDTO> 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<WorkReportAttachmentDTO> attachments) {
}
private record OpportunityVisibility(
boolean allDataAccess,
List<Long> visibleOwnerUserIds,
Long preSalesUserId,
List<String> preSalesUserNames) {
}
}

View File

@ -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<String, String> 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<WorkReportToUserDTO> 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<WorkCheckInExportDTO> 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<String> roleCodes = profileMapper.selectUserRoleCodes(userId);
boolean onlySee = roleCodes != null && roleCodes.stream()
@ -632,6 +728,167 @@ public class WorkServiceImpl implements WorkService {
return normalizedUrls;
}
private List<WorkReportAttachmentDTO> normalizeReportAttachments(List<WorkReportAttachmentDTO> attachments) {
List<WorkReportAttachmentDTO> 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<WorkReportToUserDTO> normalizeReportToUsers(List<WorkReportToUserDTO> toUsers) {
List<WorkReportToUserDTO> 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<String> 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<WorkReportToUserDTO> toUsers) {
List<WorkReportToUserDTO> normalizedUsers = normalizeReportToUsers(toUsers);
if (normalizedUsers.isEmpty()) {
return null;
}
List<String> 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<WorkReportToUserDTO> 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<WorkReportAttachmentDTO> collectReportAttachments(List<WorkReportLineItemDTO> lineItems) {
List<WorkReportAttachmentDTO> 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<WorkReportAttachmentDTO> attachments) {
int count = attachments == null ? 0 : attachments.size();
return count > 0 ? ";附件:" + count + "个" : "";
}
private String appendReportAttachmentMetadata(String content, List<WorkReportAttachmentDTO> attachments) {
List<WorkReportAttachmentDTO> 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<WorkReportAttachmentDTO> 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<List<WorkReportAttachmentDTO>>() {});
} 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<String> 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<WorkTomorrowPlanItemRequest> normalizedPlanItems = new ArrayList<>();
for (WorkTomorrowPlanItemRequest item : request.getPlanItems()) {
List<WorkTomorrowPlanItemRequest> 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<WorkReportLineItemRequest> 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<String, String> 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<String, String> parseEditorTextFields(String editorText) {
Map<String, String> 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<String, String> fieldValues) {
return buildEditorText(bizType, bizName, fieldValues, List.of());
}
private String buildEditorText(String bizType, String bizName, Map<String, String> fieldValues, List<WorkReportToUserDTO> toUsers) {
List<String> 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<String> photoUrls) {}
private record AttachmentMetadata(String cleanText, List<WorkReportAttachmentDTO> attachments) {}
private record ReportLineMetadata(String cleanText, List<WorkReportLineItemDTO> lineItems) {}
private record PlanItemMetadata(String cleanText, List<WorkTomorrowPlanItemDTO> planItems) {}

View File

@ -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

View File

@ -107,6 +107,39 @@
id desc
</select>
<select id="selectMessages" resultType="com.unis.crm.dto.dashboard.DashboardMessageDTO">
select
min(m.id) as id,
m.report_id as reportId,
m.sender_user_id as senderUserId,
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as senderName,
to_char(m.report_date, 'YYYY-MM-DD') as reportDate,
case when count(1) = 1 then max(m.biz_type) else null end as bizType,
case when count(1) = 1 then max(m.biz_id) else null end as bizId,
coalesce(string_agg(distinct nullif(btrim(m.biz_name), ''), '、'), '') as bizName,
string_agg(coalesce(m.content, ''), E'\n' order by m.line_index asc, m.id asc) as content,
string_agg(m.line_index::text, ',' order by m.line_index asc, m.id asc) as lineIndexes,
max(m.created_at) as createdAt,
case
when count(1) filter (where m.read_at is null) > 0 then null
else max(m.read_at)
end as readAt
from work_report_message m
left join sys_user u on u.user_id = m.sender_user_id and coalesce(u.is_deleted, 0) = 0
where m.receiver_user_id = #{userId}
group by
m.report_id,
m.sender_user_id,
u.display_name,
u.username,
m.report_date
order by
case when count(1) filter (where m.read_at is null) > 0 then 0 else 1 end asc,
coalesce(max(m.read_at), max(m.created_at)) desc nulls last,
min(m.id) desc
limit 20
</select>
<update id="markTodoDone">
update work_todo
set status = 'done',
@ -116,6 +149,20 @@
and status in ('todo', 'done')
</update>
<update id="markMessageRead">
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}
</update>
<select id="selectLatestOpportunityActivities" resultType="com.unis.crm.dto.dashboard.DashboardActivityDTO">
select
a.id,

View File

@ -4,6 +4,31 @@
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.unis.crm.mapper.OpportunityMapper">
<sql id="opportunityVisibilityCondition">
<if test="!allDataAccess">
<trim prefix="and (" suffix=")" prefixOverrides="or">
<if test="visibleOwnerUserIds != null and visibleOwnerUserIds.size() > 0">
or o.owner_user_id in
<foreach collection="visibleOwnerUserIds" item="ownerUserId" open="(" separator="," close=")">
#{ownerUserId}
</foreach>
</if>
<if test="preSalesUserId != null">
or o.pre_sales_id = #{preSalesUserId}
</if>
<if test="preSalesUserNames != null and preSalesUserNames.size() > 0">
or nullif(btrim(o.pre_sales_name), '') in
<foreach collection="preSalesUserNames" item="preSalesUserName" open="(" separator="," close=")">
#{preSalesUserName}
</foreach>
</if>
<if test="(visibleOwnerUserIds == null or visibleOwnerUserIds.size() == 0) and preSalesUserId == null and (preSalesUserNames == null or preSalesUserNames.size() == 0)">
1 = 0
</if>
</trim>
</if>
</sql>
<select id="selectDictItems" resultType="com.unis.crm.dto.opportunity.OpportunityDictOptionDTO">
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}, '%')
)
</if>
<if test="stage != null and stage != ''">
and o.stage = #{stage}
</if>
<include refid="opportunityVisibilityCondition"/>
order by coalesce(o.updated_at, o.created_at) desc, o.id desc
</select>
@ -336,6 +363,7 @@
and coalesce(operator_dict.is_deleted, 0) = 0
where 1 = 1
and o.id = #{opportunityId}
<include refid="opportunityVisibilityCondition"/>
limit 1
</select>
@ -355,6 +383,7 @@
<foreach collection="opportunityIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
<include refid="opportunityVisibilityCondition"/>
order by f.followup_time desc, f.id desc
</select>

View File

@ -168,6 +168,34 @@
order by action_time asc, group_name asc, detail asc
</select>
<select id="selectReportMessageUsers" resultType="com.unis.crm.dto.work.WorkReportToUserDTO">
select
u.user_id as userId,
coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), ''), '') as name,
coalesce(u.username, '') as username
from sys_user u
where coalesce(u.is_deleted, 0) = 0
and coalesce(u.status, 1) = 1
<if test="tenantId != null">
and exists (
select 1
from sys_tenant_user tu
where tu.user_id = u.user_id
and tu.tenant_id = #{tenantId}
and coalesce(tu.is_deleted, 0) = 0
)
</if>
<if test="keyword != null and keyword != ''">
and (
coalesce(u.display_name, '') ilike concat('%', #{keyword}, '%')
or coalesce(u.username, '') ilike concat('%', #{keyword}, '%')
)
</if>
order by coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) asc,
u.user_id asc
limit #{limit}
</select>
<select id="selectCheckInHistory" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
select
c.id,
@ -242,6 +270,57 @@
limit #{limit}
</select>
<select id="selectReportHistoryById" resultType="com.unis.crm.dto.work.WorkHistoryItemDTO">
select
r.id,
'日报' as type,
to_char(r.report_date, 'YYYY-MM-DD') as date,
to_char(r.submit_time, 'HH24:MI') as time,
case
when coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) is not null
then '提交人:' || coalesce(nullif(btrim(u.display_name), ''), nullif(btrim(u.username), '')) || E'\n'
else ''
end ||
coalesce(r.work_content, '') ||
case
when r.tomorrow_plan is not null and btrim(r.tomorrow_plan) &lt;&gt; '' then E'\n明日计划' || r.tomorrow_plan
else ''
end as content,
case coalesce(rc.comment_content, '')
when '' then
case coalesce(r.status, 'submitted')
when 'submitted' then '已提交'
when 'reviewed' then '已点评'
else coalesce(r.status, '已提交')
end
else '已点评'
end as status,
rc.score,
rc.comment_content as comment,
coalesce(r.report_date::timestamp + r.submit_time::time, r.created_at) as sort_time
from work_daily_report r
left join sys_user u on u.user_id = r.user_id and u.is_deleted = 0
left join (
select distinct on (report_id)
report_id,
score,
comment_content
from work_daily_report_comment
order by report_id, reviewed_at desc nulls last, id desc
) rc on rc.report_id = r.id
where r.id = #{reportId}
and (
r.user_id = #{userId}
or exists (
select 1
from work_report_message m
where m.report_id = r.id
and m.receiver_user_id = #{userId}
)
)
limit 1
</select>
<select id="selectCheckInExportRows" resultType="com.unis.crm.dto.work.WorkCheckInExportDTO">
select
to_char(c.checkin_date, 'YYYY-MM-DD') as checkinDate,
@ -468,6 +547,46 @@
where id = #{reportId}
</update>
<delete id="deleteReportMessages">
delete from work_report_message
where report_id = #{reportId}
</delete>
<insert id="insertReportMessage">
insert into work_report_message (
report_id,
sender_user_id,
receiver_user_id,
report_date,
line_index,
biz_type,
biz_id,
biz_name,
content,
created_at,
updated_at
) values (
#{reportId},
#{senderUserId},
#{receiverUserId},
#{reportDate},
#{lineIndex},
#{bizType},
#{bizId},
#{bizName},
#{content},
now(),
now()
)
on conflict (report_id, line_index, receiver_user_id)
do update set
biz_type = excluded.biz_type,
biz_id = excluded.biz_id,
biz_name = excluded.biz_name,
content = excluded.content,
updated_at = now()
</insert>
<delete id="deleteGeneratedExpansionFollowUps">
delete from crm_expansion_followup
where source_type = 'work_report'

View File

@ -9,19 +9,26 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
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.CreateOpportunityRequest;
import com.unis.crm.dto.opportunity.OmsPreSalesOptionDTO;
import com.unis.crm.dto.opportunity.OpportunityCustomerSnapshotDTO;
import com.unis.crm.dto.opportunity.OpportunityItemDTO;
import com.unis.crm.dto.opportunity.OpportunityOverviewDTO;
import com.unis.crm.dto.opportunity.OpportunityIntegrationTargetDTO;
import com.unis.crm.dto.opportunity.OpportunityOmsPushDataDTO;
import com.unis.crm.dto.opportunity.PushOpportunityToOmsRequest;
import com.unis.crm.dto.opportunity.UpdateOpportunityIntegrationRequest;
import com.unis.crm.mapper.OpportunityMapper;
import com.unis.crm.service.OmsClient;
import com.unisbase.dto.DataScopeRuleDTO;
import com.unisbase.service.DataScopeService;
import com.unisbase.spi.UnisBaseTenantProvider;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@ -37,6 +44,15 @@ class OpportunityServiceImplTest {
@Mock
private OmsClient omsClient;
@Mock
private ObjectMapper objectMapper;
@Mock
private DataScopeService dataScopeService;
@Mock
private UnisBaseTenantProvider tenantProvider;
@InjectMocks
private OpportunityServiceImpl opportunityService;
@ -180,6 +196,41 @@ class OpportunityServiceImplTest {
&& "下周提交报价".equals(payload.getNextPlan())));
}
@Test
void getOverview_shouldKeepConfiguredDataScopeAndIncludePreSalesVisibility() {
DataScopeRuleDTO rule = new DataScopeRuleDTO();
rule.setCreatorUserIds(List.of(2L, 3L, 2L));
when(tenantProvider.getCurrentTenantId()).thenReturn(100L);
when(dataScopeService.resolveUserScope(9L, 100L)).thenReturn(rule);
CurrentUserAccountDTO currentUser = new CurrentUserAccountDTO();
currentUser.setUserId(9L);
currentUser.setUsername("presales.zhang");
currentUser.setDisplayName("张售前");
when(opportunityMapper.selectCurrentUserAccount(9L)).thenReturn(currentUser);
OpportunityItemDTO item = new OpportunityItemDTO();
item.setId(10L);
when(opportunityMapper.selectOpportunities(
eq(9L),
eq("重点"),
eq(null),
eq(false),
eq(List.of(2L, 3L)),
eq(9L),
eq(List.of("张售前", "presales.zhang")))).thenReturn(List.of(item));
when(opportunityMapper.selectOpportunityFollowUps(
eq(9L),
eq(List.of(10L)),
eq(false),
eq(List.of(2L, 3L)),
eq(9L),
eq(List.of("张售前", "presales.zhang")))).thenReturn(List.of());
OpportunityOverviewDTO overview = opportunityService.getOverview(9L, " 重点 ", null);
assertEquals(1, overview.getItems().size());
}
private OpportunityCustomerSnapshotDTO customerSnapshot(Long customerId, String customerName) {
OpportunityCustomerSnapshotDTO dto = new OpportunityCustomerSnapshotDTO();
dto.setCustomerId(customerId);

View File

@ -15,6 +15,7 @@ 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.WorkReportAttachmentDTO;
import com.unis.crm.dto.work.WorkReportLineItemRequest;
import com.unis.crm.dto.work.WorkTomorrowPlanItemRequest;
import com.unis.crm.mapper.OpportunityMapper;
@ -22,10 +23,12 @@ import java.time.LocalDate;
import com.unis.crm.mapper.ProfileMapper;
import com.unis.crm.mapper.WorkMapper;
import com.unis.crm.service.ReportReminderService;
import com.unisbase.spi.UnisBaseTenantProvider;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
@ -49,11 +52,14 @@ class WorkServiceImplTest {
@Mock
private ReportReminderService reportReminderService;
@Mock
private UnisBaseTenantProvider tenantProvider;
private WorkServiceImpl workService;
@BeforeEach
void setUp() {
workService = new WorkServiceImpl(workMapper, opportunityMapper, profileMapper, reportReminderService, new ObjectMapper(), "build/test-uploads", "");
workService = new WorkServiceImpl(workMapper, opportunityMapper, profileMapper, reportReminderService, new ObjectMapper(), tenantProvider, "build/test-uploads", "");
}
@Test
@ -144,6 +150,46 @@ class WorkServiceImplTest {
org.mockito.ArgumentMatchers.any());
}
@Test
void saveDailyReport_shouldAllowManualTextWithoutLinkedObject() {
CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest();
WorkReportLineItemRequest lineItem = new WorkReportLineItemRequest();
lineItem.setWorkDate("2026-04-28");
lineItem.setBizType("sales");
lineItem.setBizId(0L);
lineItem.setEditorText("整理客户资料,完成重点项目复盘");
lineItem.setContent("整理客户资料,完成重点项目复盘");
request.setLineItems(List.of(lineItem));
WorkTomorrowPlanItemRequest planItem = new WorkTomorrowPlanItemRequest();
planItem.setContent("继续跟进重点项目");
request.setPlanItems(List.of(planItem));
request.setWorkContent("占位");
request.setTomorrowPlan("继续跟进重点项目");
request.setSourceType("manual");
WorkDailyReportDTO currentReport = new WorkDailyReportDTO();
currentReport.setDate("2026-04-28");
currentReport.setSubmitTime("2026-04-28 10:00");
when(workMapper.insertDailyReport(eq(17L), any(CreateWorkDailyReportRequest.class), any(LocalDate.class), any()))
.thenReturn(1);
when(workMapper.selectTodayReportId(eq(17L), any(LocalDate.class)))
.thenReturn(null, 501L);
when(workMapper.selectTodayReport(eq(17L), any(LocalDate.class)))
.thenReturn(currentReport);
workService.saveDailyReport(17L, request);
ArgumentCaptor<CreateWorkDailyReportRequest> requestCaptor = ArgumentCaptor.forClass(CreateWorkDailyReportRequest.class);
verify(workMapper).insertDailyReport(eq(17L), requestCaptor.capture(), any(LocalDate.class), any());
assertEquals(
true,
requestCaptor.getValue().getWorkContent().startsWith("1. 2026-04-28 整理客户资料,完成重点项目复盘"));
verify(workMapper, never()).insertLegacyOpportunityFollowUp(anyLong(), anyLong(), any(), any(), any(), any());
verify(workMapper, never()).insertLegacyExpansionFollowUp(any(), anyLong(), anyLong(), any(), any(), any(), any(), any(), any(), any());
verify(workMapper, never()).updateOpportunitySnapshot(anyLong(), any(), any(), any());
}
@Test
void uploadCheckInPhoto_shouldRejectOnlySeeRole() {
when(profileMapper.selectUserRoleCodes(17L)).thenReturn(List.of("only_see"));
@ -248,6 +294,110 @@ class WorkServiceImplTest {
eq(null));
}
@Test
void saveDailyReport_shouldSyncOpportunityAttachmentsIntoFollowUpContent() {
CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest();
WorkReportLineItemRequest lineItem = new WorkReportLineItemRequest();
lineItem.setWorkDate("2026-04-28");
lineItem.setBizType("opportunity");
lineItem.setBizId(101L);
lineItem.setBizName("项目A");
lineItem.setEditorText("""
# A
+
+
""");
WorkReportAttachmentDTO attachment = new WorkReportAttachmentDTO();
attachment.setName("方案.pdf");
attachment.setUrl("/api/work/report-attachments/17-demo.pdf");
attachment.setContentType("application/pdf");
attachment.setSize(1024L);
lineItem.setAttachments(List.of(attachment));
request.setLineItems(List.of(lineItem));
WorkTomorrowPlanItemRequest planItem = new WorkTomorrowPlanItemRequest();
planItem.setContent("继续推进项目");
request.setPlanItems(List.of(planItem));
request.setWorkContent("占位");
request.setTomorrowPlan("继续推进项目");
request.setSourceType("manual");
WorkDailyReportDTO currentReport = new WorkDailyReportDTO();
currentReport.setDate("2026-04-28");
currentReport.setSubmitTime("2026-04-28 10:00");
when(workMapper.insertDailyReport(eq(17L), any(CreateWorkDailyReportRequest.class), any(LocalDate.class), any()))
.thenReturn(1);
when(workMapper.selectTodayReportId(eq(17L), any(LocalDate.class)))
.thenReturn(null, 501L);
when(workMapper.selectTodayReport(eq(17L), any(LocalDate.class)))
.thenReturn(currentReport);
workService.saveDailyReport(17L, request);
ArgumentCaptor<String> contentCaptor = ArgumentCaptor.forClass(String.class);
verify(workMapper).insertLegacyOpportunityFollowUp(
eq(101L),
eq(17L),
any(),
eq("工作日报"),
contentCaptor.capture(),
eq("继续推进"));
assertEquals(true, contentCaptor.getValue().contains("[[WORK_REPORT_ATTACHMENTS]]"));
assertEquals(true, contentCaptor.getValue().contains("方案.pdf"));
}
@Test
void saveDailyReport_shouldCreateMessagesForSelectedToUsers() {
CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest();
WorkReportLineItemRequest lineItem = new WorkReportLineItemRequest();
lineItem.setWorkDate("2026-04-28");
lineItem.setBizType("sales");
lineItem.setBizId(201L);
lineItem.setBizName("张三");
lineItem.setEditorText("""
#
+
+
To
""");
com.unis.crm.dto.work.WorkReportToUserDTO toUser = new com.unis.crm.dto.work.WorkReportToUserDTO();
toUser.setUserId(88L);
toUser.setName("李四");
lineItem.setToUsers(List.of(toUser));
request.setLineItems(List.of(lineItem));
WorkTomorrowPlanItemRequest planItem = new WorkTomorrowPlanItemRequest();
planItem.setContent("继续跟进重点项目");
request.setPlanItems(List.of(planItem));
request.setWorkContent("占位");
request.setTomorrowPlan("继续跟进重点项目");
request.setSourceType("manual");
WorkDailyReportDTO currentReport = new WorkDailyReportDTO();
currentReport.setDate("2026-04-28");
currentReport.setSubmitTime("2026-04-28 10:00");
when(workMapper.insertDailyReport(eq(17L), any(CreateWorkDailyReportRequest.class), any(LocalDate.class), any()))
.thenReturn(1);
when(workMapper.selectTodayReportId(eq(17L), any(LocalDate.class)))
.thenReturn(null, 501L);
when(workMapper.selectTodayReport(eq(17L), any(LocalDate.class)))
.thenReturn(currentReport);
workService.saveDailyReport(17L, request);
verify(workMapper).deleteReportMessages(501L);
verify(workMapper).insertReportMessage(
eq(501L),
eq(17L),
eq(88L),
eq(LocalDate.parse("2026-04-28")),
eq(0),
eq("sales"),
eq(201L),
eq("张三"),
eq("沟通内容:已沟通\n后续规划继续跟进"));
}
@Test
void saveDailyReport_shouldSyncOpportunityStageFromReportTemplate() {
CreateWorkDailyReportRequest request = new CreateWorkDailyReportRequest();

View File

@ -0,0 +1,185 @@
import { useEffect, useState } from "react";
import { Download, ExternalLink, FileText, X } from "lucide-react";
import { fetchWithAuth, type WorkReportAttachment } from "@/lib/auth";
import { cn } from "@/lib/utils";
type AttachmentPreviewModalProps = {
attachment: WorkReportAttachment;
onClose: () => void;
};
function isImageAttachment(attachment: WorkReportAttachment) {
return attachment.contentType?.startsWith("image/") || /\.(png|jpe?g|webp|gif|bmp)$/i.test(attachment.name || attachment.url || "");
}
function isPdfAttachment(attachment: WorkReportAttachment) {
return attachment.contentType === "application/pdf" || /\.pdf$/i.test(attachment.name || attachment.url || "");
}
function isTextAttachment(attachment: WorkReportAttachment) {
const contentType = attachment.contentType || "";
const name = attachment.name || attachment.url || "";
return contentType.startsWith("text/")
|| ["application/json", "application/xml", "application/javascript", "application/x-javascript"].includes(contentType)
|| /\.(md|markdown|txt|csv|json|xml|yaml|yml|log|sql)$/i.test(name);
}
export function AttachmentPreviewModal({ attachment, onClose }: AttachmentPreviewModalProps) {
const [objectUrl, setObjectUrl] = useState("");
const [textContent, setTextContent] = useState("");
const [error, setError] = useState("");
const fileName = attachment.name || "附件";
useEffect(() => {
let active = true;
let nextObjectUrl = "";
setObjectUrl("");
setTextContent("");
setError("");
void (async () => {
try {
const response = await fetchWithAuth(attachment.url || "");
if (!response.ok) {
throw new Error(`附件加载失败(${response.status})`);
}
const blob = await response.blob();
nextObjectUrl = URL.createObjectURL(blob);
const nextTextContent = isTextAttachment(attachment)
? await blob.text()
: "";
if (active) {
setObjectUrl(nextObjectUrl);
setTextContent(nextTextContent);
} else {
URL.revokeObjectURL(nextObjectUrl);
}
} catch (loadError) {
if (active) {
setError(loadError instanceof Error ? loadError.message : "附件加载失败");
}
}
})();
return () => {
active = false;
if (nextObjectUrl) {
URL.revokeObjectURL(nextObjectUrl);
}
};
}, [attachment]);
const handleDownload = () => {
if (!objectUrl) {
return;
}
const link = document.createElement("a");
link.href = objectUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleOpen = () => {
if (objectUrl) {
window.open(objectUrl, "_blank", "noopener,noreferrer");
}
};
return (
<div className="fixed inset-0 z-[110]">
<button
type="button"
onClick={onClose}
className="absolute inset-0 bg-slate-950/82 backdrop-blur-sm"
aria-label="关闭附件预览"
/>
<div className="absolute inset-0 flex items-center justify-center px-4 py-6">
<div className="flex h-[min(760px,90dvh)] w-[min(960px,94vw)] flex-col overflow-hidden rounded-2xl border border-white/10 bg-white shadow-2xl dark:bg-slate-950">
<div className="flex items-center justify-between gap-3 border-b border-slate-100 px-4 py-3 dark:border-slate-800">
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-slate-900 dark:text-white">{fileName}</p>
<p className="text-xs text-slate-400 dark:text-slate-500">{formatAttachmentSize(attachment.size)}</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={handleOpen}
disabled={!objectUrl}
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-500 transition-colors hover:bg-slate-100 disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800"
title="新窗口打开"
aria-label="新窗口打开"
>
<ExternalLink className="h-4 w-4" />
</button>
<button
type="button"
onClick={handleDownload}
disabled={!objectUrl}
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-500 transition-colors hover:bg-slate-100 disabled:opacity-40 dark:text-slate-300 dark:hover:bg-slate-800"
title="下载附件"
aria-label="下载附件"
>
<Download className="h-4 w-4" />
</button>
<button
type="button"
onClick={onClose}
className="inline-flex h-9 w-9 items-center justify-center rounded-full text-slate-500 transition-colors hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
aria-label="关闭"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
<div className="flex min-h-0 flex-1 items-center justify-center bg-slate-100/70 p-3 dark:bg-slate-900">
{error ? (
<div className="rounded-xl border border-rose-200 bg-white px-4 py-3 text-sm text-rose-600 dark:border-rose-500/30 dark:bg-slate-950 dark:text-rose-300">
{error}
</div>
) : !objectUrl ? (
<div className="h-20 w-20 animate-pulse rounded-2xl bg-slate-200 dark:bg-slate-800" />
) : isImageAttachment(attachment) ? (
<img src={objectUrl} alt={fileName} className="max-h-full max-w-full rounded-xl object-contain shadow-lg" />
) : isPdfAttachment(attachment) ? (
<iframe title={fileName} src={objectUrl} className="h-full w-full rounded-xl border border-slate-200 bg-white dark:border-slate-800" />
) : isTextAttachment(attachment) ? (
textContent ? (
<pre className="h-full w-full overflow-auto rounded-xl border border-slate-200 bg-white p-4 text-left text-sm leading-6 text-slate-800 shadow-inner dark:border-slate-800 dark:bg-slate-950 dark:text-slate-100">
{textContent}
</pre>
) : (
<div className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-500 dark:border-slate-800 dark:bg-slate-950 dark:text-slate-300">
</div>
)
) : (
<div className={cn("flex flex-col items-center rounded-2xl border border-slate-200 bg-white px-6 py-8 text-center dark:border-slate-800 dark:bg-slate-950")}>
<FileText className="mb-3 h-10 w-10 text-violet-500" />
<p className="max-w-sm text-sm font-medium text-slate-900 dark:text-white">{fileName}</p>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400"></p>
</div>
)}
</div>
</div>
</div>
</div>
);
}
export function formatAttachmentSize(size?: number) {
if (!size || size <= 0) {
return "未知大小";
}
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / 1024 / 1024).toFixed(1)} MB`;
}

View File

@ -111,6 +111,22 @@ export interface DashboardActivity {
timeText?: string;
}
export interface DashboardMessage {
id: number;
reportId?: number;
senderUserId?: number;
senderName?: string;
reportDate?: string;
bizType?: string;
bizId?: number;
bizName?: string;
content?: string;
lineIndexes?: string;
createdAt?: string;
readAt?: string;
timeText?: string;
}
export interface DashboardAnalyticsCard {
id?: number;
cardKey?: string;
@ -165,6 +181,7 @@ export interface DashboardHome {
analyticsCardVisible?: boolean;
stats?: DashboardStat[];
todos?: DashboardTodo[];
messages?: DashboardMessage[];
activities?: DashboardActivity[];
analyticsPanel?: DashboardAnalyticsPanel;
}
@ -271,6 +288,7 @@ export interface WorkDailyReport {
submitTime?: string;
workContent?: string;
lineItems?: WorkReportLineItem[];
attachments?: WorkReportAttachment[];
tomorrowPlan?: string;
planItems?: WorkTomorrowPlanItem[];
sourceType?: string;
@ -289,6 +307,21 @@ export interface WorkHistoryItem {
score?: number;
comment?: string;
photoUrls?: string[];
attachments?: WorkReportAttachment[];
lineItems?: WorkReportLineItem[];
}
export interface WorkReportAttachment {
name?: string;
url: string;
contentType?: string;
size?: number;
}
export interface WorkReportToUser {
userId: number;
name: string;
username?: string;
}
export interface WorkCheckInExportRow {
@ -324,6 +357,7 @@ export interface WorkDailyReportExportRow {
createdAt?: string;
updatedAt?: string;
lineItems?: WorkReportLineItem[];
attachments?: WorkReportAttachment[];
planItems?: WorkTomorrowPlanItem[];
}
@ -377,6 +411,8 @@ export interface WorkReportLineItem {
stage?: string;
communicationTime?: string;
communicationContent?: string;
attachments?: WorkReportAttachment[];
toUsers?: WorkReportToUser[];
}
export interface WorkTomorrowPlanItem {
@ -386,6 +422,7 @@ export interface WorkTomorrowPlanItem {
export interface CreateWorkDailyReportPayload {
workContent: string;
lineItems: WorkReportLineItem[];
attachments?: WorkReportAttachment[];
planItems: WorkTomorrowPlanItem[];
tomorrowPlan: string;
sourceType?: string;
@ -402,6 +439,7 @@ export interface OpportunityFollowUp {
communicationContent?: string;
nextAction?: string;
user?: string;
attachments?: WorkReportAttachment[];
}
export interface OpportunityItem {
@ -1202,6 +1240,12 @@ export async function completeDashboardTodo(todoId: string) {
}, true);
}
export async function readDashboardMessage(messageId: number) {
return request<void>(`/api/dashboard/messages/${messageId}/read`, {
method: "POST",
}, true);
}
export async function getProfileOverview() {
return getCachedAuthedRequest<ProfileOverview>(
PROFILE_OVERVIEW_CACHE_KEY,
@ -1267,6 +1311,38 @@ export async function getWorkOverview() {
return request<WorkOverview>("/api/work/overview", undefined, true);
}
export async function listWorkReportMessageUsers(keyword?: string) {
const params = new URLSearchParams();
const normalizedKeyword = keyword?.trim() || "";
if (normalizedKeyword) {
params.set("keyword", normalizedKeyword);
}
const query = params.toString();
try {
const users = await request<WorkReportToUser[]>(`/api/work/report-message-users${query ? `?${query}` : ""}`, undefined, true);
if (users?.length) {
return users;
}
} catch {
// Older running backends do not have /api/work/report-message-users yet.
}
const fallbackUsers = await listOwnerTransferTargetUsers();
const normalizedUsers = fallbackUsers.map((user) => ({
userId: user.userId,
name: user.displayName || user.username || `用户${user.userId}`,
username: user.username,
}));
if (!normalizedKeyword) {
return normalizedUsers;
}
const lowerKeyword = normalizedKeyword.toLowerCase();
return normalizedUsers.filter((user) => (
user.name.toLowerCase().includes(lowerKeyword)
|| (user.username || "").toLowerCase().includes(lowerKeyword)
));
}
export async function getWorkHistory(type: "checkin" | "report", page = 1, size = 8) {
const params = new URLSearchParams({
type,
@ -1276,6 +1352,10 @@ export async function getWorkHistory(type: "checkin" | "report", page = 1, size
return request<WorkHistoryPage>(`/api/work/history?${params.toString()}`, undefined, true);
}
export async function getWorkReportHistoryItem(reportId: number) {
return request<WorkHistoryItem>(`/api/work/history/reports/${reportId}`, undefined, true);
}
function buildWorkExportQuery(params?: WorkExportQuery) {
const searchParams = new URLSearchParams();
Object.entries(params ?? {}).forEach(([key, value]) => {
@ -1316,6 +1396,15 @@ export async function uploadWorkCheckInPhoto(file: File) {
}, true);
}
export async function uploadWorkReportAttachment(file: File) {
const formData = new FormData();
formData.append("file", file);
return request<WorkReportAttachment>("/api/work/report-attachments", {
method: "POST",
body: formData,
}, true);
}
export async function saveWorkDailyReport(payload: CreateWorkDailyReportPayload) {
return request<number>("/api/work/daily-reports", {
method: "POST",

View File

@ -19,6 +19,7 @@ import {
Megaphone,
MoreVertical,
Package,
Paperclip,
PhoneCall,
PieChart,
Rocket,
@ -38,15 +39,22 @@ import {
completeDashboardTodo,
getDashboardAnalyticsCardDetail,
getDashboardHome,
getWorkReportHistoryItem,
readDashboardMessage,
type DashboardActivity,
type DashboardAnalyticsCard,
type DashboardHome,
type DashboardMessage,
type DashboardStat,
type DashboardTodo
type DashboardTodo,
type WorkHistoryItem,
type WorkReportAttachment,
type WorkReportLineItem,
} from "@/lib/auth";
import { useIsMobileViewport } from "@/hooks/useIsMobileViewport";
import { useIsWecomBrowser } from "@/hooks/useIsWecomBrowser";
import DashboardAnalyticsChart from "@/components/dashboard/DashboardAnalyticsChart";
import { AttachmentPreviewModal, formatAttachmentSize } from "@/components/AttachmentPreviewModal";
const DASHBOARD_PREVIEW_COUNT = 5;
const DASHBOARD_HISTORY_PREVIEW_COUNT = 3;
@ -624,7 +632,15 @@ export default function Dashboard() {
const [showAllActivities, setShowAllActivities] = useState(false);
const [showAllHistoryTodos, setShowAllHistoryTodos] = useState(false);
const [historyExpanded, setHistoryExpanded] = useState(false);
const [messageHistoryExpanded, setMessageHistoryExpanded] = useState(false);
const [activeWorkTab, setActiveWorkTab] = useState<"todo" | "message">("todo");
const [completingTodoId, setCompletingTodoId] = useState<string | null>(null);
const [readingMessageId, setReadingMessageId] = useState<number | null>(null);
const [messageDetailMessage, setMessageDetailMessage] = useState<DashboardMessage | null>(null);
const [messageDetailItem, setMessageDetailItem] = useState<WorkHistoryItem | null>(null);
const [messageDetailLoading, setMessageDetailLoading] = useState(false);
const [messageDetailError, setMessageDetailError] = useState("");
const [previewAttachment, setPreviewAttachment] = useState<WorkReportAttachment | null>(null);
const [detailCard, setDetailCard] = useState<DashboardAnalyticsCard | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [detailError, setDetailError] = useState<string>("");
@ -671,7 +687,8 @@ export default function Dashboard() {
setShowAllActivities(false);
setShowAllHistoryTodos(false);
setHistoryExpanded(false);
}, [home.activities, home.todos]);
setMessageHistoryExpanded(false);
}, [home.activities, home.todos, home.messages]);
const statMap = new Map<string, number | undefined>((home.stats ?? []).map((item: DashboardStat) => [item.metricKey, item.value]));
const stats = baseStats.map((stat) => ({
@ -686,6 +703,15 @@ export default function Dashboard() {
() => (home.todos ?? []).filter((item) => item.status === "done"),
[home.todos],
);
const messages = home.messages ?? [];
const unreadMessages = useMemo(
() => messages.filter((item) => !item.readAt),
[messages],
);
const readMessages = useMemo(
() => messages.filter((item) => item.readAt),
[messages],
);
const visibleHistoryTodos = showAllHistoryTodos ? historyTodos : historyTodos.slice(0, DASHBOARD_HISTORY_PREVIEW_COUNT);
const activities = (home.activities?.length
? home.activities
@ -697,6 +723,8 @@ export default function Dashboard() {
const showTodoCard = home.todoCardVisible !== false;
const showActivityCard = home.activityCardVisible !== false;
const showAnalyticsCard = home.analyticsCardVisible !== false && home.analyticsPanel?.enabled === true;
const showWorkCard = showTodoCard || messages.length > 0;
const activeWorkPanel = showTodoCard ? activeWorkTab : "message";
const analyticsCards = useMemo(
() => (home.analyticsPanel?.cards ?? [])
.filter((item) => !item.errorMessage)
@ -778,6 +806,64 @@ export default function Dashboard() {
}
};
const handleMessageClick = async (message: DashboardMessage) => {
if (!message?.id || readingMessageId === message.id) {
return;
}
setReadingMessageId(message.id);
setMessageDetailMessage(message);
setMessageDetailItem(null);
setMessageDetailError("");
setMessageDetailLoading(true);
try {
if (!message.readAt) {
const readAt = new Date().toISOString();
try {
await readDashboardMessage(message.id);
const nextMessage = { ...message, readAt };
setHome((current) => ({
...current,
messages: (current.messages ?? []).map((item) => (
item.id === message.id ? { ...item, readAt } : item
)),
}));
setMessageDetailMessage(nextMessage);
} catch {
// Keep opening the detail when the read-state update fails, but leave it unread.
}
}
if (message.reportId) {
const item = await getWorkReportHistoryItem(message.reportId);
setMessageDetailItem(item);
} else {
setMessageDetailError("未找到关联日报");
}
} catch (error) {
setMessageDetailError(error instanceof Error ? error.message : "日报详情加载失败");
} finally {
setReadingMessageId(null);
setMessageDetailLoading(false);
}
};
const closeMessageDetail = () => {
setMessageDetailMessage(null);
setMessageDetailItem(null);
setMessageDetailLoading(false);
setMessageDetailError("");
};
const messageDetailContent = messageDetailItem?.content || messageDetailMessage?.content || "";
const messageDetailDate = messageDetailItem?.date || messageDetailMessage?.reportDate || "";
const messageDetailTime = messageDetailItem?.time || messageDetailMessage?.timeText || "";
const messageDetailLineIndexSet = getDashboardMessageLineIndexSet(messageDetailMessage);
const messageDetailLineItems = (messageDetailItem?.lineItems ?? []).filter((_, lineIndex) => (
messageDetailLineIndexSet.size > 0 && messageDetailLineIndexSet.has(lineIndex)
));
const hasMessageDetailLineItems = messageDetailLineItems.length > 0;
const messageDetailAttachments = messageDetailItem?.attachments ?? [];
const handleStatCardClick = (metricKey: (typeof baseStats)[number]["metricKey"]) => {
const target = statRoutes[metricKey];
if (!target) {
@ -944,7 +1030,7 @@ export default function Dashboard() {
) : null}
<div className={`${showStatsCard ? "mt-5 md:mt-6 " : ""}grid gap-5 ${dashboardPanelGridClassName} md:gap-6`}>
{showTodoCard ? (
{showWorkCard ? (
<motion.div
initial={disableMobileMotion ? false : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
@ -954,12 +1040,41 @@ export default function Dashboard() {
<div className="crm-dashboard-panel-header">
<div className="crm-dashboard-panel-heading">
<h2 className="crm-dashboard-panel-title"></h2>
<p className="crm-dashboard-panel-subtitle"></p>
<p className="crm-dashboard-panel-subtitle"></p>
</div>
<span className="crm-dashboard-panel-meta">
{pendingTodos.length + historyTodos.length}
{pendingTodos.length + historyTodos.length} · {messages.length}
</span>
</div>
{showTodoCard ? (
<div className="mb-4 grid grid-cols-2 gap-1 rounded-2xl bg-slate-100 p-1">
<button
type="button"
onClick={() => setActiveWorkTab("todo")}
className={`rounded-xl px-4 py-2 text-sm font-semibold transition-colors ${
activeWorkTab === "todo"
? "bg-white text-violet-600 shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
aria-pressed={activeWorkTab === "todo"}
>
</button>
<button
type="button"
onClick={() => setActiveWorkTab("message")}
className={`rounded-xl px-4 py-2 text-sm font-semibold transition-colors ${
activeWorkTab === "message"
? "bg-white text-violet-600 shadow-sm"
: "text-slate-500 hover:text-slate-700"
}`}
aria-pressed={activeWorkTab === "message"}
>
</button>
</div>
) : null}
{activeWorkPanel === "todo" ? (
<div className="crm-section-stack sm:gap-5">
<div className="rounded-[24px] border border-slate-100 bg-slate-50/70 p-4">
<div className="mb-2 flex items-center gap-2 sm:mb-3">
@ -1043,6 +1158,99 @@ export default function Dashboard() {
) : null}
</div>
</div>
) : (
<div className="rounded-[24px] border border-slate-100 bg-slate-50/70 p-4">
{messages.length ? (
<div className="space-y-4">
<div>
<div className="mb-2 flex items-center gap-2 sm:mb-3">
<span className="rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold text-slate-500"></span>
<span className="text-xs text-slate-400 dark:text-slate-500">{unreadMessages.length} </span>
</div>
{unreadMessages.length ? (
<ul className="space-y-2.5 sm:space-y-3">
{unreadMessages.map((message: DashboardMessage) => (
<li key={message.id}>
<button
type="button"
onClick={() => void handleMessageClick(message)}
disabled={readingMessageId === message.id}
className="w-full rounded-[20px] border border-slate-100 bg-white px-4 py-3 text-left transition-all hover:border-violet-200 hover:bg-violet-50/50 disabled:cursor-wait disabled:opacity-70"
>
<div className="flex items-start justify-between gap-3">
<p className="min-w-0 flex-1 text-sm font-semibold text-slate-900">
{message.senderName || "同事"}
</p>
<span className="shrink-0 text-[10px] text-slate-400">{message.timeText || message.reportDate || ""}</span>
</div>
{message.bizName ? (
<p className="mt-1 truncate text-xs font-medium text-violet-600">
{message.bizName}
</p>
) : null}
<p className="crm-field-note mt-1 line-clamp-2 whitespace-pre-line">{message.content || "无"}</p>
</button>
</li>
))}
</ul>
) : (
<div className="crm-empty-panel">
</div>
)}
</div>
<div>
<button
type="button"
onClick={() => setMessageHistoryExpanded((current) => !current)}
className="flex w-full items-center justify-between rounded-[20px] border border-slate-200 bg-white px-4 py-3 text-left transition-colors hover:border-slate-300 hover:bg-slate-50"
>
<div className="flex items-center gap-2">
<span className="rounded-full border border-slate-200 bg-white px-3 py-1 text-[11px] font-semibold text-slate-500"></span>
<span className="text-xs text-slate-400 dark:text-slate-500">{readMessages.length} </span>
</div>
<span className="text-sm font-medium text-slate-500">
{messageHistoryExpanded ? "收起" : "展开"}
</span>
</button>
{messageHistoryExpanded ? (
readMessages.length ? (
<ul className="mt-3 space-y-2.5 sm:space-y-3">
{readMessages.map((message: DashboardMessage) => (
<li key={message.id}>
<button
type="button"
onClick={() => void handleMessageClick(message)}
disabled={readingMessageId === message.id}
className="w-full rounded-[20px] border border-slate-100 bg-white px-4 py-3 text-left transition-colors hover:border-slate-200 hover:bg-slate-50 disabled:cursor-wait disabled:opacity-70"
>
<div className="flex items-start justify-between gap-3">
<p className="min-w-0 flex-1 truncate text-sm text-slate-500">
{message.senderName || "同事"}
</p>
<Check className="mt-0.5 h-4 w-4 shrink-0 text-emerald-500" />
</div>
<p className="crm-field-note mt-1 line-clamp-2 whitespace-pre-line">{message.content || "无"}</p>
</button>
</li>
))}
</ul>
) : (
<div className="crm-empty-panel mt-3">
</div>
)
) : null}
</div>
</div>
) : (
<div className="crm-empty-panel">
</div>
)}
</div>
)}
</motion.div>
) : null}
@ -1381,6 +1589,107 @@ export default function Dashboard() {
</motion.div>
)}
{messageDetailMessage ? (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/45 p-3 sm:items-center sm:p-6"
onClick={closeMessageDetail}
>
<div
className="flex max-h-[88vh] w-full max-w-2xl flex-col overflow-hidden rounded-[28px] bg-white shadow-[0_28px_80px_-28px_rgba(15,23,42,0.45)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex min-h-[88px] items-start justify-between gap-4 border-b border-slate-200 px-5 py-5 sm:px-6">
<div className="min-w-0 space-y-1.5">
<h3 className="text-[17px] font-semibold text-slate-900 sm:text-[19px]"></h3>
<p className="text-xs leading-5 text-slate-500 sm:text-sm">
{messageDetailMessage.senderName || "同事"} · {messageDetailDate || "无日期"}{messageDetailTime ? ` ${messageDetailTime}` : ""}
</p>
</div>
<button
type="button"
onClick={closeMessageDetail}
className="rounded-full border border-slate-200 bg-white p-2 text-slate-500 transition-colors hover:border-slate-300 hover:text-slate-700"
aria-label="关闭日报详情"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="overflow-y-auto px-5 py-5 sm:px-6 sm:py-6">
{messageDetailLoading ? (
<div className="crm-empty-panel">...</div>
) : messageDetailError ? (
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-600">
{messageDetailError}
</div>
) : (
<div className="space-y-4">
{messageDetailMessage.bizName ? (
<div className="rounded-2xl border border-violet-100 bg-violet-50/60 px-4 py-3">
<p className="text-xs font-medium text-violet-500"></p>
<p className="mt-1 text-sm font-semibold text-violet-700">{messageDetailMessage.bizName}</p>
</div>
) : null}
{hasMessageDetailLineItems ? (
<div className="space-y-3">
<p className="text-xs font-medium text-slate-500"></p>
{messageDetailLineItems.map((lineItem, lineIndex) => {
const lineAttachments = lineItem.attachments ?? [];
return (
<div key={`${messageDetailMessage.id}-message-line-${lineIndex}`} className="rounded-2xl border border-slate-100 bg-slate-50/80 px-4 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-violet-50 px-2.5 py-1 text-xs font-semibold text-violet-600">
{lineIndex + 1}
</span>
{lineItem.bizName ? (
<span className="text-xs font-medium text-slate-500">
{getDashboardBizTypeLabel(lineItem.bizType)} · {lineItem.bizName}
</span>
) : null}
</div>
<p className="mt-3 whitespace-pre-line text-sm leading-7 text-slate-700">
{getDashboardReportLineDisplayText(lineItem) || "无"}
</p>
{lineAttachments.length ? (
<div className="mt-3 border-t border-slate-200/80 pt-3">
<p className="mb-2 text-xs font-medium text-slate-500"> · {lineAttachments.length} </p>
<DashboardAttachmentList attachments={lineAttachments} onPreview={setPreviewAttachment} />
</div>
) : null}
</div>
);
})}
</div>
) : (
<div className="rounded-2xl border border-slate-100 bg-slate-50/80 px-4 py-3">
<p className="text-xs font-medium text-slate-500"></p>
<p className="mt-2 whitespace-pre-line text-sm leading-7 text-slate-700">
{messageDetailContent || "无"}
</p>
</div>
)}
{messageDetailAttachments.length ? (
<div className="rounded-2xl border border-slate-100 bg-slate-50/80 px-4 py-3">
<p className="mb-2 text-xs font-medium text-slate-500"> · {messageDetailAttachments.length} </p>
<DashboardAttachmentList attachments={messageDetailAttachments} onPreview={setPreviewAttachment} />
</div>
) : null}
{messageDetailItem?.comment ? (
<div className="rounded-2xl border border-slate-100 bg-white px-4 py-3">
<p className="text-xs font-medium text-slate-500"></p>
<p className="mt-2 whitespace-pre-line text-sm leading-7 text-slate-700">{messageDetailItem.comment}</p>
</div>
) : null}
</div>
)}
</div>
</div>
</div>
) : null}
{previewAttachment ? (
<AttachmentPreviewModal attachment={previewAttachment} onClose={() => setPreviewAttachment(null)} />
) : null}
{detailCard ? (
<div
className="fixed inset-0 z-50 flex items-end justify-center bg-slate-950/45 p-3 sm:items-center sm:p-6"
@ -1516,3 +1825,74 @@ function getTodoDisplayTitle(task: DashboardTodo) {
}
return title;
}
function getDashboardBizTypeLabel(bizType?: WorkReportLineItem["bizType"]) {
if (bizType === "sales") {
return "人员拓展";
}
if (bizType === "channel") {
return "渠道拓展";
}
if (bizType === "opportunity") {
return "商机";
}
return "记录";
}
function getDashboardReportLineDisplayText(lineItem: WorkReportLineItem) {
const content = lineItem.content?.trim() || lineItem.editorText?.trim() || "";
if (!content) {
return "";
}
return content
.replace(/\r/g, "")
.split("\n")
.filter((line) => {
const normalizedLine = line.trim();
return normalizedLine && !normalizedLine.startsWith("#") && !/^to\s*[:]/i.test(normalizedLine);
})
.join("\n")
.replace(/[;]?\s*\s*\d+\s*\s*$/u, "")
.trim();
}
function getDashboardMessageLineIndexSet(message: DashboardMessage | null) {
const indexes = new Set<number>();
const rawIndexes = message?.lineIndexes?.split(",") ?? [];
for (const rawIndex of rawIndexes) {
const index = Number(rawIndex);
if (Number.isInteger(index) && index >= 0) {
indexes.add(index);
}
}
return indexes;
}
function DashboardAttachmentList({
attachments,
onPreview,
}: {
attachments: WorkReportAttachment[];
onPreview: (attachment: WorkReportAttachment) => void;
}) {
if (!attachments.length) {
return null;
}
return (
<div className="flex flex-wrap gap-2">
{attachments.map((attachment) => (
<button
key={attachment.url}
type="button"
onClick={() => onPreview(attachment)}
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-600 transition-colors hover:border-violet-200 hover:bg-violet-50 hover:text-violet-700"
title="预览附件"
>
<Paperclip className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[180px] truncate">{attachment.name || "附件"}</span>
<span className="shrink-0 text-slate-400">{formatAttachmentSize(attachment.size)}</span>
</button>
))}
</div>
);
}

View File

@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Search, Plus, Download, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter } from "lucide-react";
import { Search, Plus, Download, ChevronDown, Check, Building, Calendar, DollarSign, Activity, X, Clock, FileText, User, Tag, AlertTriangle, ListFilter, Paperclip } from "lucide-react";
import { motion, AnimatePresence } from "motion/react";
import { createPortal } from "react-dom";
import { useLocation } from "react-router-dom";
@ -31,8 +31,10 @@ import {
type OpportunityItem,
type PushOpportunityToOmsPayload,
type SalesExpansionItem,
type WorkReportAttachment,
} from "@/lib/auth";
import { AdaptiveSelect } from "@/components/AdaptiveSelect";
import { AttachmentPreviewModal, formatAttachmentSize } from "@/components/AttachmentPreviewModal";
import {
QuickChannelForm as SharedQuickChannelForm,
QuickSalesForm as SharedQuickSalesForm,
@ -1843,6 +1845,7 @@ export default function Opportunities() {
const [selectedItemDetail, setSelectedItemDetail] = useState<OpportunityItem | null>(null);
const [loadingSelectedItemDetail, setLoadingSelectedItemDetail] = useState(false);
const [selectedItemDetailError, setSelectedItemDetailError] = useState("");
const [previewAttachment, setPreviewAttachment] = useState<WorkReportAttachment | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [editOpen, setEditOpen] = useState(false);
const [pushConfirmOpen, setPushConfirmOpen] = useState(false);
@ -3635,6 +3638,12 @@ export default function Opportunities() {
<p className="mb-1 text-xs text-amber-700 dark:text-amber-300"></p>
<p className="whitespace-pre-wrap font-medium leading-6 text-slate-900 dark:text-white">{summary.communicationContent}</p>
</div>
{record.attachments?.length ? (
<OpportunityFollowUpAttachments
attachments={record.attachments}
onPreview={setPreviewAttachment}
/>
) : null}
<p className="mt-2 text-xs text-slate-400">: {record.user || "无"}<span className="ml-3">{record.date || "无"}</span></p>
</div>
</div>
@ -3709,6 +3718,38 @@ export default function Opportunities() {
</>
)}
</AnimatePresence>
{previewAttachment ? (
<AttachmentPreviewModal
attachment={previewAttachment}
onClose={() => setPreviewAttachment(null)}
/>
) : null}
</div>
);
}
function OpportunityFollowUpAttachments({
attachments,
onPreview,
}: {
attachments: WorkReportAttachment[];
onPreview: (attachment: WorkReportAttachment) => void;
}) {
return (
<div className="mt-3 flex flex-wrap gap-2">
{attachments.map((attachment) => (
<button
key={attachment.url}
type="button"
onClick={() => onPreview(attachment)}
className="inline-flex max-w-full items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-2.5 py-1.5 text-left text-xs text-slate-600 shadow-sm transition-colors hover:border-violet-200 hover:text-violet-600 dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300 dark:hover:border-violet-500/30 dark:hover:text-violet-300"
title="预览附件"
>
<Paperclip className="h-3.5 w-3.5 shrink-0" />
<span className="max-w-[180px] truncate">{attachment.name || "附件"}</span>
<span className="shrink-0 text-slate-400">{formatAttachmentSize(attachment.size)}</span>
</button>
))}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1 +0,0 @@
.report-reminder-page{max-width:1280px;height:100%;min-height:0;overflow:hidden}.report-reminder-scroll{flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;padding:0 4px 32px 0}.report-reminder-page .app-page__content-card{overflow:visible}.report-reminder-page .ant-row{row-gap:24px}.report-reminder-page__preview{background:#fafafa;border:1px dashed #d9d9d9;border-radius:12px;padding:12px 16px}.report-reminder-page__preview .ant-typography:last-child{margin-bottom:0}.report-reminder-page__template-tools{background:#fafafa;border:1px dashed #d9d9d9;border-radius:12px;padding:12px 16px;margin-bottom:12px}.report-reminder-page__template-tools-header{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:10px}.report-reminder-page__template-tools-tags{display:flex;flex-wrap:wrap;gap:8px}

View File

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>UnisBase - 智能会议系统</title>
<script type="module" crossorigin src="/assets/index-CfFV13db.js"></script>
<script type="module" crossorigin src="/assets/index-CibjLtv7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
</head>
<body>

View File

@ -33,6 +33,7 @@ export const DEFAULT_REPORT_REMINDER_CONFIG: ReportReminderConfig = {
submitNotifyTargetType: "USERS",
submitNotifyUserIds: [],
submitNotifyRoleIds: [],
submitNotifyRules: [{ orgIds: [], submitterUserIds: [], submitterRoleIds: [], userIds: [], roleIds: [] }],
submitNotifyTemplate: "【日报提交】{{submitterName}}已于{{submitTime}}提交{{reportDate}}日报,请及时查看。",
excludeSubmitter: true
};

View File

@ -51,8 +51,17 @@ const reportReminderEnUS = {
skipHoliday: "Skip holidays",
submitEnabled: "Enable submission notification",
submitTargetType: "Notification audience",
notifyUsers: "Notify users",
notifyRoles: "Notify roles",
submitNotifyRules: "Submission notification rules",
submitNotifyRulesHint: "Match rules by the submitter's organization, role, or specific person. Each matched rule can notify selected users or roles; configure either one.",
addSubmitNotifyRule: "Add rule",
submitNotifyRuleTitle: "Rule {{index}}",
deleteSubmitNotifyRule: "Delete rule",
deleteSubmitNotifyRuleConfirm: "Delete this submission notification rule?",
notifyRuleOrgs: "Submitter organizations (optional)",
notifyRuleSubmitterUsers: "Submitters (optional)",
notifyRuleSubmitterRoles: "Submitter roles (optional)",
notifyUsers: "Notify users (optional)",
notifyRoles: "Notify roles (optional)",
submitTemplate: "Submission notification template",
submitTemplatePlaceholder: "Enter the submission notification template",
excludeSubmitter: "Exclude submitter",
@ -83,6 +92,9 @@ const reportReminderEnUS = {
validationMissingOrgs: "Select at least one department to remind.",
validationNotifyUsers: "Select at least one user to notify.",
validationNotifyRoles: "Select at least one role to notify.",
validationNotifyRuleConditions: "For submission notification rule {{index}}, configure at least submitter organizations, submitter roles, or submitters.",
validationNotifyRuleOrgs: "Select submitter organizations for submission notification rule {{index}}.",
validationNotifyRuleReceivers: "For submission notification rule {{index}}, configure at least notify users or notify roles.",
validationTestReceiver: "Select a test receiver.",
placeholderRemindIndex: "Reminder index",
placeholderUserName: "Receiver name",

View File

@ -51,8 +51,17 @@ const reportReminderZhCN = {
skipHoliday: "节假日跳过",
submitEnabled: "开启提交后通知",
submitTargetType: "通知对象范围",
notifyUsers: "通知用户",
notifyRoles: "通知角色",
submitNotifyRules: "提交后通知规则",
submitNotifyRulesHint: "按提交人所属组织、角色或具体人员匹配规则;命中后会通知该规则下选择的用户或角色,通知用户和通知角色任选其一即可。",
addSubmitNotifyRule: "新增规则",
submitNotifyRuleTitle: "规则 {{index}}",
deleteSubmitNotifyRule: "删除规则",
deleteSubmitNotifyRuleConfirm: "确认删除这条提交后通知规则?",
notifyRuleOrgs: "提交人所属组织(可选)",
notifyRuleSubmitterUsers: "提交人(可选)",
notifyRuleSubmitterRoles: "提交人角色(可选)",
notifyUsers: "通知用户(可选)",
notifyRoles: "通知角色(可选)",
submitTemplate: "提交后通知模板",
submitTemplatePlaceholder: "请输入提交后通知模板",
excludeSubmitter: "排除提交人本人",
@ -83,6 +92,9 @@ const reportReminderZhCN = {
validationMissingOrgs: "请选择需要提醒的部门。",
validationNotifyUsers: "请选择提交后需要通知的用户。",
validationNotifyRoles: "请选择提交后需要通知的角色。",
validationNotifyRuleConditions: "第 {{index}} 条提交后通知规则中,提交人所属组织、提交人角色和提交人任选其一即可,请至少配置一个。",
validationNotifyRuleOrgs: "请选择第 {{index}} 条提交后通知规则的提交人所属组织。",
validationNotifyRuleReceivers: "第 {{index}} 条提交后通知规则中,通知用户和通知角色任选其一即可,请至少配置一个。",
validationTestReceiver: "请选择测试接收人。",
placeholderRemindIndex: "提醒序号",
placeholderUserName: "接收人姓名",

View File

@ -53,3 +53,37 @@
flex-wrap: wrap;
gap: 8px;
}
.report-reminder-page__rules {
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px 16px;
}
.report-reminder-page__rules-header,
.report-reminder-page__rule-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.report-reminder-page__rules-header {
margin-bottom: 12px;
}
.report-reminder-page__rule {
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 8px;
padding: 12px;
}
.report-reminder-page__rule + .report-reminder-page__rule {
margin-top: 12px;
}
.report-reminder-page__rule .ant-form-item {
margin-bottom: 12px;
}

View File

@ -7,6 +7,7 @@ import {
Form,
Input,
InputNumber,
Popconfirm,
Radio,
Row,
Select,
@ -22,6 +23,8 @@ import {
BellOutlined,
CalendarOutlined,
ClockCircleOutlined,
DeleteOutlined,
PlusOutlined,
SaveOutlined,
SendOutlined,
TeamOutlined
@ -61,6 +64,7 @@ import type {
BusinessCalendarStatus,
ReportReminderConfig,
ReportReminderTargetType,
ReportSubmitNotifyRule,
WecomAppConfig,
WecomConfigStatus
} from "@/features/report-reminder/types";
@ -144,6 +148,7 @@ function extractAppConfig(values: ReminderFormValues, tenantId: number): WecomAp
}
function extractReminderConfig(values: ReminderFormValues, tenantId: number): ReportReminderConfig {
const submitNotifyRules = normalizeSubmitNotifyRules(values.submitNotifyRules);
return {
tenantId,
wecomPushEnabled: values.wecomPushEnabled,
@ -162,13 +167,32 @@ function extractReminderConfig(values: ReminderFormValues, tenantId: number): Re
skipHoliday: values.skipHoliday,
submitNotifyEnabled: values.submitNotifyEnabled,
submitNotifyTargetType: values.submitNotifyTargetType,
submitNotifyUserIds: values.submitNotifyUserIds || [],
submitNotifyRoleIds: values.submitNotifyRoleIds || [],
submitNotifyUserIds: submitNotifyRules.flatMap((rule) => rule.userIds),
submitNotifyRoleIds: submitNotifyRules.flatMap((rule) => rule.roleIds),
submitNotifyRules,
submitNotifyTemplate: values.submitNotifyTemplate,
excludeSubmitter: values.excludeSubmitter
};
}
function normalizeSubmitNotifyRules(rules?: ReportSubmitNotifyRule[]) {
const normalized = (rules || []).map((rule) => ({
orgIds: Array.from(new Set((rule?.orgIds || []).filter(Boolean))),
submitterUserIds: Array.from(new Set((rule?.submitterUserIds || []).filter(Boolean))),
submitterRoleIds: Array.from(new Set((rule?.submitterRoleIds || []).filter(Boolean))),
userIds: Array.from(new Set((rule?.userIds || []).filter(Boolean))),
roleIds: Array.from(new Set((rule?.roleIds || []).filter(Boolean)))
})).filter((rule) => (
rule.orgIds.length > 0
|| rule.submitterUserIds.length > 0
|| rule.submitterRoleIds.length > 0
|| rule.userIds.length > 0
|| rule.roleIds.length > 0
));
return normalized.length > 0 ? normalized : [{ orgIds: [], submitterUserIds: [], submitterRoleIds: [], userIds: [], roleIds: [] }];
}
function validateMissingTargets(targetType: ReportReminderTargetType, values: ReminderFormValues, t: (key: string) => string) {
if (targetType === "USERS" && (!values.missingReportUserIds || values.missingReportUserIds.length === 0)) {
throw new Error(t("reportReminder.validationMissingUsers"));
@ -181,6 +205,18 @@ function validateMissingTargets(targetType: ReportReminderTargetType, values: Re
}
}
function validateSubmitNotifyRules(values: ReminderFormValues, t: (key: string) => string) {
const rules = normalizeSubmitNotifyRules(values.submitNotifyRules);
rules.forEach((rule, index) => {
if (rule.orgIds.length === 0 && rule.submitterUserIds.length === 0 && rule.submitterRoleIds.length === 0) {
throw new Error(t("reportReminder.validationNotifyRuleConditions").replace("{{index}}", String(index + 1)));
}
if (rule.userIds.length === 0 && rule.roleIds.length === 0) {
throw new Error(t("reportReminder.validationNotifyRuleReceivers").replace("{{index}}", String(index + 1)));
}
});
}
function insertPlaceholder(originalValue: string | undefined, token: string, textarea?: HTMLTextAreaElement | null) {
const currentValue = originalValue || "";
if (!textarea) {
@ -236,7 +272,6 @@ export default function ReportReminderSettings() {
const missingEnabled = Form.useWatch("missingReportEnabled", form) ?? DEFAULT_REPORT_REMINDER_CONFIG.missingReportEnabled;
const submitEnabled = Form.useWatch("submitNotifyEnabled", form) ?? DEFAULT_REPORT_REMINDER_CONFIG.submitNotifyEnabled;
const missingTargetType = Form.useWatch("missingReportTargetType", form) || DEFAULT_REPORT_REMINDER_CONFIG.missingReportTargetType;
const submitTargetType = Form.useWatch("submitNotifyTargetType", form) || DEFAULT_REPORT_REMINDER_CONFIG.submitNotifyTargetType;
const remindEndTime = Form.useWatch("remindEndTime", form) || DEFAULT_REPORT_REMINDER_CONFIG.remindEndTime;
const missingTemplate = Form.useWatch("missingReportTemplate", form) || DEFAULT_REPORT_REMINDER_CONFIG.missingReportTemplate;
const submitTemplate = Form.useWatch("submitNotifyTemplate", form) || DEFAULT_REPORT_REMINDER_CONFIG.submitNotifyTemplate;
@ -361,12 +396,7 @@ export default function ReportReminderSettings() {
validateMissingTargets(reminderConfig.missingReportTargetType, values, t);
}
if (reminderConfig.submitNotifyEnabled) {
if (reminderConfig.submitNotifyTargetType === "USERS" && reminderConfig.submitNotifyUserIds.length === 0) {
throw new Error(t("reportReminder.validationNotifyUsers"));
}
if (reminderConfig.submitNotifyTargetType === "ROLES" && reminderConfig.submitNotifyRoleIds.length === 0) {
throw new Error(t("reportReminder.validationNotifyRoles"));
}
validateSubmitNotifyRules(values, t);
}
setSaving(true);
@ -755,33 +785,6 @@ export default function ReportReminderSettings() {
<Switch disabled={!wecomAppEnabled} />
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item label={t("reportReminder.submitTargetType")} name="submitNotifyTargetType">
<Radio.Group
disabled={!wecomAppEnabled || !submitEnabled}
options={[
{ label: t("reportReminder.targetUsers"), value: "USERS" },
{ label: t("reportReminder.targetRoles"), value: "ROLES" }
]}
/>
</Form.Item>
</Col>
{submitTargetType === "USERS" && (
<Col span={24}>
<Form.Item label={t("reportReminder.notifyUsers")} name="submitNotifyUserIds">
<Select mode="multiple" allowClear showSearch optionFilterProp="label" options={userOptions} placeholder={t("reportReminder.selectUsers")} />
</Form.Item>
</Col>
)}
{submitTargetType === "ROLES" && (
<Col span={24}>
<Form.Item label={t("reportReminder.notifyRoles")} name="submitNotifyRoleIds">
<Select mode="multiple" allowClear showSearch optionFilterProp="label" options={roleOptions} placeholder={t("reportReminder.selectRoles")} />
</Form.Item>
</Col>
)}
<Col xs={24} md={12}>
<Form.Item label={t("reportReminder.excludeSubmitter")} name="excludeSubmitter" valuePropName="checked">
@ -789,6 +792,136 @@ export default function ReportReminderSettings() {
</Form.Item>
</Col>
<Col span={24}>
<div className="report-reminder-page__rules">
<Form.List name="submitNotifyRules">
{(fields, { add, remove }) => (
<>
<div className="report-reminder-page__rules-header">
<div>
<Text strong>{t("reportReminder.submitNotifyRules")}</Text>
<div>
<Text type="secondary">{t("reportReminder.submitNotifyRulesHint")}</Text>
</div>
</div>
<Button
icon={<PlusOutlined />}
onClick={() => add({ orgIds: [], submitterUserIds: [], submitterRoleIds: [], userIds: [], roleIds: [] })}
disabled={!wecomAppEnabled || !submitEnabled}
>
{t("reportReminder.addSubmitNotifyRule")}
</Button>
</div>
{(fields.length ? fields : []).map((field, index) => (
<div className="report-reminder-page__rule" key={field.key}>
<div className="report-reminder-page__rule-header">
<Text strong>{t("reportReminder.submitNotifyRuleTitle", { index: index + 1 })}</Text>
{fields.length > 1 ? (
<Popconfirm
title={t("reportReminder.deleteSubmitNotifyRuleConfirm")}
okText={t("common.confirm")}
cancelText={t("common.cancel")}
onConfirm={() => remove(field.name)}
>
<Button
danger
type="text"
icon={<DeleteOutlined />}
disabled={!wecomAppEnabled || !submitEnabled}
aria-label={t("reportReminder.deleteSubmitNotifyRule")}
/>
</Popconfirm>
) : null}
</div>
<Row gutter={12}>
<Col span={24}>
<Form.Item
label={t("reportReminder.notifyRuleOrgs")}
name={[field.name, "orgIds"]}
>
<TreeSelect
treeCheckable
multiple
allowClear
treeData={orgTreeData}
placeholder={t("reportReminder.selectOrgs")}
disabled={!wecomAppEnabled || !submitEnabled}
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
label={t("reportReminder.notifyRuleSubmitterUsers")}
name={[field.name, "submitterUserIds"]}
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
options={userOptions}
placeholder={t("reportReminder.selectUsers")}
disabled={!wecomAppEnabled || !submitEnabled}
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
label={t("reportReminder.notifyRuleSubmitterRoles")}
name={[field.name, "submitterRoleIds"]}
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
options={roleOptions}
placeholder={t("reportReminder.selectRoles")}
disabled={!wecomAppEnabled || !submitEnabled}
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
label={t("reportReminder.notifyUsers")}
name={[field.name, "userIds"]}
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
options={userOptions}
placeholder={t("reportReminder.selectUsers")}
disabled={!wecomAppEnabled || !submitEnabled}
/>
</Form.Item>
</Col>
<Col xs={24} md={12}>
<Form.Item
label={t("reportReminder.notifyRoles")}
name={[field.name, "roleIds"]}
>
<Select
mode="multiple"
allowClear
showSearch
optionFilterProp="label"
options={roleOptions}
placeholder={t("reportReminder.selectRoles")}
disabled={!wecomAppEnabled || !submitEnabled}
/>
</Form.Item>
</Col>
</Row>
</div>
))}
</>
)}
</Form.List>
</div>
</Col>
<Col span={24}>
<div className="report-reminder-page__template-tools">
<div className="report-reminder-page__template-tools-header">

View File

@ -2,6 +2,14 @@ export type ReportReminderTargetType = "ALL" | "USERS" | "ROLES" | "ORGS";
export type ReportNotifyTargetType = "USERS" | "ROLES";
export type ReportReminderTestMessageType = "MISSING_REPORT" | "SUBMIT_NOTIFY";
export interface ReportSubmitNotifyRule {
orgIds: number[];
submitterUserIds: number[];
submitterRoleIds: number[];
userIds: number[];
roleIds: number[];
}
export interface WecomAppConfig {
tenantId?: number;
enabled: boolean;
@ -30,6 +38,7 @@ export interface ReportReminderConfig {
submitNotifyTargetType: ReportNotifyTargetType;
submitNotifyUserIds: number[];
submitNotifyRoleIds: number[];
submitNotifyRules: ReportSubmitNotifyRule[];
submitNotifyTemplate: string;
excludeSubmitter: boolean;
}

View File

@ -0,0 +1,69 @@
-- 2026-06-24 CRM upgrade for PostgreSQL 17.
-- Includes:
-- 1. Daily report to-user messages and read tracking.
-- 2. WeCom message log identity sequence repair.
-- 3. Report reminder submit notification rules.
begin;
set search_path to public;
create table if not exists work_report_message (
id bigint generated by default as identity primary key,
report_id bigint not null,
sender_user_id bigint not null,
receiver_user_id bigint not null,
report_date date not null,
line_index integer not null default 0,
biz_type varchar(20) check (biz_type is null or biz_type in ('sales', 'channel', 'opportunity')),
biz_id bigint,
biz_name varchar(200),
content text not null,
read_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint fk_work_report_message_report
foreign key (report_id) references work_daily_report(id) on delete cascade,
constraint uk_work_report_message_report_line_receiver
unique (report_id, line_index, receiver_user_id)
);
alter table work_report_message
add column if not exists read_at timestamptz;
create index if not exists idx_work_report_message_receiver_time
on work_report_message (receiver_user_id, created_at desc);
create index if not exists idx_work_report_message_receiver_read_time
on work_report_message (receiver_user_id, read_at, created_at desc);
create index if not exists idx_work_report_message_report
on work_report_message (report_id);
do $$
declare
v_sequence_name text;
begin
if to_regclass('wecom_message_log') is not null then
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 if;
end $$;
alter table if exists report_reminder_config
add column if not exists submit_notify_rules text;
do $$
begin
if to_regclass('report_reminder_config') is not null then
comment on column report_reminder_config.submit_notify_rules is
'JSON array of submit notification rules. Each rule can match submitter orgs, submitter roles, or submitter users, then notify configured users or roles.';
end if;
end $$;
commit;

View File

@ -9,6 +9,7 @@
- 经营分析卡片历史展示配置修正脚本:`sql/upgrade_dashboard_analytics_card_display_config_pg17.sql`
- 商机实际签约金额字段升级脚本:`sql/upgrade_opportunity_actual_signed_amount_pg17.sql`
- 日报提醒功能初始化脚本:`sql/init_report_reminder_pg17.sql`
- 2026-06-24 CRM 增量升级脚本:`sql/20260624_report_reminder_and_work_report_upgrade_pg17.sql`
- 归属人转移权限初始化脚本:`sql/init_owner_transfer_permissions_pg17.sql`
- 一次性修复/导入工具脚本:
- `sql/import_oms_existing_opportunities_pg17.sql`
@ -56,6 +57,12 @@ psql -d your_database -f sql/upgrade_dashboard_analytics_prod_pg17.sql
psql -d your_database -f sql/init_report_reminder_pg17.sql
```
如果老环境需要上线 2026-06-24 本次未提交改动对应的数据库升级,请执行:
```bash
psql -d your_database -f sql/20260624_report_reminder_and_work_report_upgrade_pg17.sql
```
如果老环境需要补“归属人转移”权限,请执行:
```bash
@ -137,6 +144,9 @@ psql -d your_database -f sql/upgrade_dashboard_analytics_card_display_config_pg1
- `sql/init_report_reminder_pg17.sql`
老环境补齐“日报提醒”功能时使用的正式增量脚本,已包含表结构与权限。
- `sql/20260624_report_reminder_and_work_report_upgrade_pg17.sql`
2026-06-24 本次未提交改动对应的正式增量脚本,包含日报 to-user 消息表、消息已读追踪、企业微信消息日志序列修复、日报提交后通知规则字段。
- `sql/init_owner_transfer_permissions_pg17.sql`
老环境补齐“归属人转移”权限时使用的正式增量脚本。

View File

@ -303,6 +303,35 @@ create table if not exists work_daily_report_comment (
foreign key (report_id) references work_daily_report(id) on delete cascade
);
create table if not exists work_report_message (
id bigint generated by default as identity primary key,
report_id bigint not null,
sender_user_id bigint not null,
receiver_user_id bigint not null,
report_date date not null,
line_index integer not null default 0,
biz_type varchar(20) check (biz_type is null or biz_type in ('sales', 'channel', 'opportunity')),
biz_id bigint,
biz_name varchar(200),
content text not null,
read_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
constraint fk_work_report_message_report
foreign key (report_id) references work_daily_report(id) on delete cascade,
constraint uk_work_report_message_report_line_receiver
unique (report_id, line_index, receiver_user_id)
);
create index if not exists idx_work_report_message_receiver_time
on work_report_message (receiver_user_id, created_at desc);
create index if not exists idx_work_report_message_receiver_read_time
on work_report_message (receiver_user_id, read_at, created_at desc);
create index if not exists idx_work_report_message_report
on work_report_message (report_id);
create table if not exists work_todo (
id bigint generated by default as identity primary key,
user_id bigint not null,

View File

@ -35,6 +35,7 @@ create table if not exists report_reminder_config (
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(),
@ -44,6 +45,7 @@ create table if not exists report_reminder_config (
alter table if exists report_reminder_config add column if not exists missing_report_template text;
alter table if exists report_reminder_config add column if not exists submit_notify_template text;
alter table if exists report_reminder_config add column if not exists submit_notify_rules text;
create table if not exists wecom_user_mapping (
id bigint generated by default as identity primary key,