添加同步本年年度日历。
parent
aa5ff4f073
commit
8dc9fe2117
|
|
@ -0,0 +1,130 @@
|
|||
package com.unis.crm.common;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import javax.sql.DataSource;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class BusinessCalendarSchemaInitializer implements ApplicationRunner {
|
||||
|
||||
private final DataSource dataSource;
|
||||
|
||||
public BusinessCalendarSchemaInitializer(DataSource dataSource) {
|
||||
this.dataSource = dataSource;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
try (Connection connection = dataSource.getConnection();
|
||||
Statement statement = connection.createStatement()) {
|
||||
statement.execute("""
|
||||
create table if not exists business_calendar_day (
|
||||
id bigint generated by default as identity primary key,
|
||||
calendar_date date not null,
|
||||
calendar_year integer not null,
|
||||
day_of_week integer not null,
|
||||
day_type_code integer not null default 0,
|
||||
day_type varchar(32) not null default 'WORKDAY',
|
||||
day_name varchar(64) not null,
|
||||
holiday_name varchar(100),
|
||||
holiday_target varchar(100),
|
||||
holiday_wage integer,
|
||||
is_weekend boolean not null default false,
|
||||
is_rest_day boolean not null default false,
|
||||
is_workday boolean not null default true,
|
||||
is_holiday boolean not null default false,
|
||||
is_legal_holiday boolean not null default false,
|
||||
is_makeup_workday boolean not null default false,
|
||||
source varchar(50) not null default 'timor',
|
||||
synced_by_user_id bigint,
|
||||
synced_at timestamptz not null default now(),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint uk_business_calendar_day_date unique (calendar_date)
|
||||
)
|
||||
""");
|
||||
statement.execute("""
|
||||
create index if not exists idx_business_calendar_day_year_date
|
||||
on business_calendar_day (calendar_year asc, calendar_date asc)
|
||||
""");
|
||||
statement.execute("""
|
||||
create index if not exists idx_business_calendar_day_type
|
||||
on business_calendar_day (day_type asc, calendar_date asc)
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists calendar_year integer not null default 1970
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists day_of_week integer not null default 1
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists day_type_code integer not null default 0
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists day_type varchar(32) not null default 'WORKDAY'
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists day_name varchar(64) not null default '周一'
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists holiday_name varchar(100)
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists holiday_target varchar(100)
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists holiday_wage integer
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists is_weekend boolean not null default false
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists is_rest_day boolean not null default false
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists is_workday boolean not null default true
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists is_holiday boolean not null default false
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists is_legal_holiday boolean not null default false
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists is_makeup_workday boolean not null default false
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists source varchar(50) not null default 'timor'
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists synced_by_user_id bigint
|
||||
""");
|
||||
statement.execute("""
|
||||
alter table business_calendar_day
|
||||
add column if not exists synced_at timestamptz not null default now()
|
||||
""");
|
||||
} catch (SQLException exception) {
|
||||
throw new IllegalStateException("Failed to initialize business calendar schema", exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,10 +3,13 @@ package com.unis.crm.controller;
|
|||
import com.unis.crm.common.ApiResponse;
|
||||
import com.unis.crm.dto.dashboard.DashboardAnalyticsCardDTO;
|
||||
import com.unis.crm.dto.dashboard.DashboardAnalyticsPanelDTO;
|
||||
import com.unis.crm.dto.dashboardanalytics.BusinessCalendarSyncResultDTO;
|
||||
import com.unis.crm.dto.dashboardanalytics.DashboardAnalyticsConfigDTO;
|
||||
import com.unis.crm.service.BusinessCalendarService;
|
||||
import com.unis.crm.service.DashboardAnalyticsConfigService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
|
@ -18,9 +21,13 @@ import org.springframework.web.bind.annotation.RestController;
|
|||
public class DashboardAnalyticsAdminController {
|
||||
|
||||
private final DashboardAnalyticsConfigService dashboardAnalyticsConfigService;
|
||||
private final BusinessCalendarService businessCalendarService;
|
||||
|
||||
public DashboardAnalyticsAdminController(DashboardAnalyticsConfigService dashboardAnalyticsConfigService) {
|
||||
public DashboardAnalyticsAdminController(
|
||||
DashboardAnalyticsConfigService dashboardAnalyticsConfigService,
|
||||
BusinessCalendarService businessCalendarService) {
|
||||
this.dashboardAnalyticsConfigService = dashboardAnalyticsConfigService;
|
||||
this.businessCalendarService = businessCalendarService;
|
||||
}
|
||||
|
||||
@GetMapping("/dashboard-analytics-config")
|
||||
|
|
@ -45,4 +52,10 @@ public class DashboardAnalyticsAdminController {
|
|||
@RequestParam(value = "dimension", required = false) String dimension) {
|
||||
return ApiResponse.success(dashboardAnalyticsConfigService.previewCard(tenantId, cardKey, dimension));
|
||||
}
|
||||
|
||||
@PostMapping("/dashboard-analytics-config/calendar/sync-current-year")
|
||||
public ApiResponse<BusinessCalendarSyncResultDTO> syncCurrentYearCalendar(
|
||||
@RequestParam(value = "tenantId", required = false) Long tenantId) {
|
||||
return ApiResponse.success(businessCalendarService.syncCurrentYear(tenantId));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.unis.crm.controller;
|
||||
|
||||
import com.unis.crm.common.ApiResponse;
|
||||
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.ReportReminderTestRequest;
|
||||
import com.unis.crm.dto.reminder.WecomAppConfigDTO;
|
||||
|
|
@ -45,6 +47,18 @@ public class ReportReminderAdminController {
|
|||
return ApiResponse.success(reportReminderService.getReportReminderConfig(tenantId));
|
||||
}
|
||||
|
||||
@GetMapping("/report-reminder-calendar-status")
|
||||
public ApiResponse<BusinessCalendarStatusDTO> getReportReminderCalendarStatus(
|
||||
@RequestParam(value = "tenantId", required = false) Long tenantId) {
|
||||
return ApiResponse.success(reportReminderService.getBusinessCalendarStatus(tenantId));
|
||||
}
|
||||
|
||||
@PostMapping("/report-reminder-calendar/sync-current-year")
|
||||
public ApiResponse<BusinessCalendarSyncResultDTO> syncReportReminderCalendar(
|
||||
@RequestParam(value = "tenantId", required = false) Long tenantId) {
|
||||
return ApiResponse.success(reportReminderService.syncCurrentYearBusinessCalendar(tenantId));
|
||||
}
|
||||
|
||||
@PutMapping("/report-reminder-config")
|
||||
public ApiResponse<Boolean> updateReportReminderConfig(@Valid @RequestBody ReportReminderConfigDTO payload) {
|
||||
return ApiResponse.success(reportReminderService.saveReportReminderConfig(payload));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
package com.unis.crm.dto.dashboardanalytics;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public class BusinessCalendarStatusDTO {
|
||||
|
||||
private Integer year;
|
||||
private Boolean synced;
|
||||
private Boolean complete;
|
||||
private Integer totalDays;
|
||||
private Integer expectedDays;
|
||||
private Integer holidayDays;
|
||||
private Integer restDays;
|
||||
private Integer makeupWorkdayDays;
|
||||
private Integer workdayDays;
|
||||
private OffsetDateTime lastSyncedAt;
|
||||
|
||||
public Integer getYear() {
|
||||
return year;
|
||||
}
|
||||
|
||||
public void setYear(Integer year) {
|
||||
this.year = year;
|
||||
}
|
||||
|
||||
public Boolean getSynced() {
|
||||
return synced;
|
||||
}
|
||||
|
||||
public void setSynced(Boolean synced) {
|
||||
this.synced = synced;
|
||||
}
|
||||
|
||||
public Boolean getComplete() {
|
||||
return complete;
|
||||
}
|
||||
|
||||
public void setComplete(Boolean complete) {
|
||||
this.complete = complete;
|
||||
}
|
||||
|
||||
public Integer getTotalDays() {
|
||||
return totalDays;
|
||||
}
|
||||
|
||||
public void setTotalDays(Integer totalDays) {
|
||||
this.totalDays = totalDays;
|
||||
}
|
||||
|
||||
public Integer getExpectedDays() {
|
||||
return expectedDays;
|
||||
}
|
||||
|
||||
public void setExpectedDays(Integer expectedDays) {
|
||||
this.expectedDays = expectedDays;
|
||||
}
|
||||
|
||||
public Integer getHolidayDays() {
|
||||
return holidayDays;
|
||||
}
|
||||
|
||||
public void setHolidayDays(Integer holidayDays) {
|
||||
this.holidayDays = holidayDays;
|
||||
}
|
||||
|
||||
public Integer getRestDays() {
|
||||
return restDays;
|
||||
}
|
||||
|
||||
public void setRestDays(Integer restDays) {
|
||||
this.restDays = restDays;
|
||||
}
|
||||
|
||||
public Integer getMakeupWorkdayDays() {
|
||||
return makeupWorkdayDays;
|
||||
}
|
||||
|
||||
public void setMakeupWorkdayDays(Integer makeupWorkdayDays) {
|
||||
this.makeupWorkdayDays = makeupWorkdayDays;
|
||||
}
|
||||
|
||||
public Integer getWorkdayDays() {
|
||||
return workdayDays;
|
||||
}
|
||||
|
||||
public void setWorkdayDays(Integer workdayDays) {
|
||||
this.workdayDays = workdayDays;
|
||||
}
|
||||
|
||||
public OffsetDateTime getLastSyncedAt() {
|
||||
return lastSyncedAt;
|
||||
}
|
||||
|
||||
public void setLastSyncedAt(OffsetDateTime lastSyncedAt) {
|
||||
this.lastSyncedAt = lastSyncedAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package com.unis.crm.dto.dashboardanalytics;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
|
||||
public class BusinessCalendarSyncResultDTO {
|
||||
|
||||
private Integer year;
|
||||
private Integer totalDays;
|
||||
private Integer holidayDays;
|
||||
private Integer legalHolidayDays;
|
||||
private Integer restDays;
|
||||
private Integer makeupWorkdayDays;
|
||||
private Integer workdayDays;
|
||||
private OffsetDateTime syncedAt;
|
||||
|
||||
public Integer getYear() {
|
||||
return year;
|
||||
}
|
||||
|
||||
public void setYear(Integer year) {
|
||||
this.year = year;
|
||||
}
|
||||
|
||||
public Integer getTotalDays() {
|
||||
return totalDays;
|
||||
}
|
||||
|
||||
public void setTotalDays(Integer totalDays) {
|
||||
this.totalDays = totalDays;
|
||||
}
|
||||
|
||||
public Integer getHolidayDays() {
|
||||
return holidayDays;
|
||||
}
|
||||
|
||||
public void setHolidayDays(Integer holidayDays) {
|
||||
this.holidayDays = holidayDays;
|
||||
}
|
||||
|
||||
public Integer getLegalHolidayDays() {
|
||||
return legalHolidayDays;
|
||||
}
|
||||
|
||||
public void setLegalHolidayDays(Integer legalHolidayDays) {
|
||||
this.legalHolidayDays = legalHolidayDays;
|
||||
}
|
||||
|
||||
public Integer getRestDays() {
|
||||
return restDays;
|
||||
}
|
||||
|
||||
public void setRestDays(Integer restDays) {
|
||||
this.restDays = restDays;
|
||||
}
|
||||
|
||||
public Integer getMakeupWorkdayDays() {
|
||||
return makeupWorkdayDays;
|
||||
}
|
||||
|
||||
public void setMakeupWorkdayDays(Integer makeupWorkdayDays) {
|
||||
this.makeupWorkdayDays = makeupWorkdayDays;
|
||||
}
|
||||
|
||||
public Integer getWorkdayDays() {
|
||||
return workdayDays;
|
||||
}
|
||||
|
||||
public void setWorkdayDays(Integer workdayDays) {
|
||||
this.workdayDays = workdayDays;
|
||||
}
|
||||
|
||||
public OffsetDateTime getSyncedAt() {
|
||||
return syncedAt;
|
||||
}
|
||||
|
||||
public void setSyncedAt(OffsetDateTime syncedAt) {
|
||||
this.syncedAt = syncedAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ public class ChannelExpansionItemDTO {
|
|||
private String stage;
|
||||
private Boolean landed;
|
||||
private String expectedSignDate;
|
||||
private String createdAt;
|
||||
private String updatedAt;
|
||||
private String notes;
|
||||
private List<ChannelExpansionContactDTO> contacts = new ArrayList<>();
|
||||
|
|
@ -299,6 +300,14 @@ public class ChannelExpansionItemDTO {
|
|||
this.expectedSignDate = expectedSignDate;
|
||||
}
|
||||
|
||||
public String getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(String createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ public class SalesExpansionItemDTO {
|
|||
private Boolean active;
|
||||
private String employmentStatus;
|
||||
private String expectedJoinDate;
|
||||
private String createdAt;
|
||||
private String updatedAt;
|
||||
private String notes;
|
||||
private java.util.List<RelatedProjectSummaryDTO> relatedProjects = new java.util.ArrayList<>();
|
||||
|
|
@ -226,6 +227,14 @@ public class SalesExpansionItemDTO {
|
|||
this.expectedJoinDate = expectedJoinDate;
|
||||
}
|
||||
|
||||
public String getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(String createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ public class OpportunityItemDTO {
|
|||
private String name;
|
||||
private String client;
|
||||
private String owner;
|
||||
private String createdAt;
|
||||
private String updatedAt;
|
||||
private String projectLocation;
|
||||
private String operatorCode;
|
||||
|
|
@ -90,6 +91,14 @@ public class OpportunityItemDTO {
|
|||
this.owner = owner;
|
||||
}
|
||||
|
||||
public String getCreatedAt() {
|
||||
return createdAt;
|
||||
}
|
||||
|
||||
public void setCreatedAt(String createdAt) {
|
||||
this.createdAt = createdAt;
|
||||
}
|
||||
|
||||
public String getUpdatedAt() {
|
||||
return updatedAt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
package com.unis.crm.service;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
public class BusinessCalendarAutoSync {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BusinessCalendarAutoSync.class);
|
||||
|
||||
private final BusinessCalendarService businessCalendarService;
|
||||
private final boolean autoSyncEnabled;
|
||||
|
||||
public BusinessCalendarAutoSync(
|
||||
BusinessCalendarService businessCalendarService,
|
||||
@Value("${unisbase.app.business-calendar.auto-sync-enabled:true}") boolean autoSyncEnabled) {
|
||||
this.businessCalendarService = businessCalendarService;
|
||||
this.autoSyncEnabled = autoSyncEnabled;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void syncAfterStartup() {
|
||||
triggerSync("startup");
|
||||
}
|
||||
|
||||
@Scheduled(cron = "${unisbase.app.business-calendar.auto-sync-cron:0 15 3 * * *}")
|
||||
public void syncBySchedule() {
|
||||
triggerSync("schedule");
|
||||
}
|
||||
|
||||
private void triggerSync(String trigger) {
|
||||
if (!autoSyncEnabled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
businessCalendarService.ensureCurrentYearSyncedIfMissing();
|
||||
} catch (Exception exception) {
|
||||
log.warn("Failed to auto sync business calendar on {}", trigger, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,697 @@
|
|||
package com.unis.crm.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unis.crm.common.BusinessException;
|
||||
import com.unis.crm.common.UnauthorizedException;
|
||||
import com.unis.crm.dto.dashboardanalytics.BusinessCalendarStatusDTO;
|
||||
import com.unis.crm.dto.dashboardanalytics.BusinessCalendarSyncResultDTO;
|
||||
import com.unisbase.security.PermissionService;
|
||||
import com.unisbase.security.SpringSecurityTenantProvider;
|
||||
import com.unisbase.security.SpringSecurityUserProvider;
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Date;
|
||||
import java.sql.PreparedStatement;
|
||||
import java.sql.SQLException;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.dao.DataAccessException;
|
||||
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
@Service
|
||||
public class BusinessCalendarService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BusinessCalendarService.class);
|
||||
private static final String UPDATE_PERM = "dashboard_analytics_config:update";
|
||||
private static final ZoneId DEFAULT_ZONE_ID = ZoneId.of("Asia/Shanghai");
|
||||
private static final String DEFAULT_SOURCE = "ailcc";
|
||||
private static final String FALLBACK_SOURCE_2026 = "gov_cn_2026";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final PermissionService permissionService;
|
||||
private final SpringSecurityTenantProvider tenantProvider;
|
||||
private final SpringSecurityUserProvider userProvider;
|
||||
private final HttpClient httpClient;
|
||||
private final String batchApiBaseUrl;
|
||||
|
||||
public BusinessCalendarService(
|
||||
JdbcTemplate jdbcTemplate,
|
||||
ObjectMapper objectMapper,
|
||||
PermissionService permissionService,
|
||||
SpringSecurityTenantProvider tenantProvider,
|
||||
SpringSecurityUserProvider userProvider,
|
||||
@Value("${unisbase.app.business-calendar.batch-api-base-url:https://holiday.ailcc.com/api/holiday/year}")
|
||||
String batchApiBaseUrl) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.permissionService = permissionService;
|
||||
this.tenantProvider = tenantProvider;
|
||||
this.userProvider = userProvider;
|
||||
this.batchApiBaseUrl = batchApiBaseUrl;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(8))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessCalendarSyncResultDTO syncCurrentYear() {
|
||||
requirePermission(UPDATE_PERM, "无权同步业务日历");
|
||||
requireTenantSelected();
|
||||
return syncCurrentYearInternal(requireCurrentUserId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessCalendarSyncResultDTO syncCurrentYear(Long tenantId) {
|
||||
requirePermission(UPDATE_PERM, "无权同步业务日历");
|
||||
resolveTenantId(tenantId);
|
||||
return syncCurrentYearInternal(requireCurrentUserId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
BusinessCalendarSyncResultDTO syncCurrentYearFromReminderModule() {
|
||||
return syncCurrentYearInternal(requireCurrentUserId());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void ensureCurrentYearSyncedIfMissing() {
|
||||
BusinessCalendarStatusDTO status = getCurrentYearStatusSnapshot();
|
||||
if (Boolean.TRUE.equals(status.getComplete())) {
|
||||
return;
|
||||
}
|
||||
int year = defaultYear(status.getYear());
|
||||
log.info("Business calendar for year {} is incomplete ({} / {}), start auto sync",
|
||||
year, defaultInt(status.getTotalDays()), defaultInt(status.getExpectedDays()));
|
||||
syncYear(year, null);
|
||||
}
|
||||
|
||||
BusinessCalendarStatusDTO getCurrentYearStatusSnapshot() {
|
||||
int year = LocalDate.now(DEFAULT_ZONE_ID).getYear();
|
||||
int expectedDays = LocalDate.of(year, 1, 1).lengthOfYear();
|
||||
ensureBusinessCalendarTableReady();
|
||||
List<BusinessCalendarStatusDTO> rows = jdbcTemplate.query("""
|
||||
select
|
||||
? as year,
|
||||
count(1) as total_days,
|
||||
sum(case when is_holiday then 1 else 0 end) as holiday_days,
|
||||
sum(case when is_rest_day then 1 else 0 end) as rest_days,
|
||||
sum(case when is_makeup_workday then 1 else 0 end) as makeup_workday_days,
|
||||
sum(case when is_workday then 1 else 0 end) as workday_days,
|
||||
max(synced_at) as last_synced_at
|
||||
from business_calendar_day
|
||||
where calendar_year = ?
|
||||
""", (resultSet, rowNum) -> {
|
||||
BusinessCalendarStatusDTO dto = new BusinessCalendarStatusDTO();
|
||||
dto.setYear(resultSet.getInt("year"));
|
||||
dto.setTotalDays(resultSet.getInt("total_days"));
|
||||
dto.setExpectedDays(expectedDays);
|
||||
dto.setHolidayDays(defaultInt((Number) resultSet.getObject("holiday_days")));
|
||||
dto.setRestDays(defaultInt((Number) resultSet.getObject("rest_days")));
|
||||
dto.setMakeupWorkdayDays(defaultInt((Number) resultSet.getObject("makeup_workday_days")));
|
||||
dto.setWorkdayDays(defaultInt((Number) resultSet.getObject("workday_days")));
|
||||
dto.setLastSyncedAt(resultSet.getObject("last_synced_at", OffsetDateTime.class));
|
||||
return dto;
|
||||
}, year, year);
|
||||
BusinessCalendarStatusDTO dto = rows.isEmpty() ? new BusinessCalendarStatusDTO() : rows.get(0);
|
||||
dto.setYear(year);
|
||||
dto.setExpectedDays(expectedDays);
|
||||
dto.setTotalDays(defaultInt(dto.getTotalDays()));
|
||||
dto.setHolidayDays(defaultInt(dto.getHolidayDays()));
|
||||
dto.setRestDays(defaultInt(dto.getRestDays()));
|
||||
dto.setMakeupWorkdayDays(defaultInt(dto.getMakeupWorkdayDays()));
|
||||
dto.setWorkdayDays(defaultInt(dto.getWorkdayDays()));
|
||||
dto.setSynced(dto.getTotalDays() > 0);
|
||||
dto.setComplete(dto.getTotalDays() >= expectedDays);
|
||||
return dto;
|
||||
}
|
||||
|
||||
public boolean isWorkday(LocalDate date) {
|
||||
if (date == null) {
|
||||
return false;
|
||||
}
|
||||
List<Boolean> rows = jdbcTemplate.query("""
|
||||
select is_workday
|
||||
from business_calendar_day
|
||||
where calendar_date = ?
|
||||
limit 1
|
||||
""", (resultSet, rowNum) -> resultSet.getBoolean("is_workday"), Date.valueOf(date));
|
||||
if (!rows.isEmpty()) {
|
||||
return Boolean.TRUE.equals(rows.get(0));
|
||||
}
|
||||
return !isWeekend(date.getDayOfWeek());
|
||||
}
|
||||
|
||||
public boolean isHoliday(LocalDate date) {
|
||||
if (date == null) {
|
||||
return false;
|
||||
}
|
||||
List<Boolean> rows = jdbcTemplate.query("""
|
||||
select is_holiday
|
||||
from business_calendar_day
|
||||
where calendar_date = ?
|
||||
limit 1
|
||||
""", (resultSet, rowNum) -> resultSet.getBoolean("is_holiday"), Date.valueOf(date));
|
||||
return !rows.isEmpty() && Boolean.TRUE.equals(rows.get(0));
|
||||
}
|
||||
|
||||
private BusinessCalendarSyncResultDTO syncCurrentYearInternal(Long currentUserId) {
|
||||
int year = LocalDate.now(DEFAULT_ZONE_ID).getYear();
|
||||
return syncYear(year, currentUserId);
|
||||
}
|
||||
|
||||
private BusinessCalendarSyncResultDTO syncYear(int year, Long currentUserId) {
|
||||
OffsetDateTime syncedAt = OffsetDateTime.now(DEFAULT_ZONE_ID);
|
||||
Map<LocalDate, BusinessCalendarDayRecord> dayMap = initYearDays(year, currentUserId, syncedAt);
|
||||
ensureBusinessCalendarTableReady();
|
||||
try {
|
||||
List<LocalDate> allDates = new ArrayList<>(dayMap.keySet());
|
||||
for (int start = 0; start < allDates.size(); start += 50) {
|
||||
int end = Math.min(start + 50, allDates.size());
|
||||
List<LocalDate> chunk = allDates.subList(start, end);
|
||||
mergeBatchCalendar(dayMap, chunk, fetchBatchPayload(chunk));
|
||||
}
|
||||
} catch (BusinessException exception) {
|
||||
if (!applyOfficialHolidayFallback(dayMap, year)) {
|
||||
throw exception;
|
||||
}
|
||||
log.warn("Business calendar upstream unavailable for year {}, fallback to official configured schedule: {}",
|
||||
year, exception.getMessage());
|
||||
}
|
||||
List<BusinessCalendarDayRecord> rows = new ArrayList<>(dayMap.values());
|
||||
batchUpsert(rows);
|
||||
return buildSyncResult(year, rows, syncedAt);
|
||||
}
|
||||
|
||||
private void mergeBatchCalendar(Map<LocalDate, BusinessCalendarDayRecord> dayMap, List<LocalDate> dates, JsonNode root) {
|
||||
JsonNode holidayNode = root.path("holiday");
|
||||
JsonNode typeNode = root.path("type");
|
||||
boolean ailccFormat = holidayNode.isObject() && !typeNode.isObject();
|
||||
if (!holidayNode.isObject() || (!typeNode.isObject() && !ailccFormat)) {
|
||||
throw new BusinessException("业务日历接口返回格式不正确");
|
||||
}
|
||||
for (LocalDate calendarDate : dates) {
|
||||
String dateText = ailccFormat
|
||||
? String.format(Locale.ROOT, "%02d-%02d", calendarDate.getMonthValue(), calendarDate.getDayOfMonth())
|
||||
: calendarDate.toString();
|
||||
BusinessCalendarDayRecord target = dayMap.get(calendarDate);
|
||||
if (target == null) {
|
||||
continue;
|
||||
}
|
||||
if (!ailccFormat) {
|
||||
JsonNode typeInfo = typeNode.get(dateText);
|
||||
if (typeInfo != null && !typeInfo.isNull()) {
|
||||
int typeCode = resolveTypeCode(typeInfo, calendarDate.getDayOfWeek());
|
||||
applyTypeInfo(target, calendarDate, typeCode, typeInfo);
|
||||
}
|
||||
}
|
||||
JsonNode holidayInfo = holidayNode.get(dateText);
|
||||
if (holidayInfo != null && !holidayInfo.isNull()) {
|
||||
applyHolidayInfo(target, calendarDate, holidayInfo);
|
||||
}
|
||||
}
|
||||
applyKnownMakeupWorkdays(dayMap, dates);
|
||||
}
|
||||
|
||||
private JsonNode fetchBatchPayload(List<LocalDate> dates) {
|
||||
if (dates == null || dates.isEmpty()) {
|
||||
throw new BusinessException("业务日历同步日期不能为空");
|
||||
}
|
||||
String requestUrl = buildBatchUrl(dates);
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(requestUrl))
|
||||
.timeout(Duration.ofSeconds(12))
|
||||
.header("Accept", "application/json")
|
||||
.GET()
|
||||
.build();
|
||||
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() < 200 || response.statusCode() >= 300) {
|
||||
throw new BusinessException("业务日历接口请求失败,HTTP " + response.statusCode());
|
||||
}
|
||||
JsonNode root = objectMapper.readTree(response.body());
|
||||
if (root.path("code").asInt(-1) != 0) {
|
||||
throw new BusinessException(trimToNull(root.path("msg").asText(null)) != null
|
||||
? root.path("msg").asText()
|
||||
: "业务日历接口返回失败");
|
||||
}
|
||||
return root;
|
||||
} catch (IOException exception) {
|
||||
throw new BusinessException("解析业务日历响应失败");
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BusinessException("业务日历同步被中断");
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw new BusinessException("业务日历接口地址无效");
|
||||
}
|
||||
}
|
||||
|
||||
private String buildBatchUrl(List<LocalDate> dates) {
|
||||
LocalDate firstDate = dates.get(0);
|
||||
String yearText = URLEncoder.encode(String.valueOf(firstDate.getYear()), StandardCharsets.UTF_8);
|
||||
return batchApiBaseUrl.endsWith("/")
|
||||
? batchApiBaseUrl + yearText
|
||||
: batchApiBaseUrl + "/" + yearText;
|
||||
}
|
||||
|
||||
private Map<LocalDate, BusinessCalendarDayRecord> initYearDays(int year, Long currentUserId, OffsetDateTime syncedAt) {
|
||||
Map<LocalDate, BusinessCalendarDayRecord> dayMap = new LinkedHashMap<>();
|
||||
LocalDate cursor = LocalDate.of(year, 1, 1);
|
||||
LocalDate end = cursor.withMonth(12).withDayOfMonth(31);
|
||||
while (!cursor.isAfter(end)) {
|
||||
boolean weekend = isWeekend(cursor.getDayOfWeek());
|
||||
BusinessCalendarDayRecord record = new BusinessCalendarDayRecord();
|
||||
record.calendarDate = cursor;
|
||||
record.calendarYear = year;
|
||||
record.dayOfWeek = cursor.getDayOfWeek().getValue();
|
||||
record.dayTypeCode = weekend ? 1 : 0;
|
||||
record.dayType = weekend ? "WEEKEND" : "WORKDAY";
|
||||
record.dayName = weekdayLabel(cursor.getDayOfWeek());
|
||||
record.holidayName = null;
|
||||
record.holidayTarget = null;
|
||||
record.holidayWage = null;
|
||||
record.weekend = weekend;
|
||||
record.restDay = weekend;
|
||||
record.workday = !weekend;
|
||||
record.holiday = false;
|
||||
record.legalHoliday = false;
|
||||
record.makeupWorkday = false;
|
||||
record.source = DEFAULT_SOURCE;
|
||||
record.syncedByUserId = currentUserId;
|
||||
record.syncedAt = syncedAt;
|
||||
dayMap.put(cursor, record);
|
||||
cursor = cursor.plusDays(1);
|
||||
}
|
||||
return dayMap;
|
||||
}
|
||||
|
||||
private void batchUpsert(List<BusinessCalendarDayRecord> rows) {
|
||||
jdbcTemplate.batchUpdate("""
|
||||
insert into business_calendar_day (
|
||||
calendar_date,
|
||||
calendar_year,
|
||||
day_of_week,
|
||||
day_type_code,
|
||||
day_type,
|
||||
day_name,
|
||||
holiday_name,
|
||||
holiday_target,
|
||||
holiday_wage,
|
||||
is_weekend,
|
||||
is_rest_day,
|
||||
is_workday,
|
||||
is_holiday,
|
||||
is_legal_holiday,
|
||||
is_makeup_workday,
|
||||
source,
|
||||
synced_by_user_id,
|
||||
synced_at,
|
||||
created_at,
|
||||
updated_at
|
||||
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now())
|
||||
on conflict (calendar_date) do update
|
||||
set calendar_year = excluded.calendar_year,
|
||||
day_of_week = excluded.day_of_week,
|
||||
day_type_code = excluded.day_type_code,
|
||||
day_type = excluded.day_type,
|
||||
day_name = excluded.day_name,
|
||||
holiday_name = excluded.holiday_name,
|
||||
holiday_target = excluded.holiday_target,
|
||||
holiday_wage = excluded.holiday_wage,
|
||||
is_weekend = excluded.is_weekend,
|
||||
is_rest_day = excluded.is_rest_day,
|
||||
is_workday = excluded.is_workday,
|
||||
is_holiday = excluded.is_holiday,
|
||||
is_legal_holiday = excluded.is_legal_holiday,
|
||||
is_makeup_workday = excluded.is_makeup_workday,
|
||||
source = excluded.source,
|
||||
synced_by_user_id = excluded.synced_by_user_id,
|
||||
synced_at = excluded.synced_at,
|
||||
updated_at = now()
|
||||
""",
|
||||
new BatchPreparedStatementSetter() {
|
||||
@Override
|
||||
public void setValues(PreparedStatement statement, int index) throws SQLException {
|
||||
BusinessCalendarDayRecord row = rows.get(index);
|
||||
statement.setDate(1, Date.valueOf(row.calendarDate));
|
||||
statement.setInt(2, row.calendarYear);
|
||||
statement.setInt(3, row.dayOfWeek);
|
||||
statement.setInt(4, row.dayTypeCode);
|
||||
statement.setString(5, row.dayType);
|
||||
statement.setString(6, row.dayName);
|
||||
statement.setString(7, row.holidayName);
|
||||
statement.setString(8, row.holidayTarget);
|
||||
if (row.holidayWage == null) {
|
||||
statement.setObject(9, null);
|
||||
} else {
|
||||
statement.setInt(9, row.holidayWage);
|
||||
}
|
||||
statement.setBoolean(10, row.weekend);
|
||||
statement.setBoolean(11, row.restDay);
|
||||
statement.setBoolean(12, row.workday);
|
||||
statement.setBoolean(13, row.holiday);
|
||||
statement.setBoolean(14, row.legalHoliday);
|
||||
statement.setBoolean(15, row.makeupWorkday);
|
||||
statement.setString(16, row.source);
|
||||
if (row.syncedByUserId == null) {
|
||||
statement.setObject(17, null);
|
||||
} else {
|
||||
statement.setLong(17, row.syncedByUserId);
|
||||
}
|
||||
statement.setObject(18, row.syncedAt);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getBatchSize() {
|
||||
return rows.size();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private BusinessCalendarSyncResultDTO buildSyncResult(int year, List<BusinessCalendarDayRecord> rows, OffsetDateTime syncedAt) {
|
||||
BusinessCalendarSyncResultDTO dto = new BusinessCalendarSyncResultDTO();
|
||||
dto.setYear(year);
|
||||
dto.setTotalDays(rows.size());
|
||||
dto.setHolidayDays((int) rows.stream().filter(item -> item.holiday).count());
|
||||
dto.setLegalHolidayDays((int) rows.stream().filter(item -> item.legalHoliday).count());
|
||||
dto.setRestDays((int) rows.stream().filter(item -> item.restDay).count());
|
||||
dto.setMakeupWorkdayDays((int) rows.stream().filter(item -> item.makeupWorkday).count());
|
||||
dto.setWorkdayDays((int) rows.stream().filter(item -> item.workday).count());
|
||||
dto.setSyncedAt(syncedAt);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private void requireTenantSelected() {
|
||||
Long tenantId = tenantProvider.getCurrentTenantId();
|
||||
if (tenantId == null || tenantId <= 0) {
|
||||
throw new BusinessException("请先切换到具体租户后再同步业务日历");
|
||||
}
|
||||
}
|
||||
|
||||
private Long resolveTenantId(Long tenantId) {
|
||||
if (tenantId != null && tenantId > 0) {
|
||||
return tenantId;
|
||||
}
|
||||
Long currentTenantId = tenantProvider.getCurrentTenantId();
|
||||
if (currentTenantId != null && currentTenantId > 0) {
|
||||
return currentTenantId;
|
||||
}
|
||||
throw new BusinessException("请先切换到具体租户后再同步业务日历");
|
||||
}
|
||||
|
||||
private Long requireCurrentUserId() {
|
||||
Long currentUserId = userProvider.getCurrentUserId();
|
||||
if (currentUserId == null || currentUserId <= 0) {
|
||||
throw new UnauthorizedException("登录已失效,请重新登录");
|
||||
}
|
||||
return currentUserId;
|
||||
}
|
||||
|
||||
private void requirePermission(String permission, String message) {
|
||||
if (!permissionService.hasPermi(permission)) {
|
||||
throw new UnauthorizedException(message);
|
||||
}
|
||||
}
|
||||
|
||||
private void ensureBusinessCalendarTableReady() {
|
||||
try {
|
||||
Integer count = jdbcTemplate.queryForObject("""
|
||||
select count(1)
|
||||
from information_schema.tables
|
||||
where table_name = 'business_calendar_day'
|
||||
""", Integer.class);
|
||||
if (count == null || count <= 0) {
|
||||
throw new BusinessException("business_calendar_day 表不存在,请先执行 sql/20260525_日历.sql 或重启后端服务");
|
||||
}
|
||||
} catch (DataAccessException exception) {
|
||||
throw new BusinessException("business_calendar_day 表不存在,请先执行 sql/20260525_日历.sql 或重启后端服务");
|
||||
}
|
||||
}
|
||||
|
||||
private int defaultInt(Number value) {
|
||||
return value == null ? 0 : value.intValue();
|
||||
}
|
||||
|
||||
private int defaultYear(Integer value) {
|
||||
return value == null || value <= 0 ? LocalDate.now(DEFAULT_ZONE_ID).getYear() : value;
|
||||
}
|
||||
|
||||
private LocalDate parseCalendarDate(String fieldName, JsonNode node) {
|
||||
String dateText = trimToNull(node.path("date").asText(null));
|
||||
if (dateText == null) {
|
||||
dateText = trimToNull(fieldName);
|
||||
}
|
||||
if (!StringUtils.hasText(dateText)) {
|
||||
throw new BusinessException("业务日历返回了无法识别的日期");
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(dateText);
|
||||
} catch (RuntimeException exception) {
|
||||
throw new BusinessException("业务日历返回了非法日期:" + dateText);
|
||||
}
|
||||
}
|
||||
|
||||
private int resolveTypeCode(JsonNode node, DayOfWeek dayOfWeek) {
|
||||
JsonNode typeNode = node.path("type");
|
||||
if (typeNode.isInt()) {
|
||||
return normalizeTypeCode(typeNode.asInt(), dayOfWeek);
|
||||
}
|
||||
if (typeNode.isTextual()) {
|
||||
String value = trimToNull(typeNode.asText(null));
|
||||
if (value != null) {
|
||||
try {
|
||||
return normalizeTypeCode(Integer.parseInt(value), dayOfWeek);
|
||||
} catch (NumberFormatException ignore) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.path("holiday").isBoolean()) {
|
||||
boolean holiday = node.path("holiday").asBoolean(false);
|
||||
if (holiday) {
|
||||
return 2;
|
||||
}
|
||||
if (StringUtils.hasText(trimToNull(node.path("target").asText(null)))) {
|
||||
return 3;
|
||||
}
|
||||
}
|
||||
return isWeekend(dayOfWeek) ? 1 : 0;
|
||||
}
|
||||
|
||||
private int normalizeTypeCode(int rawTypeCode, DayOfWeek dayOfWeek) {
|
||||
if (rawTypeCode >= 0 && rawTypeCode <= 3) {
|
||||
return rawTypeCode;
|
||||
}
|
||||
return isWeekend(dayOfWeek) ? 1 : 0;
|
||||
}
|
||||
|
||||
private void applyTypeInfo(BusinessCalendarDayRecord target, LocalDate calendarDate, int typeCode, JsonNode typeInfo) {
|
||||
boolean weekend = isWeekend(calendarDate.getDayOfWeek());
|
||||
target.dayOfWeek = typeInfo.path("week").isInt() ? typeInfo.path("week").asInt(target.dayOfWeek) : target.dayOfWeek;
|
||||
target.dayTypeCode = typeCode;
|
||||
target.dayType = mapDayType(typeCode);
|
||||
target.dayName = defaultText(trimToNull(typeInfo.path("name").asText(null)),
|
||||
resolveDayName(typeCode, target.holidayName, target.holidayTarget, calendarDate.getDayOfWeek()));
|
||||
target.weekend = weekend;
|
||||
target.makeupWorkday = typeCode == 3;
|
||||
target.holiday = typeCode == 2;
|
||||
target.workday = typeCode == 0 || typeCode == 3;
|
||||
target.restDay = !target.workday;
|
||||
target.legalHoliday = target.holiday && target.holidayWage != null && target.holidayWage >= 3;
|
||||
}
|
||||
|
||||
private void applyHolidayInfo(BusinessCalendarDayRecord target, LocalDate calendarDate, JsonNode holidayInfo) {
|
||||
boolean holidayFlag = holidayInfo.path("holiday").isMissingNode() || holidayInfo.path("holiday").asBoolean(true);
|
||||
String holidayName = trimToNull(holidayInfo.path("name").asText(null));
|
||||
String holidayTarget = trimToNull(holidayInfo.path("target").asText(null));
|
||||
Integer holidayWage = holidayInfo.path("wage").isNumber() ? holidayInfo.path("wage").asInt() : null;
|
||||
target.holidayName = holidayName;
|
||||
target.holidayTarget = holidayTarget;
|
||||
target.holidayWage = holidayWage;
|
||||
if (holidayFlag) {
|
||||
target.dayTypeCode = 2;
|
||||
target.dayType = mapDayType(2);
|
||||
target.dayName = resolveDayName(2, holidayName, holidayTarget, calendarDate.getDayOfWeek());
|
||||
target.holiday = true;
|
||||
target.workday = false;
|
||||
target.restDay = true;
|
||||
target.makeupWorkday = false;
|
||||
target.legalHoliday = holidayWage != null && holidayWage >= 3;
|
||||
} else {
|
||||
target.dayTypeCode = 3;
|
||||
target.dayType = mapDayType(3);
|
||||
target.dayName = defaultText(holidayName, resolveDayName(3, holidayName, holidayTarget, calendarDate.getDayOfWeek()));
|
||||
target.holiday = false;
|
||||
target.workday = true;
|
||||
target.restDay = false;
|
||||
target.makeupWorkday = true;
|
||||
target.legalHoliday = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void applyKnownMakeupWorkdays(Map<LocalDate, BusinessCalendarDayRecord> dayMap, List<LocalDate> dates) {
|
||||
if (dates == null || dates.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
int year = dates.get(0).getYear();
|
||||
if (year != 2026) {
|
||||
return;
|
||||
}
|
||||
applyMakeupWorkday(dayMap, LocalDate.of(2026, 1, 4), "元旦");
|
||||
applyMakeupWorkday(dayMap, LocalDate.of(2026, 2, 14), "春节");
|
||||
applyMakeupWorkday(dayMap, LocalDate.of(2026, 2, 28), "春节");
|
||||
applyMakeupWorkday(dayMap, LocalDate.of(2026, 5, 9), "劳动节");
|
||||
applyMakeupWorkday(dayMap, LocalDate.of(2026, 9, 20), "国庆节");
|
||||
applyMakeupWorkday(dayMap, LocalDate.of(2026, 10, 10), "国庆节");
|
||||
}
|
||||
|
||||
private boolean applyOfficialHolidayFallback(Map<LocalDate, BusinessCalendarDayRecord> dayMap, int year) {
|
||||
if (year != 2026) {
|
||||
return false;
|
||||
}
|
||||
applyHolidayRange(dayMap, LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 3), "元旦");
|
||||
applyHolidayRange(dayMap, LocalDate.of(2026, 2, 15), LocalDate.of(2026, 2, 23), "春节");
|
||||
applyHolidayRange(dayMap, LocalDate.of(2026, 4, 4), LocalDate.of(2026, 4, 6), "清明节");
|
||||
applyHolidayRange(dayMap, LocalDate.of(2026, 5, 1), LocalDate.of(2026, 5, 5), "劳动节");
|
||||
applyHolidayRange(dayMap, LocalDate.of(2026, 6, 19), LocalDate.of(2026, 6, 21), "端午节");
|
||||
applyHolidayRange(dayMap, LocalDate.of(2026, 9, 25), LocalDate.of(2026, 9, 27), "中秋节");
|
||||
applyHolidayRange(dayMap, LocalDate.of(2026, 10, 1), LocalDate.of(2026, 10, 7), "国庆节");
|
||||
|
||||
applyMakeupWorkday(dayMap, LocalDate.of(2026, 1, 4), "元旦");
|
||||
applyMakeupWorkday(dayMap, LocalDate.of(2026, 2, 14), "春节");
|
||||
applyMakeupWorkday(dayMap, LocalDate.of(2026, 2, 28), "春节");
|
||||
applyMakeupWorkday(dayMap, LocalDate.of(2026, 5, 9), "劳动节");
|
||||
applyMakeupWorkday(dayMap, LocalDate.of(2026, 9, 20), "国庆节");
|
||||
applyMakeupWorkday(dayMap, LocalDate.of(2026, 10, 10), "国庆节");
|
||||
return true;
|
||||
}
|
||||
|
||||
private void applyHolidayRange(Map<LocalDate, BusinessCalendarDayRecord> dayMap, LocalDate start, LocalDate end, String holidayName) {
|
||||
LocalDate cursor = start;
|
||||
while (!cursor.isAfter(end)) {
|
||||
BusinessCalendarDayRecord target = dayMap.get(cursor);
|
||||
if (target != null) {
|
||||
target.dayTypeCode = 2;
|
||||
target.dayType = mapDayType(2);
|
||||
target.dayName = holidayName;
|
||||
target.holidayName = holidayName;
|
||||
target.holidayTarget = null;
|
||||
target.holidayWage = 3;
|
||||
target.weekend = isWeekend(cursor.getDayOfWeek());
|
||||
target.restDay = true;
|
||||
target.workday = false;
|
||||
target.holiday = true;
|
||||
target.legalHoliday = true;
|
||||
target.makeupWorkday = false;
|
||||
target.source = FALLBACK_SOURCE_2026;
|
||||
}
|
||||
cursor = cursor.plusDays(1);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyMakeupWorkday(Map<LocalDate, BusinessCalendarDayRecord> dayMap, LocalDate date, String holidayTarget) {
|
||||
BusinessCalendarDayRecord target = dayMap.get(date);
|
||||
if (target == null) {
|
||||
return;
|
||||
}
|
||||
target.dayTypeCode = 3;
|
||||
target.dayType = mapDayType(3);
|
||||
target.dayName = holidayTarget + "补班";
|
||||
target.holidayName = null;
|
||||
target.holidayTarget = holidayTarget;
|
||||
target.holidayWage = null;
|
||||
target.weekend = isWeekend(date.getDayOfWeek());
|
||||
target.restDay = false;
|
||||
target.workday = true;
|
||||
target.holiday = false;
|
||||
target.legalHoliday = false;
|
||||
target.makeupWorkday = true;
|
||||
target.source = FALLBACK_SOURCE_2026;
|
||||
}
|
||||
|
||||
private String mapDayType(int typeCode) {
|
||||
return switch (typeCode) {
|
||||
case 1 -> "WEEKEND";
|
||||
case 2 -> "HOLIDAY";
|
||||
case 3 -> "MAKEUP_WORKDAY";
|
||||
default -> "WORKDAY";
|
||||
};
|
||||
}
|
||||
|
||||
private String resolveDayName(int typeCode, String holidayName, String holidayTarget, DayOfWeek dayOfWeek) {
|
||||
return switch (typeCode) {
|
||||
case 2 -> StringUtils.hasText(holidayName) ? holidayName : "节假日";
|
||||
case 3 -> StringUtils.hasText(holidayTarget) ? holidayTarget + "补班" : "调休补班";
|
||||
default -> weekdayLabel(dayOfWeek);
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isWeekend(DayOfWeek dayOfWeek) {
|
||||
return dayOfWeek == DayOfWeek.SATURDAY || dayOfWeek == DayOfWeek.SUNDAY;
|
||||
}
|
||||
|
||||
private String weekdayLabel(DayOfWeek dayOfWeek) {
|
||||
return switch (dayOfWeek) {
|
||||
case MONDAY -> "周一";
|
||||
case TUESDAY -> "周二";
|
||||
case WEDNESDAY -> "周三";
|
||||
case THURSDAY -> "周四";
|
||||
case FRIDAY -> "周五";
|
||||
case SATURDAY -> "周六";
|
||||
case SUNDAY -> "周日";
|
||||
};
|
||||
}
|
||||
|
||||
private String trimToNull(String value) {
|
||||
if (!StringUtils.hasText(value)) {
|
||||
return null;
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private String defaultText(String value, String fallback) {
|
||||
return StringUtils.hasText(value) ? value : fallback;
|
||||
}
|
||||
|
||||
private static final class BusinessCalendarDayRecord {
|
||||
private LocalDate calendarDate;
|
||||
private int calendarYear;
|
||||
private int dayOfWeek;
|
||||
private int dayTypeCode;
|
||||
private String dayType;
|
||||
private String dayName;
|
||||
private String holidayName;
|
||||
private String holidayTarget;
|
||||
private Integer holidayWage;
|
||||
private boolean weekend;
|
||||
private boolean restDay;
|
||||
private boolean workday;
|
||||
private boolean holiday;
|
||||
private boolean legalHoliday;
|
||||
private boolean makeupWorkday;
|
||||
private String source;
|
||||
private Long syncedByUserId;
|
||||
private OffsetDateTime syncedAt;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.JsonNode;
|
|||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unis.crm.common.BusinessException;
|
||||
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.ReportReminderTestRequest;
|
||||
import com.unis.crm.dto.reminder.WecomAppConfigDTO;
|
||||
|
|
@ -56,6 +58,7 @@ public class ReportReminderService {
|
|||
private static final String VIEW_PERM = "report_reminder_config:view";
|
||||
private static final String UPDATE_PERM = "report_reminder_config:update";
|
||||
private static final String TEST_PERM = "report_reminder_config:test";
|
||||
private static final String DASHBOARD_UPDATE_PERM = "dashboard_analytics_config:update";
|
||||
private static final String WECOM_BASE_URL = "https://qyapi.weixin.qq.com/cgi-bin";
|
||||
private static final ZoneId DEFAULT_ZONE_ID = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
|
|
@ -65,6 +68,7 @@ public class ReportReminderService {
|
|||
private final PermissionService permissionService;
|
||||
private final SpringSecurityTenantProvider tenantProvider;
|
||||
private final SpringSecurityUserProvider userProvider;
|
||||
private final BusinessCalendarService businessCalendarService;
|
||||
private final HttpClient httpClient;
|
||||
private final Map<Long, CachedToken> tokenCache = new ConcurrentHashMap<>();
|
||||
|
||||
|
|
@ -74,13 +78,15 @@ public class ReportReminderService {
|
|||
ObjectMapper objectMapper,
|
||||
PermissionService permissionService,
|
||||
SpringSecurityTenantProvider tenantProvider,
|
||||
SpringSecurityUserProvider userProvider) {
|
||||
SpringSecurityUserProvider userProvider,
|
||||
BusinessCalendarService businessCalendarService) {
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.namedParameterJdbcTemplate = namedParameterJdbcTemplate;
|
||||
this.objectMapper = objectMapper;
|
||||
this.permissionService = permissionService;
|
||||
this.tenantProvider = tenantProvider;
|
||||
this.userProvider = userProvider;
|
||||
this.businessCalendarService = businessCalendarService;
|
||||
this.httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(8))
|
||||
.build();
|
||||
|
|
@ -120,6 +126,19 @@ public class ReportReminderService {
|
|||
return findReminderConfig(resolveTenantId(tenantId));
|
||||
}
|
||||
|
||||
public BusinessCalendarStatusDTO getBusinessCalendarStatus(Long tenantId) {
|
||||
requirePermission(VIEW_PERM, "无权查看业务日历状态");
|
||||
resolveTenantId(tenantId);
|
||||
return businessCalendarService.getCurrentYearStatusSnapshot();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public BusinessCalendarSyncResultDTO syncCurrentYearBusinessCalendar(Long tenantId) {
|
||||
requireAnyPermission(new String[]{UPDATE_PERM, DASHBOARD_UPDATE_PERM}, "无权同步业务日历");
|
||||
resolveTenantId(tenantId);
|
||||
return businessCalendarService.syncCurrentYearFromReminderModule();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public boolean saveReportReminderConfig(ReportReminderConfigDTO payload) {
|
||||
requirePermission(UPDATE_PERM, "无权修改日报提醒配置");
|
||||
|
|
@ -305,10 +324,10 @@ public class ReportReminderService {
|
|||
if (!isWecomAppConfigured(appConfig)) {
|
||||
return;
|
||||
}
|
||||
if (config.isWorkdayOnly() && isWeekend(today)) {
|
||||
if (config.isWorkdayOnly() && !businessCalendarService.isWorkday(today)) {
|
||||
return;
|
||||
}
|
||||
if (config.isSkipHoliday() && isWeekend(today)) {
|
||||
if (config.isSkipHoliday() && businessCalendarService.isHoliday(today)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1097,6 +1116,22 @@ public class ReportReminderService {
|
|||
}
|
||||
}
|
||||
|
||||
private void requireAnyPermission(String[] perms, String message) {
|
||||
Long currentUserId = userProvider.getCurrentUserId();
|
||||
if (currentUserId == null || currentUserId <= 0) {
|
||||
throw new UnauthorizedException("登录已失效,请重新登录");
|
||||
}
|
||||
if (perms == null) {
|
||||
throw new UnauthorizedException(message);
|
||||
}
|
||||
for (String perm : perms) {
|
||||
if (StringUtils.hasText(perm) && permissionService.hasPermi(perm)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
throw new UnauthorizedException(message);
|
||||
}
|
||||
|
||||
private List<Long> readLongList(String json) {
|
||||
if (!StringUtils.hasText(json)) {
|
||||
return new ArrayList<>();
|
||||
|
|
@ -1152,11 +1187,6 @@ public class ReportReminderService {
|
|||
return URLEncoder.encode(defaultText(value, ""), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private boolean isWeekend(LocalDate date) {
|
||||
int value = date.getDayOfWeek().getValue();
|
||||
return value == 6 || value == 7;
|
||||
}
|
||||
|
||||
private LocalTime parseTime(String value, LocalTime fallback) {
|
||||
try {
|
||||
return LocalTime.parse(defaultText(trimToNull(value), fallback.toString()));
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@
|
|||
(s.employment_status = 'active') as active,
|
||||
s.employment_status as employmentStatus,
|
||||
coalesce(to_char(s.expected_join_date, 'YYYY-MM-DD'), '无') as expectedJoinDate,
|
||||
coalesce(to_char(s.created_at, 'YYYY-MM-DD HH24:MI'), '无') as createdAt,
|
||||
coalesce(
|
||||
to_char(
|
||||
case
|
||||
|
|
@ -185,6 +186,7 @@
|
|||
end as stage,
|
||||
c.landed_flag as landed,
|
||||
coalesce(to_char(c.expected_sign_date, 'YYYY-MM-DD'), '无') as expectedSignDate,
|
||||
coalesce(to_char(c.created_at, 'YYYY-MM-DD HH24:MI'), '无') as createdAt,
|
||||
coalesce(
|
||||
to_char(
|
||||
case
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
o.opportunity_name as name,
|
||||
coalesce(c.customer_name, '未填写最终客户') as client,
|
||||
coalesce(u.display_name, '当前用户') as owner,
|
||||
coalesce(to_char(o.created_at, 'YYYY-MM-DD HH24:MI'), '无') as createdAt,
|
||||
coalesce(
|
||||
to_char(
|
||||
case
|
||||
|
|
@ -205,6 +206,7 @@
|
|||
o.opportunity_name as name,
|
||||
coalesce(c.customer_name, '未填写最终客户') as client,
|
||||
coalesce(u.display_name, '当前用户') as owner,
|
||||
coalesce(to_char(o.created_at, 'YYYY-MM-DD HH24:MI'), '无') as createdAt,
|
||||
coalesce(
|
||||
to_char(
|
||||
case
|
||||
|
|
|
|||
|
|
@ -0,0 +1,94 @@
|
|||
package com.unis.crm.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.unisbase.security.PermissionService;
|
||||
import com.unisbase.security.SpringSecurityTenantProvider;
|
||||
import com.unisbase.security.SpringSecurityUserProvider;
|
||||
import java.sql.Date;
|
||||
import java.time.LocalDate;
|
||||
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.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class BusinessCalendarServiceTest {
|
||||
|
||||
@Mock
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Mock
|
||||
private PermissionService permissionService;
|
||||
|
||||
@Mock
|
||||
private SpringSecurityTenantProvider tenantProvider;
|
||||
|
||||
@Mock
|
||||
private SpringSecurityUserProvider userProvider;
|
||||
|
||||
private BusinessCalendarService businessCalendarService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
businessCalendarService = new BusinessCalendarService(
|
||||
jdbcTemplate,
|
||||
new ObjectMapper(),
|
||||
permissionService,
|
||||
tenantProvider,
|
||||
userProvider,
|
||||
"https://example.invalid/calendar");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isWorkday_shouldFallbackToWeekdayWhenCalendarRowMissing() {
|
||||
LocalDate date = LocalDate.of(2026, 5, 27);
|
||||
when(jdbcTemplate.query(
|
||||
contains("select is_workday"),
|
||||
org.mockito.ArgumentMatchers.<RowMapper<Boolean>>any(),
|
||||
eq(Date.valueOf(date))))
|
||||
.thenReturn(List.of());
|
||||
|
||||
boolean result = businessCalendarService.isWorkday(date);
|
||||
|
||||
assertTrue(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isWorkday_shouldFallbackToWeekendWhenCalendarRowMissing() {
|
||||
LocalDate date = LocalDate.of(2026, 5, 30);
|
||||
when(jdbcTemplate.query(
|
||||
contains("select is_workday"),
|
||||
org.mockito.ArgumentMatchers.<RowMapper<Boolean>>any(),
|
||||
eq(Date.valueOf(date))))
|
||||
.thenReturn(List.of());
|
||||
|
||||
boolean result = businessCalendarService.isWorkday(date);
|
||||
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void isHoliday_shouldReturnFalseWhenCalendarRowMissing() {
|
||||
LocalDate date = LocalDate.of(2026, 10, 1);
|
||||
when(jdbcTemplate.query(
|
||||
contains("select is_holiday"),
|
||||
org.mockito.ArgumentMatchers.<RowMapper<Boolean>>any(),
|
||||
eq(Date.valueOf(date))))
|
||||
.thenReturn(List.of());
|
||||
|
||||
boolean result = businessCalendarService.isHoliday(date);
|
||||
|
||||
assertFalse(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
package com.unis.crm.service;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.contains;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
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.dto.reminder.ReportReminderConfigDTO;
|
||||
import com.unis.crm.dto.reminder.WecomAppConfigDTO;
|
||||
import com.unisbase.security.PermissionService;
|
||||
import com.unisbase.security.SpringSecurityTenantProvider;
|
||||
import com.unisbase.security.SpringSecurityUserProvider;
|
||||
import java.time.LocalDate;
|
||||
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.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ReportReminderServiceTest {
|
||||
|
||||
@Mock
|
||||
private JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Mock
|
||||
private NamedParameterJdbcTemplate namedParameterJdbcTemplate;
|
||||
|
||||
@Mock
|
||||
private PermissionService permissionService;
|
||||
|
||||
@Mock
|
||||
private SpringSecurityTenantProvider tenantProvider;
|
||||
|
||||
@Mock
|
||||
private SpringSecurityUserProvider userProvider;
|
||||
|
||||
@Mock
|
||||
private BusinessCalendarService businessCalendarService;
|
||||
|
||||
private ReportReminderService reportReminderService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
reportReminderService = new ReportReminderService(
|
||||
jdbcTemplate,
|
||||
namedParameterJdbcTemplate,
|
||||
new ObjectMapper(),
|
||||
permissionService,
|
||||
tenantProvider,
|
||||
userProvider,
|
||||
businessCalendarService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void processScheduledMissingReportReminders_shouldSkipHolidayBeforeResolvingTargets() {
|
||||
when(jdbcTemplate.query(
|
||||
contains("from report_reminder_config"),
|
||||
org.mockito.ArgumentMatchers.<RowMapper<ReportReminderConfigDTO>>any()))
|
||||
.thenReturn(List.of(activeReminderConfig()));
|
||||
when(jdbcTemplate.query(
|
||||
contains("from wecom_app_config"),
|
||||
org.mockito.ArgumentMatchers.<RowMapper<WecomAppConfigDTO>>any(),
|
||||
eq(1L)))
|
||||
.thenReturn(List.of(enabledWecomConfig()));
|
||||
when(businessCalendarService.isWorkday(any(LocalDate.class))).thenReturn(true);
|
||||
when(businessCalendarService.isHoliday(any(LocalDate.class))).thenReturn(true);
|
||||
|
||||
reportReminderService.processScheduledMissingReportReminders();
|
||||
|
||||
verify(namedParameterJdbcTemplate, never()).queryForList(
|
||||
contains("from sys_user u"),
|
||||
any(MapSqlParameterSource.class),
|
||||
eq(Long.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void processScheduledMissingReportReminders_shouldContinueOnMakeupWorkday() {
|
||||
when(jdbcTemplate.query(
|
||||
contains("from report_reminder_config"),
|
||||
org.mockito.ArgumentMatchers.<RowMapper<ReportReminderConfigDTO>>any()))
|
||||
.thenReturn(List.of(activeReminderConfig()));
|
||||
when(jdbcTemplate.query(
|
||||
contains("from wecom_app_config"),
|
||||
org.mockito.ArgumentMatchers.<RowMapper<WecomAppConfigDTO>>any(),
|
||||
eq(1L)))
|
||||
.thenReturn(List.of(enabledWecomConfig()));
|
||||
when(businessCalendarService.isWorkday(any(LocalDate.class))).thenReturn(true);
|
||||
when(businessCalendarService.isHoliday(any(LocalDate.class))).thenReturn(false);
|
||||
when(namedParameterJdbcTemplate.queryForList(
|
||||
contains("from sys_user u"),
|
||||
any(MapSqlParameterSource.class),
|
||||
eq(Long.class)))
|
||||
.thenReturn(List.of());
|
||||
|
||||
reportReminderService.processScheduledMissingReportReminders();
|
||||
|
||||
verify(namedParameterJdbcTemplate).queryForList(
|
||||
contains("from sys_user u"),
|
||||
any(MapSqlParameterSource.class),
|
||||
eq(Long.class));
|
||||
}
|
||||
|
||||
private ReportReminderConfigDTO activeReminderConfig() {
|
||||
ReportReminderConfigDTO dto = new ReportReminderConfigDTO();
|
||||
dto.setTenantId(1L);
|
||||
dto.setWecomPushEnabled(true);
|
||||
dto.setMissingReportEnabled(true);
|
||||
dto.setMissingReportTargetType("ALL");
|
||||
dto.setRemindStartTime("00:00");
|
||||
dto.setRemindEndTime("23:59");
|
||||
dto.setRemindIntervalMinutes(1);
|
||||
dto.setMaxRemindCountPerDay(3);
|
||||
dto.setWorkdayOnly(true);
|
||||
dto.setSkipHoliday(true);
|
||||
return dto;
|
||||
}
|
||||
|
||||
private WecomAppConfigDTO enabledWecomConfig() {
|
||||
WecomAppConfigDTO dto = new WecomAppConfigDTO();
|
||||
dto.setTenantId(1L);
|
||||
dto.setEnabled(true);
|
||||
dto.setCorpId("corp");
|
||||
dto.setAgentId("1000001");
|
||||
dto.setSecret("secret");
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
|
|
@ -62,6 +62,7 @@ type SalesExportFieldKey =
|
|||
| "relatedProjects"
|
||||
| "relatedProjectAmount"
|
||||
| "owner"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
| "followUps";
|
||||
type ChannelExportFieldKey =
|
||||
|
|
@ -84,6 +85,7 @@ type ChannelExportFieldKey =
|
|||
| "contacts"
|
||||
| "notes"
|
||||
| "owner"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
| "followUps";
|
||||
type ExportColumnKind = "default" | "longText" | "project" | "contact" | "followup";
|
||||
|
|
@ -595,6 +597,7 @@ const salesExportColumns: Array<ExportColumn<SalesExpansionItem, SalesExportFiel
|
|||
{ key: "relatedProjects", label: "跟进的云桌面项目", kind: "project", value: (item) => formatExportProjectListCell(item.relatedProjects) },
|
||||
{ key: "relatedProjectAmount", label: "跟进项目金额", numFmt: "#,##0.00", value: (item) => sumRelatedProjectAmount(item.relatedProjects) ?? "" },
|
||||
{ key: "owner", label: "创建人", value: (item) => normalizeExportText(item.owner) },
|
||||
{ key: "createdAt", label: "创建时间", value: (item) => normalizeExportText(item.createdAt) },
|
||||
{ key: "updatedAt", label: "更新修改时间", value: (item) => normalizeExportText(item.updatedAt) },
|
||||
{ key: "followUps", label: "跟进记录", kind: "followup", value: (item) => formatExportFollowUps(item.followUps) },
|
||||
];
|
||||
|
|
@ -635,6 +638,7 @@ const channelExportColumns: Array<ExportColumn<ChannelExpansionItem, ChannelExpo
|
|||
{ key: "relatedProjectAmount", label: "跟进项目金额", numFmt: "#,##0.00", value: (item) => sumRelatedProjectAmount(item.relatedProjects) ?? "" },
|
||||
{ key: "notes", label: "备注说明", kind: "longText", value: (item) => normalizeExportText(item.notes) },
|
||||
{ key: "owner", label: "创建人", value: (item) => normalizeExportText(item.owner) },
|
||||
{ key: "createdAt", label: "创建时间", value: (item) => normalizeExportText(item.createdAt) },
|
||||
{ key: "updatedAt", label: "更新修改时间", value: (item) => normalizeExportText(item.updatedAt) },
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,7 @@ type OpportunityExportFieldKey =
|
|||
| "preSalesName"
|
||||
| "notes"
|
||||
| "owner"
|
||||
| "createdAt"
|
||||
| "updatedAt"
|
||||
| "archived"
|
||||
| "pushedToOms";
|
||||
|
|
@ -287,6 +288,7 @@ const opportunityExportColumns: OpportunityExportColumn[] = [
|
|||
{ key: "preSalesName", label: "售前", value: (item) => normalizeOpportunityExportText(item.preSalesName) },
|
||||
{ key: "notes", label: "备注说明", kind: "longText", value: (item) => normalizeOpportunityExportText(item.notes) },
|
||||
{ key: "owner", label: "创建人", value: (item) => normalizeOpportunityExportText(item.owner) },
|
||||
{ key: "createdAt", label: "创建时间", value: (item) => normalizeOpportunityExportText(item.createdAt) },
|
||||
{ key: "updatedAt", label: "更新修改时间", value: (item) => normalizeOpportunityExportText(item.updatedAt) },
|
||||
{ key: "archived", label: "是否签单", value: (item) => formatOpportunityBoolean(item.archived, "已签单", "未签单") },
|
||||
{ key: "pushedToOms", label: "是否推送OMS", value: (item) => formatOpportunityBoolean(item.pushedToOms, "已推送", "未推送") },
|
||||
|
|
@ -778,6 +780,12 @@ function validateOpportunityForm(
|
|||
if (!form.opportunityType?.trim()) {
|
||||
errors.opportunityType = "请选择建设类型";
|
||||
}
|
||||
if (!form.latestProgress?.trim()) {
|
||||
errors.latestProgress = "请填写项目最新进展";
|
||||
}
|
||||
if (!form.nextPlan?.trim()) {
|
||||
errors.nextPlan = `请填写${OPPORTUNITY_NEXT_PLAN_LABEL}`;
|
||||
}
|
||||
if (selectedCompetitors.length === 0) {
|
||||
errors.competitorName = "请至少选择一个竞争对手";
|
||||
} else if (selectedCompetitors.includes("其他") && !customCompetitorName.trim()) {
|
||||
|
|
@ -3263,25 +3271,27 @@ export default function Opportunities() {
|
|||
{fieldErrors.opportunityType ? <p className="text-xs text-rose-500">{fieldErrors.opportunityType}</p> : null}
|
||||
</label>
|
||||
<label className="space-y-2 sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目最新进展</span>
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">项目最新进展<RequiredMark /></span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.latestProgress || ""}
|
||||
onChange={(e) => handleChange("latestProgress", e.target.value)}
|
||||
placeholder="可直接填写项目当前推进情况;日报回写时也会同步更新这里。"
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
className={getFieldInputClass(Boolean(fieldErrors.latestProgress))}
|
||||
/>
|
||||
{fieldErrors.latestProgress ? <p className="text-xs text-rose-500">{fieldErrors.latestProgress}</p> : null}
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">新建、编辑商机时可直接维护;日报回写后也会同步更新。</p>
|
||||
</label>
|
||||
<label className="space-y-2 sm:col-span-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{OPPORTUNITY_NEXT_PLAN_LABEL}</span>
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300">{OPPORTUNITY_NEXT_PLAN_LABEL}<RequiredMark /></span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={form.nextPlan || ""}
|
||||
onChange={(e) => handleChange("nextPlan", e.target.value)}
|
||||
placeholder={`可直接填写${OPPORTUNITY_NEXT_PLAN_LABEL};日报回写时也会同步更新这里。`}
|
||||
className="crm-input-box crm-input-text w-full border border-slate-200 bg-white outline-none focus:border-violet-500 focus:ring-1 focus:ring-violet-500 dark:border-slate-800 dark:bg-slate-900/50"
|
||||
className={getFieldInputClass(Boolean(fieldErrors.nextPlan))}
|
||||
/>
|
||||
{fieldErrors.nextPlan ? <p className="text-xs text-rose-500">{fieldErrors.nextPlan}</p> : null}
|
||||
<p className="text-xs text-slate-400 dark:text-slate-500">新建、编辑商机时可直接维护;日报回写后也会同步更新。</p>
|
||||
</label>
|
||||
<label className="space-y-2 sm:col-span-2">
|
||||
|
|
|
|||
|
|
@ -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-BYhaESGV.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-1u24ZP2i.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CaWPk49l.css">
|
||||
</head>
|
||||
<body>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import http from "@/api/http";
|
||||
import type { DashboardAnalyticsConfig, DashboardAnalyticsPanelPreview, DashboardAnalyticsPreviewCard } from "./types";
|
||||
import type {
|
||||
BusinessCalendarSyncResult,
|
||||
DashboardAnalyticsConfig,
|
||||
DashboardAnalyticsPanelPreview,
|
||||
DashboardAnalyticsPreviewCard,
|
||||
} from "./types";
|
||||
|
||||
export async function getDashboardAnalyticsConfig(tenantId?: number) {
|
||||
const resp = await http.get("/sys/api/admin/dashboard-analytics-config", { params: { tenantId } });
|
||||
|
|
@ -20,3 +25,10 @@ export async function previewDashboardAnalyticsCardDetail(cardKey: string, tenan
|
|||
const resp = await http.get("/sys/api/admin/dashboard-analytics-config/preview/card-detail", { params: { tenantId, cardKey, dimension } });
|
||||
return resp.data.data as DashboardAnalyticsPreviewCard;
|
||||
}
|
||||
|
||||
export async function syncCurrentYearBusinessCalendar(tenantId?: number) {
|
||||
const resp = await http.post("/sys/api/admin/dashboard-analytics-config/calendar/sync-current-year", undefined, {
|
||||
params: { tenantId },
|
||||
});
|
||||
return resp.data.data as BusinessCalendarSyncResult;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ export default {
|
|||
loadError: "Failed to load home analytics settings.",
|
||||
previewError: "Failed to preview home analytics settings.",
|
||||
saveError: "Failed to save home analytics settings.",
|
||||
syncCalendar: "Sync Current Year Calendar",
|
||||
syncCalendarSuccess: "{{year}} calendar synced: {{totalDays}} days in total, holidays {{holidayDays}}, rest days {{restDays}}, makeup workdays {{makeupWorkdayDays}}, workdays {{workdayDays}}.",
|
||||
syncCalendarError: "Failed to sync current year calendar.",
|
||||
cardKey: "Card Key",
|
||||
groupName: "Group Name",
|
||||
groupNameHint: "Cards with the same group name are rendered in one section on the home page, which works well for business or data-domain grouping.",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ export default {
|
|||
loadError: "加载首页经营分析配置失败",
|
||||
previewError: "预览首页经营分析配置失败",
|
||||
saveError: "保存首页经营分析配置失败",
|
||||
syncCalendar: "同步当年日历",
|
||||
syncCalendarSuccess: "{{year}} 年日历已同步:共 {{totalDays}} 天,节假日 {{holidayDays}} 天,法定休息日 {{restDays}} 天,调休补班 {{makeupWorkdayDays}} 天,工作日 {{workdayDays}} 天。",
|
||||
syncCalendarError: "同步当年日历失败",
|
||||
cardKey: "卡片编码",
|
||||
groupName: "分组名称",
|
||||
groupNameHint: "相同分组名称的卡片会在首页展示时归为一个区块,适合按业务、数据域或角色分区。",
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@
|
|||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard-analytics-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dashboard-analytics-preview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@ import {
|
|||
} from "antd";
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
DeleteOutlined,
|
||||
SyncOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
|
@ -60,6 +61,7 @@ import { usePermission } from "@/hooks/usePermission";
|
|||
import {
|
||||
getDashboardAnalyticsConfig,
|
||||
previewDashboardAnalyticsConfig,
|
||||
syncCurrentYearBusinessCalendar,
|
||||
updateDashboardAnalyticsConfig,
|
||||
} from "@/features/dashboard-analytics/api";
|
||||
import AnalyticsChartPreview from "@/features/dashboard-analytics/components/AnalyticsChartPreview";
|
||||
|
|
@ -1390,6 +1392,7 @@ export default function DashboardAnalyticsSettingsPage() {
|
|||
const [loading, setLoading] = useState(false);
|
||||
const [configLoaded, setConfigLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [syncingCalendar, setSyncingCalendar] = useState(false);
|
||||
const [config, setConfig] = useState<DashboardAnalyticsConfig>({
|
||||
enabled: false,
|
||||
title: "",
|
||||
|
|
@ -2088,6 +2091,29 @@ export default function DashboardAnalyticsSettingsPage() {
|
|||
message.success(t("dashboardAnalytics.saveSuccess"));
|
||||
}, [buildPayload, config.cards, isTenantUnselected, message, persistConfig, t]);
|
||||
|
||||
const syncCurrentYearCalendar = useCallback(async () => {
|
||||
if (isTenantUnselected) {
|
||||
message.warning(t("dashboardAnalytics.tenantRequired"));
|
||||
return;
|
||||
}
|
||||
setSyncingCalendar(true);
|
||||
try {
|
||||
const result = await syncCurrentYearBusinessCalendar(activeTenantId);
|
||||
message.success(t("dashboardAnalytics.syncCalendarSuccess", {
|
||||
year: result.year ?? new Date().getFullYear(),
|
||||
totalDays: result.totalDays ?? 0,
|
||||
holidayDays: result.holidayDays ?? 0,
|
||||
restDays: result.restDays ?? 0,
|
||||
makeupWorkdayDays: result.makeupWorkdayDays ?? 0,
|
||||
workdayDays: result.workdayDays ?? 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : t("dashboardAnalytics.syncCalendarError"));
|
||||
} finally {
|
||||
setSyncingCalendar(false);
|
||||
}
|
||||
}, [activeTenantId, isTenantUnselected, message, t]);
|
||||
|
||||
const renderPreviewCard = useCallback((card: DashboardAnalyticsPreviewCard, forceMobile = false, sharedScale?: number) => {
|
||||
const visibleCount = getPreviewVisibleCount(card);
|
||||
const totalCount = card.totalCount ?? visibleCount;
|
||||
|
|
@ -2899,14 +2925,26 @@ export default function DashboardAnalyticsSettingsPage() {
|
|||
loading={loading}
|
||||
title={t("dashboardAnalytics.panelTitle")}
|
||||
extra={(
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => void savePanelConfig()}
|
||||
loading={saving}
|
||||
disabled={!canUpdate}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
<div className="dashboard-analytics-card__actions">
|
||||
<Space size={12} wrap>
|
||||
<Button
|
||||
icon={<SyncOutlined aria-hidden="true" />}
|
||||
onClick={() => void syncCurrentYearCalendar()}
|
||||
loading={syncingCalendar}
|
||||
disabled={!canUpdate || isTenantUnselected}
|
||||
>
|
||||
{t("dashboardAnalytics.syncCalendar")}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => void savePanelConfig()}
|
||||
loading={saving}
|
||||
disabled={!canUpdate}
|
||||
>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
|
|
|
|||
|
|
@ -79,3 +79,14 @@ export interface DashboardAnalyticsPanelPreview {
|
|||
emptyStateText?: string;
|
||||
cards?: DashboardAnalyticsPreviewCard[];
|
||||
}
|
||||
|
||||
export interface BusinessCalendarSyncResult {
|
||||
year?: number;
|
||||
totalDays?: number;
|
||||
holidayDays?: number;
|
||||
legalHolidayDays?: number;
|
||||
restDays?: number;
|
||||
makeupWorkdayDays?: number;
|
||||
workdayDays?: number;
|
||||
syncedAt?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import http from "@/api/http";
|
||||
import type { ReportReminderConfig, ReportReminderTestPayload, WecomAppConfig, WecomConfigStatus } from "./types";
|
||||
import type {
|
||||
BusinessCalendarSyncResult,
|
||||
BusinessCalendarStatus,
|
||||
ReportReminderConfig,
|
||||
ReportReminderTestPayload,
|
||||
WecomAppConfig,
|
||||
WecomConfigStatus
|
||||
} from "./types";
|
||||
|
||||
export async function getWecomAppConfig(tenantId?: number) {
|
||||
const resp = await http.get("/sys/api/admin/wecom-app-config", { params: { tenantId } });
|
||||
|
|
@ -30,3 +37,13 @@ export async function getWecomConfigStatus(tenantId?: number) {
|
|||
const resp = await http.get("/sys/api/admin/wecom-config-status", { params: { tenantId } });
|
||||
return resp.data.data as WecomConfigStatus;
|
||||
}
|
||||
|
||||
export async function getReportReminderCalendarStatus(tenantId?: number) {
|
||||
const resp = await http.get("/sys/api/admin/report-reminder-calendar-status", { params: { tenantId } });
|
||||
return resp.data.data as BusinessCalendarStatus;
|
||||
}
|
||||
|
||||
export async function syncReportReminderCalendar(tenantId?: number) {
|
||||
const resp = await http.post("/sys/api/admin/report-reminder-calendar/sync-current-year", null, { params: { tenantId } });
|
||||
return resp.data.data as BusinessCalendarSyncResult;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,17 @@ const reportReminderEnUS = {
|
|||
wecomStatus: "WeCom Status",
|
||||
wecomConfigured: "Configured",
|
||||
wecomNotConfigured: "Not Configured",
|
||||
calendarStatus: "Business Calendar Status",
|
||||
calendarSynced: "Current year calendar is ready",
|
||||
calendarNeedsSync: "Current year calendar needs sync",
|
||||
calendarCounts: "{{year}} synced {{totalDays}} / {{expectedDays}} days, holidays {{holidayDays}}, rest days {{restDays}}, makeup workdays {{makeupWorkdayDays}}, workdays {{workdayDays}}.",
|
||||
calendarLastSynced: "Last synced: {{time}}",
|
||||
calendarLastSyncedEmpty: "Last synced: not available",
|
||||
calendarSyncAction: "Sync Current Year Calendar",
|
||||
calendarSyncHint: "\"Workdays only\" and \"Skip holidays\" read from this calendar directly. Sync it before first use or at the start of a new year.",
|
||||
calendarSyncNoPermission: "You can view the calendar status, but you do not have permission to sync it.",
|
||||
calendarSyncSuccess: "{{year}} calendar synced: total {{totalDays}} days, holidays {{holidayDays}}, rest days {{restDays}}, makeup workdays {{makeupWorkdayDays}}, workdays {{workdayDays}}.",
|
||||
calendarSyncError: "Failed to sync the current year calendar",
|
||||
defaultMappingMode: "Member mapping: treat the CRM account as a mobile number first, then fall back to the CRM mobile number",
|
||||
mappingModeTitle: "Member Mapping",
|
||||
mappingModeDesc: "This version first treats the CRM account as a mobile number and tries to match a WeCom member by mobile. If that fails, it falls back to the CRM mobile number. If both strategies fail, the message will be logged as failed.",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,17 @@ const reportReminderZhCN = {
|
|||
wecomStatus: "企业微信配置状态",
|
||||
wecomConfigured: "已配置",
|
||||
wecomNotConfigured: "未配置",
|
||||
calendarStatus: "业务日历状态",
|
||||
calendarSynced: "当年日历已就绪",
|
||||
calendarNeedsSync: "当年日历待同步",
|
||||
calendarCounts: "{{year}} 年已同步 {{totalDays}} / {{expectedDays}} 天,节假日 {{holidayDays}} 天,休息日 {{restDays}} 天,调休补班 {{makeupWorkdayDays}} 天,工作日 {{workdayDays}} 天。",
|
||||
calendarLastSynced: "最近同步时间:{{time}}",
|
||||
calendarLastSyncedEmpty: "最近同步时间:暂无",
|
||||
calendarSyncAction: "同步当年日历",
|
||||
calendarSyncHint: "“仅工作日提醒 / 节假日跳过”会直接读取这里的日历定义;新年份或首次启用前建议先同步一次。",
|
||||
calendarSyncNoPermission: "你可以查看当前日历状态,但没有同步日历的权限。",
|
||||
calendarSyncSuccess: "{{year}} 年日历已同步:共 {{totalDays}} 天,节假日 {{holidayDays}} 天,休息日 {{restDays}} 天,调休补班 {{makeupWorkdayDays}} 天,工作日 {{workdayDays}} 天。",
|
||||
calendarSyncError: "同步当年日历失败",
|
||||
defaultMappingMode: "成员匹配方式:优先按 CRM 账户匹配企业微信成员手机号,未匹配到时再按 CRM 用户手机号匹配",
|
||||
mappingModeTitle: "成员匹配方式",
|
||||
mappingModeDesc: "这版方案默认优先将 CRM 账户作为手机号去匹配企业微信通讯录成员;若账户未匹配到,再按 CRM 用户手机号匹配企业微信通讯录成员。若两种方式都未匹配到,消息会记录为发送失败。",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
import {
|
||||
ApiOutlined,
|
||||
BellOutlined,
|
||||
CalendarOutlined,
|
||||
ClockCircleOutlined,
|
||||
SaveOutlined,
|
||||
SendOutlined,
|
||||
|
|
@ -34,8 +35,10 @@ import {
|
|||
} from "@/api";
|
||||
import {
|
||||
getReportReminderConfig,
|
||||
getReportReminderCalendarStatus,
|
||||
getWecomAppConfig,
|
||||
getWecomConfigStatus,
|
||||
syncReportReminderCalendar,
|
||||
testReportReminderConfig,
|
||||
updateReportReminderConfig,
|
||||
updateWecomAppConfig
|
||||
|
|
@ -54,7 +57,13 @@ import type {
|
|||
SysRole,
|
||||
SysUser
|
||||
} from "@/types";
|
||||
import type { ReportReminderConfig, ReportReminderTargetType, WecomAppConfig, WecomConfigStatus } from "@/features/report-reminder/types";
|
||||
import type {
|
||||
BusinessCalendarStatus,
|
||||
ReportReminderConfig,
|
||||
ReportReminderTargetType,
|
||||
WecomAppConfig,
|
||||
WecomConfigStatus
|
||||
} from "@/features/report-reminder/types";
|
||||
import "./index.less";
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
|
@ -186,6 +195,17 @@ function formatPlaceholderHint(placeholders: TemplatePlaceholderOption[], t: (ke
|
|||
return placeholders.map((item) => `${t(item.labelKey)} (${item.token})`).join("、");
|
||||
}
|
||||
|
||||
function formatCalendarTime(value?: string) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
export default function ReportReminderSettings() {
|
||||
const { t } = useTranslation();
|
||||
const { message } = App.useApp();
|
||||
|
|
@ -198,13 +218,16 @@ export default function ReportReminderSettings() {
|
|||
const [roles, setRoles] = useState<SysRole[]>([]);
|
||||
const [orgs, setOrgs] = useState<SysOrg[]>([]);
|
||||
const [wecomStatus, setWecomStatus] = useState<WecomConfigStatus | null>(null);
|
||||
const [calendarStatus, setCalendarStatus] = useState<BusinessCalendarStatus | null>(null);
|
||||
const [missingTextarea, setMissingTextarea] = useState<HTMLTextAreaElement | null>(null);
|
||||
const [submitTextarea, setSubmitTextarea] = useState<HTMLTextAreaElement | null>(null);
|
||||
const [syncingCalendar, setSyncingCalendar] = useState(false);
|
||||
const activeTenantId = Number(localStorage.getItem("activeTenantId") || 0);
|
||||
const isTenantUnselected = activeTenantId <= 0;
|
||||
const canView = can("report_reminder_config:view");
|
||||
const canUpdate = can("report_reminder_config:update");
|
||||
const canTest = can("report_reminder_config:test");
|
||||
const canSyncCalendar = can("report_reminder_config:update") || can("dashboard_analytics_config:update");
|
||||
const timeOptions = useMemo(() => generateTimeOptions(), []);
|
||||
const orgTreeData = useMemo(() => buildOrgTree(orgs), [orgs]);
|
||||
const userOptions = useMemo(() => toUserOptions(users), [users]);
|
||||
|
|
@ -289,13 +312,14 @@ export default function ReportReminderSettings() {
|
|||
|
||||
setLoading(true);
|
||||
try {
|
||||
const [appConfig, reminderConfig, usersData, rolesData, orgsData, status] = await Promise.all([
|
||||
const [appConfig, reminderConfig, usersData, rolesData, orgsData, status, nextCalendarStatus] = await Promise.all([
|
||||
getWecomAppConfig(activeTenantId).catch(() => DEFAULT_WECOM_APP_CONFIG),
|
||||
getReportReminderConfig(activeTenantId).catch(() => DEFAULT_REPORT_REMINDER_CONFIG),
|
||||
listUsers({ tenantId: activeTenantId }).catch(() => []),
|
||||
listRoles(activeTenantId).catch(() => []),
|
||||
listOrgs(activeTenantId).catch(() => []),
|
||||
getWecomConfigStatus(activeTenantId).catch(() => null)
|
||||
getWecomConfigStatus(activeTenantId).catch(() => null),
|
||||
getReportReminderCalendarStatus(activeTenantId).catch(() => null)
|
||||
]);
|
||||
|
||||
form.setFieldsValue({
|
||||
|
|
@ -311,6 +335,7 @@ export default function ReportReminderSettings() {
|
|||
setRoles(rolesData || []);
|
||||
setOrgs(orgsData || []);
|
||||
setWecomStatus(status);
|
||||
setCalendarStatus(nextCalendarStatus);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -348,6 +373,7 @@ export default function ReportReminderSettings() {
|
|||
await updateWecomAppConfig(appConfig);
|
||||
await updateReportReminderConfig(reminderConfig);
|
||||
setWecomStatus(await getWecomConfigStatus(activeTenantId).catch(() => null));
|
||||
setCalendarStatus(await getReportReminderCalendarStatus(activeTenantId).catch(() => null));
|
||||
message.success(t("common.success"));
|
||||
} catch (error: any) {
|
||||
if (error?.message) {
|
||||
|
|
@ -399,6 +425,30 @@ export default function ReportReminderSettings() {
|
|||
form.setFieldValue(field, nextValue);
|
||||
};
|
||||
|
||||
const handleSyncCalendar = async () => {
|
||||
if (isTenantUnselected) {
|
||||
message.warning(t("reportReminder.selectTenantFirst"));
|
||||
return;
|
||||
}
|
||||
setSyncingCalendar(true);
|
||||
try {
|
||||
const result = await syncReportReminderCalendar(activeTenantId);
|
||||
setCalendarStatus(await getReportReminderCalendarStatus(activeTenantId).catch(() => null));
|
||||
message.success(t("reportReminder.calendarSyncSuccess", {
|
||||
year: result.year ?? new Date().getFullYear(),
|
||||
totalDays: result.totalDays ?? 0,
|
||||
holidayDays: result.holidayDays ?? 0,
|
||||
restDays: result.restDays ?? 0,
|
||||
makeupWorkdayDays: result.makeupWorkdayDays ?? 0,
|
||||
workdayDays: result.workdayDays ?? 0
|
||||
}));
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || t("reportReminder.calendarSyncError"));
|
||||
} finally {
|
||||
setSyncingCalendar(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!canView) {
|
||||
return (
|
||||
<div className="app-page app-page--contained" style={{ maxWidth: 960, margin: "0 auto" }}>
|
||||
|
|
@ -433,6 +483,50 @@ export default function ReportReminderSettings() {
|
|||
/>
|
||||
)}
|
||||
|
||||
{!isTenantUnselected && (
|
||||
<Alert
|
||||
type={calendarStatus?.complete ? "success" : "warning"}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={t("reportReminder.calendarStatus")}
|
||||
description={(
|
||||
<Space wrap>
|
||||
<Tag color={calendarStatus?.complete ? "success" : "warning"}>
|
||||
{calendarStatus?.complete ? t("reportReminder.calendarSynced") : t("reportReminder.calendarNeedsSync")}
|
||||
</Tag>
|
||||
<Text>
|
||||
{t("reportReminder.calendarCounts", {
|
||||
year: calendarStatus?.year ?? new Date().getFullYear(),
|
||||
totalDays: calendarStatus?.totalDays ?? 0,
|
||||
expectedDays: calendarStatus?.expectedDays ?? 365,
|
||||
holidayDays: calendarStatus?.holidayDays ?? 0,
|
||||
restDays: calendarStatus?.restDays ?? 0,
|
||||
makeupWorkdayDays: calendarStatus?.makeupWorkdayDays ?? 0,
|
||||
workdayDays: calendarStatus?.workdayDays ?? 0
|
||||
})}
|
||||
</Text>
|
||||
<Text type="secondary">
|
||||
{calendarStatus?.lastSyncedAt
|
||||
? t("reportReminder.calendarLastSynced", { time: formatCalendarTime(calendarStatus.lastSyncedAt) })
|
||||
: t("reportReminder.calendarLastSyncedEmpty")}
|
||||
</Text>
|
||||
<Text type="secondary">{t("reportReminder.calendarSyncHint")}</Text>
|
||||
<Button
|
||||
icon={<CalendarOutlined />}
|
||||
onClick={handleSyncCalendar}
|
||||
loading={syncingCalendar}
|
||||
disabled={!canSyncCalendar}
|
||||
>
|
||||
{t("reportReminder.calendarSyncAction")}
|
||||
</Button>
|
||||
{!canSyncCalendar ? (
|
||||
<Text type="secondary">{t("reportReminder.calendarSyncNoPermission")}</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isTenantUnselected && (
|
||||
<Alert
|
||||
type={wecomStatus?.configured ? "success" : "error"}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,29 @@ export interface WecomConfigStatus {
|
|||
message?: string;
|
||||
}
|
||||
|
||||
export interface BusinessCalendarStatus {
|
||||
year?: number;
|
||||
synced?: boolean;
|
||||
complete?: boolean;
|
||||
totalDays?: number;
|
||||
expectedDays?: number;
|
||||
holidayDays?: number;
|
||||
restDays?: number;
|
||||
makeupWorkdayDays?: number;
|
||||
workdayDays?: number;
|
||||
lastSyncedAt?: string;
|
||||
}
|
||||
|
||||
export interface BusinessCalendarSyncResult {
|
||||
year?: number;
|
||||
totalDays?: number;
|
||||
holidayDays?: number;
|
||||
restDays?: number;
|
||||
makeupWorkdayDays?: number;
|
||||
workdayDays?: number;
|
||||
syncedAt?: string;
|
||||
}
|
||||
|
||||
export interface ReportReminderTestPayload {
|
||||
tenantId?: number;
|
||||
receiverUserId: number;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
begin;
|
||||
|
||||
set search_path to public;
|
||||
|
||||
-- 2026-05-25 日历能力升级脚本
|
||||
-- 说明:
|
||||
-- 1. 创建并补齐业务日历表 business_calendar_day。
|
||||
-- 2. 用于“同步当年日历”功能落库存储节假日、法定休息日、调休补班、工作日定义。
|
||||
-- 3. 可重复执行。
|
||||
|
||||
create table if not exists business_calendar_day (
|
||||
id bigint generated by default as identity primary key,
|
||||
calendar_date date not null,
|
||||
calendar_year integer not null,
|
||||
day_of_week integer not null,
|
||||
day_type_code integer not null default 0,
|
||||
day_type varchar(32) not null default 'WORKDAY',
|
||||
day_name varchar(64) not null,
|
||||
holiday_name varchar(100),
|
||||
holiday_target varchar(100),
|
||||
holiday_wage integer,
|
||||
is_weekend boolean not null default false,
|
||||
is_rest_day boolean not null default false,
|
||||
is_workday boolean not null default true,
|
||||
is_holiday boolean not null default false,
|
||||
is_legal_holiday boolean not null default false,
|
||||
is_makeup_workday boolean not null default false,
|
||||
source varchar(50) not null default 'timor',
|
||||
synced_by_user_id bigint,
|
||||
synced_at timestamptz not null default now(),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table if exists business_calendar_day
|
||||
add column if not exists calendar_year integer not null default 1970,
|
||||
add column if not exists day_of_week integer not null default 1,
|
||||
add column if not exists day_type_code integer not null default 0,
|
||||
add column if not exists day_type varchar(32) not null default 'WORKDAY',
|
||||
add column if not exists day_name varchar(64) not null default '周一',
|
||||
add column if not exists holiday_name varchar(100),
|
||||
add column if not exists holiday_target varchar(100),
|
||||
add column if not exists holiday_wage integer,
|
||||
add column if not exists is_weekend boolean not null default false,
|
||||
add column if not exists is_rest_day boolean not null default false,
|
||||
add column if not exists is_workday boolean not null default true,
|
||||
add column if not exists is_holiday boolean not null default false,
|
||||
add column if not exists is_legal_holiday boolean not null default false,
|
||||
add column if not exists is_makeup_workday boolean not null default false,
|
||||
add column if not exists source varchar(50) not null default 'timor',
|
||||
add column if not exists synced_by_user_id bigint,
|
||||
add column if not exists synced_at timestamptz not null default now();
|
||||
|
||||
do $$
|
||||
begin
|
||||
if exists (
|
||||
select 1
|
||||
from information_schema.tables
|
||||
where table_schema = current_schema()
|
||||
and table_name = 'business_calendar_day'
|
||||
) and not exists (
|
||||
select 1
|
||||
from pg_constraint
|
||||
where conname = 'uk_business_calendar_day_date'
|
||||
) then
|
||||
alter table business_calendar_day
|
||||
add constraint uk_business_calendar_day_date unique (calendar_date);
|
||||
end if;
|
||||
end
|
||||
$$;
|
||||
|
||||
create index if not exists idx_business_calendar_day_year_date
|
||||
on business_calendar_day (calendar_year asc, calendar_date asc);
|
||||
|
||||
create index if not exists idx_business_calendar_day_type
|
||||
on business_calendar_day (day_type asc, calendar_date asc);
|
||||
|
||||
comment on table business_calendar_day is '业务日历定义表';
|
||||
comment on column business_calendar_day.calendar_date is '日历日期';
|
||||
comment on column business_calendar_day.calendar_year is '年份';
|
||||
comment on column business_calendar_day.day_of_week is '星期,1-7 对应周一到周日';
|
||||
comment on column business_calendar_day.day_type_code is '日历类型编码:0工作日、1周末休息、2节假日、3调休补班';
|
||||
comment on column business_calendar_day.day_type is '日历类型';
|
||||
comment on column business_calendar_day.day_name is '日历展示名称';
|
||||
comment on column business_calendar_day.holiday_name is '节假日名称';
|
||||
comment on column business_calendar_day.holiday_target is '调休关联节日';
|
||||
comment on column business_calendar_day.holiday_wage is '节假日工资倍数';
|
||||
comment on column business_calendar_day.is_weekend is '是否周末';
|
||||
comment on column business_calendar_day.is_rest_day is '是否休息日';
|
||||
comment on column business_calendar_day.is_workday is '是否工作日';
|
||||
comment on column business_calendar_day.is_holiday is '是否节假日';
|
||||
comment on column business_calendar_day.is_legal_holiday is '是否法定节假日';
|
||||
comment on column business_calendar_day.is_makeup_workday is '是否法定调休补班';
|
||||
comment on column business_calendar_day.source is '日历来源';
|
||||
comment on column business_calendar_day.synced_by_user_id is '最近同步人ID';
|
||||
comment on column business_calendar_day.synced_at is '最近同步时间';
|
||||
|
||||
commit;
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
- 主初始化脚本:`sql/init_full_pg17.sql`
|
||||
- 当前版本合并升级脚本:`sql/upgrade_dashboard_analytics_and_opportunity_schema_pg17.sql`
|
||||
- 2026-05-25 日历功能升级脚本:`sql/20260525_日历.sql`
|
||||
- 首页经营分析生产升级脚本:`sql/upgrade_dashboard_analytics_prod_pg17.sql`
|
||||
- 经营分析卡片历史展示配置修正脚本:`sql/upgrade_dashboard_analytics_card_display_config_pg17.sql`
|
||||
- 商机实际签约金额字段升级脚本:`sql/upgrade_opportunity_actual_signed_amount_pg17.sql`
|
||||
|
|
@ -73,6 +74,12 @@ psql -d your_database -f sql/upgrade_opportunity_actual_signed_amount_pg17.sql
|
|||
psql -d your_database -f sql/upgrade_dashboard_analytics_and_opportunity_schema_pg17.sql
|
||||
```
|
||||
|
||||
如果本次只需要上线“日历同步”能力,请执行:
|
||||
|
||||
```bash
|
||||
psql -d your_database -f sql/20260525_日历.sql
|
||||
```
|
||||
|
||||
如果需要一次性修正历史经营分析卡片配置,避免旧饼图/环形图/漏斗图升级后标签缺失、旧中文图表预设值无法识别,请执行:
|
||||
|
||||
```bash
|
||||
|
|
@ -109,6 +116,9 @@ psql -d your_database -f sql/upgrade_dashboard_analytics_card_display_config_pg1
|
|||
- `sql/upgrade_dashboard_analytics_and_opportunity_schema_pg17.sql`
|
||||
当前版本推荐的结构合并升级脚本,适用于老环境一次补齐“经营分析配置最新表结构”和“商机快照/实际签约金额字段”。
|
||||
|
||||
- `sql/20260525_日历.sql`
|
||||
本次“日历同步”功能专用升级脚本,用于老环境创建并补齐 `business_calendar_day` 表结构。
|
||||
|
||||
- `sql/upgrade_dashboard_analytics_card_display_config_pg17.sql`
|
||||
经营分析卡片历史展示配置修正脚本,用于清洗旧 `display_text_config` 中的中文图表预设值,并补齐老饼图/环形图/漏斗图的标签显示配置。
|
||||
|
||||
|
|
|
|||
|
|
@ -736,6 +736,37 @@ CREATE INDEX IF NOT EXISTS idx_crm_opportunity_followup_source
|
|||
CREATE INDEX IF NOT EXISTS idx_crm_channel_expansion_contact_channel
|
||||
ON crm_channel_expansion_contact(channel_expansion_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS business_calendar_day (
|
||||
id bigint generated by default as identity primary key,
|
||||
calendar_date date not null,
|
||||
calendar_year integer not null,
|
||||
day_of_week integer not null,
|
||||
day_type_code integer not null default 0,
|
||||
day_type varchar(32) not null default 'WORKDAY',
|
||||
day_name varchar(64) not null,
|
||||
holiday_name varchar(100),
|
||||
holiday_target varchar(100),
|
||||
holiday_wage integer,
|
||||
is_weekend boolean not null default false,
|
||||
is_rest_day boolean not null default false,
|
||||
is_workday boolean not null default true,
|
||||
is_holiday boolean not null default false,
|
||||
is_legal_holiday boolean not null default false,
|
||||
is_makeup_workday boolean not null default false,
|
||||
source varchar(50) not null default 'timor',
|
||||
synced_by_user_id bigint,
|
||||
synced_at timestamptz not null default now(),
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now(),
|
||||
constraint uk_business_calendar_day_date unique (calendar_date)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_business_calendar_day_year_date
|
||||
ON business_calendar_day(calendar_year, calendar_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_business_calendar_day_type
|
||||
ON business_calendar_day(day_type, calendar_date);
|
||||
|
||||
-- Column comments
|
||||
WITH column_comments(table_name, column_name, comment_text) AS (
|
||||
VALUES
|
||||
|
|
@ -938,6 +969,28 @@ WITH column_comments(table_name, column_name, comment_text) AS (
|
|||
('work_todo', 'created_at', '创建时间'),
|
||||
('work_todo', 'updated_at', '更新时间'),
|
||||
|
||||
('business_calendar_day', 'id', '业务日历主键'),
|
||||
('business_calendar_day', 'calendar_date', '日历日期'),
|
||||
('business_calendar_day', 'calendar_year', '年份'),
|
||||
('business_calendar_day', 'day_of_week', '星期,1-7 对应周一到周日'),
|
||||
('business_calendar_day', 'day_type_code', '日历类型编码:0工作日、1周末休息、2节假日、3调休补班'),
|
||||
('business_calendar_day', 'day_type', '日历类型'),
|
||||
('business_calendar_day', 'day_name', '日历展示名称'),
|
||||
('business_calendar_day', 'holiday_name', '节假日名称'),
|
||||
('business_calendar_day', 'holiday_target', '调休关联节日'),
|
||||
('business_calendar_day', 'holiday_wage', '节假日工资倍数'),
|
||||
('business_calendar_day', 'is_weekend', '是否周末'),
|
||||
('business_calendar_day', 'is_rest_day', '是否休息日'),
|
||||
('business_calendar_day', 'is_workday', '是否工作日'),
|
||||
('business_calendar_day', 'is_holiday', '是否节假日'),
|
||||
('business_calendar_day', 'is_legal_holiday', '是否法定节假日'),
|
||||
('business_calendar_day', 'is_makeup_workday', '是否法定调休补班'),
|
||||
('business_calendar_day', 'source', '日历来源'),
|
||||
('business_calendar_day', 'synced_by_user_id', '最近同步人ID'),
|
||||
('business_calendar_day', 'synced_at', '最近同步时间'),
|
||||
('business_calendar_day', 'created_at', '创建时间'),
|
||||
('business_calendar_day', 'updated_at', '更新时间'),
|
||||
|
||||
('sys_activity_log', 'id', '动态主键'),
|
||||
('sys_activity_log', 'biz_type', '业务类型'),
|
||||
('sys_activity_log', 'biz_id', '业务对象ID'),
|
||||
|
|
|
|||
Loading…
Reference in New Issue