添加同步本年年度日历。

main
kangwenjing 2026-05-27 13:35:50 +08:00
parent aa5ff4f073
commit 8dc9fe2117
32 changed files with 1798 additions and 28 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.",

View File

@ -18,6 +18,9 @@ export default {
loadError: "加载首页经营分析配置失败",
previewError: "预览首页经营分析配置失败",
saveError: "保存首页经营分析配置失败",
syncCalendar: "同步当年日历",
syncCalendarSuccess: "{{year}} 年日历已同步:共 {{totalDays}} 天,节假日 {{holidayDays}} 天,法定休息日 {{restDays}} 天,调休补班 {{makeupWorkdayDays}} 天,工作日 {{workdayDays}} 天。",
syncCalendarError: "同步当年日历失败",
cardKey: "卡片编码",
groupName: "分组名称",
groupNameHint: "相同分组名称的卡片会在首页展示时归为一个区块,适合按业务、数据域或角色分区。",

View File

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

View File

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

View File

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

View File

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

View File

@ -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.",

View File

@ -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 用户手机号匹配企业微信通讯录成员。若两种方式都未匹配到,消息会记录为发送失败。",

View File

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

View File

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

View File

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

View File

@ -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` 中的中文图表预设值,并补齐老饼图/环形图/漏斗图的标签显示配置。

View File

@ -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'),