日报功能与日志提醒功能优化
parent
fec99908ed
commit
d917806dc2
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 "";
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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) <> '' 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'
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "接收人姓名",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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`
|
||||
老环境补齐“归属人转移”权限时使用的正式增量脚本。
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue