diff --git a/backend/src/main/java/com/unis/crm/common/BusinessCalendarSchemaInitializer.java b/backend/src/main/java/com/unis/crm/common/BusinessCalendarSchemaInitializer.java new file mode 100644 index 00000000..27ab863e --- /dev/null +++ b/backend/src/main/java/com/unis/crm/common/BusinessCalendarSchemaInitializer.java @@ -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); + } + } +} diff --git a/backend/src/main/java/com/unis/crm/controller/DashboardAnalyticsAdminController.java b/backend/src/main/java/com/unis/crm/controller/DashboardAnalyticsAdminController.java index 8df5632a..dec00ca1 100644 --- a/backend/src/main/java/com/unis/crm/controller/DashboardAnalyticsAdminController.java +++ b/backend/src/main/java/com/unis/crm/controller/DashboardAnalyticsAdminController.java @@ -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 syncCurrentYearCalendar( + @RequestParam(value = "tenantId", required = false) Long tenantId) { + return ApiResponse.success(businessCalendarService.syncCurrentYear(tenantId)); + } } diff --git a/backend/src/main/java/com/unis/crm/controller/ReportReminderAdminController.java b/backend/src/main/java/com/unis/crm/controller/ReportReminderAdminController.java index 982cdf57..f3240852 100644 --- a/backend/src/main/java/com/unis/crm/controller/ReportReminderAdminController.java +++ b/backend/src/main/java/com/unis/crm/controller/ReportReminderAdminController.java @@ -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 getReportReminderCalendarStatus( + @RequestParam(value = "tenantId", required = false) Long tenantId) { + return ApiResponse.success(reportReminderService.getBusinessCalendarStatus(tenantId)); + } + + @PostMapping("/report-reminder-calendar/sync-current-year") + public ApiResponse syncReportReminderCalendar( + @RequestParam(value = "tenantId", required = false) Long tenantId) { + return ApiResponse.success(reportReminderService.syncCurrentYearBusinessCalendar(tenantId)); + } + @PutMapping("/report-reminder-config") public ApiResponse updateReportReminderConfig(@Valid @RequestBody ReportReminderConfigDTO payload) { return ApiResponse.success(reportReminderService.saveReportReminderConfig(payload)); diff --git a/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/BusinessCalendarStatusDTO.java b/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/BusinessCalendarStatusDTO.java new file mode 100644 index 00000000..730fa3bf --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/BusinessCalendarStatusDTO.java @@ -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; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/BusinessCalendarSyncResultDTO.java b/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/BusinessCalendarSyncResultDTO.java new file mode 100644 index 00000000..9b4f1dba --- /dev/null +++ b/backend/src/main/java/com/unis/crm/dto/dashboardanalytics/BusinessCalendarSyncResultDTO.java @@ -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; + } +} diff --git a/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java b/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java index 53cb0d5b..31ce4f78 100644 --- a/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/expansion/ChannelExpansionItemDTO.java @@ -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 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; } diff --git a/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java b/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java index 20546ff8..acd19ace 100644 --- a/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/expansion/SalesExpansionItemDTO.java @@ -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 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; } diff --git a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java index f7e7bc41..e84952c4 100644 --- a/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java +++ b/backend/src/main/java/com/unis/crm/dto/opportunity/OpportunityItemDTO.java @@ -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; } diff --git a/backend/src/main/java/com/unis/crm/service/BusinessCalendarAutoSync.java b/backend/src/main/java/com/unis/crm/service/BusinessCalendarAutoSync.java new file mode 100644 index 00000000..97c2c243 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/BusinessCalendarAutoSync.java @@ -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); + } + } +} diff --git a/backend/src/main/java/com/unis/crm/service/BusinessCalendarService.java b/backend/src/main/java/com/unis/crm/service/BusinessCalendarService.java new file mode 100644 index 00000000..7b71b2c6 --- /dev/null +++ b/backend/src/main/java/com/unis/crm/service/BusinessCalendarService.java @@ -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 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 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 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 dayMap = initYearDays(year, currentUserId, syncedAt); + ensureBusinessCalendarTableReady(); + try { + List allDates = new ArrayList<>(dayMap.keySet()); + for (int start = 0; start < allDates.size(); start += 50) { + int end = Math.min(start + 50, allDates.size()); + List 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 rows = new ArrayList<>(dayMap.values()); + batchUpsert(rows); + return buildSyncResult(year, rows, syncedAt); + } + + private void mergeBatchCalendar(Map dayMap, List 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 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 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 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 initYearDays(int year, Long currentUserId, OffsetDateTime syncedAt) { + Map 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 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 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 dayMap, List 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 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 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 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; + } +} diff --git a/backend/src/main/java/com/unis/crm/service/ReportReminderService.java b/backend/src/main/java/com/unis/crm/service/ReportReminderService.java index d650d55a..28ef55c8 100644 --- a/backend/src/main/java/com/unis/crm/service/ReportReminderService.java +++ b/backend/src/main/java/com/unis/crm/service/ReportReminderService.java @@ -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 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 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())); diff --git a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml index 308c7599..7c6935ae 100644 --- a/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml +++ b/backend/src/main/resources/mapper/expansion/ExpansionMapper.xml @@ -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 diff --git a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml index 026f9654..aa3bc6e1 100644 --- a/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml +++ b/backend/src/main/resources/mapper/opportunity/OpportunityMapper.xml @@ -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 diff --git a/backend/src/test/java/com/unis/crm/service/BusinessCalendarServiceTest.java b/backend/src/test/java/com/unis/crm/service/BusinessCalendarServiceTest.java new file mode 100644 index 00000000..972d01c9 --- /dev/null +++ b/backend/src/test/java/com/unis/crm/service/BusinessCalendarServiceTest.java @@ -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.>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.>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.>any(), + eq(Date.valueOf(date)))) + .thenReturn(List.of()); + + boolean result = businessCalendarService.isHoliday(date); + + assertFalse(result); + } +} diff --git a/backend/src/test/java/com/unis/crm/service/ReportReminderServiceTest.java b/backend/src/test/java/com/unis/crm/service/ReportReminderServiceTest.java new file mode 100644 index 00000000..21b21082 --- /dev/null +++ b/backend/src/test/java/com/unis/crm/service/ReportReminderServiceTest.java @@ -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.>any())) + .thenReturn(List.of(activeReminderConfig())); + when(jdbcTemplate.query( + contains("from wecom_app_config"), + org.mockito.ArgumentMatchers.>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.>any())) + .thenReturn(List.of(activeReminderConfig())); + when(jdbcTemplate.query( + contains("from wecom_app_config"), + org.mockito.ArgumentMatchers.>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; + } +} diff --git a/frontend/src/pages/Expansion.tsx b/frontend/src/pages/Expansion.tsx index 29b4f63c..1ff34379 100644 --- a/frontend/src/pages/Expansion.tsx +++ b/frontend/src/pages/Expansion.tsx @@ -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 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 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) }, ]; diff --git a/frontend/src/pages/Opportunities.tsx b/frontend/src/pages/Opportunities.tsx index 641b0a38..a8aca8dd 100644 --- a/frontend/src/pages/Opportunities.tsx +++ b/frontend/src/pages/Opportunities.tsx @@ -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 ?

{fieldErrors.opportunityType}

: null}