scheduler 가 UCO 목표량을 전송하는 /integration-service 호출하는 것 추가.

This commit is contained in:
Hyojin Ahn 2026-06-29 10:41:25 -04:00
parent b777217d1f
commit 7b33e83c55
12 changed files with 326 additions and 2 deletions

View File

@ -0,0 +1,43 @@
package com.goi.erp.client;
import com.goi.erp.dto.gchat.ChatBroadcastRequestDto;
import com.goi.erp.dto.gchat.ChatBroadcastResultDto;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
/**
* crm-rest-api integration-service 호출.
* POST {integration.api.base-url}/google/chat/broadcast
* (integration driverId env webhook 해석해 기사별 방으로 fan-out 한다.)
*
* sys HcmEmployeeClient / opr SystemTokenProvider 동일하게 RestTemplate 사용.
* (crm RestTemplateConfig RestTemplate 빈을 그대로 주입받음 webflux 불필요)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GoogleChatNotifyClient {
private final RestTemplate restTemplate;
@Value("${integration.api.base-url}")
private String integrationBaseUrl;
public ChatBroadcastResultDto broadcast(ChatBroadcastRequestDto request) {
String url = integrationBaseUrl + "/google/chat/broadcast";
try {
return restTemplate.postForObject(url, request, ChatBroadcastResultDto.class);
} catch (RestClientResponseException e) {
// 4xx/5xx 응답 본문 포함해 상위(Service) 던짐
throw new RuntimeException(
"INTEGRATION_GCHAT_ERROR: " + e.getStatusCode()
+ " body=" + e.getResponseBodyAsString(), e);
}
}
}

View File

@ -26,6 +26,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService; private final JwtService jwtService;
/**
* permitAll 경로(스케줄러 워커, swagger ) JWT 필터를 아예 타지 않게 한다.
* sys 스케줄러는 토큰 없이 워커를 호출하므로, 떠도는 만료 쿠키(AUTH_TOKEN)
* 섞여 들어와도 "Session has expired" 막히면 된다.
*/
@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
String uri = request.getRequestURI(); // context-path 포함
return uri.contains("/scheduler/")
|| uri.contains("/swagger-ui")
|| uri.contains("/v3/api-docs");
}
@Override @Override
protected void doFilterInternal( protected void doFilterInternal(
@NonNull HttpServletRequest request, @NonNull HttpServletRequest request,

View File

@ -40,6 +40,7 @@ public class SecurityConfig {
.dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll() .dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll()
.requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll() .requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/scheduler/**").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(jwtAuthFilter, .addFilterBefore(jwtAuthFilter,

View File

@ -0,0 +1,40 @@
package com.goi.erp.controller;
import com.goi.erp.dto.ScheduleWorkerRequestDto;
import com.goi.erp.dto.ScheduleWorkerResponseDto;
import com.goi.erp.service.UcoTargetNotifyService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* sys-rest-api 스케줄러가 호출하는 crm 워커 엔드포인트 모음.
* opr-rest-api 동일하게 모든 워커를 컨트롤러 곳에 모은다.
* 스케줄러가 생기면 여기에 @PostMapping 메서드만 추가하면 된다.
*
* crm-rest-api 이미 SchedulerWorkerController 있다면,
* 클래스를 새로 만들지 말고 아래 필드/메서드만 기존 컨트롤러에 추가하세요.
* SecurityConfig 에서 "/scheduler/**" permitAll 인지 확인 (opr 동일).
*/
@RestController
@RequestMapping("/scheduler/worker")
@RequiredArgsConstructor
public class SchedulerWorkerController {
private final UcoTargetNotifyService ucoTargetNotifyService;
/**
* CRM_UCO_TARGET_NOTIFY
* 오늘자 기사별 UCO 목표를 기사 Google Chat 방으로 전송.
*/
@PostMapping("/uco/target/notify")
public ScheduleWorkerResponseDto notifyUcoTarget(
@RequestBody ScheduleWorkerRequestDto request
) {
return ucoTargetNotifyService.notifyDailyTargets(request);
}
}

View File

@ -0,0 +1,26 @@
package com.goi.erp.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.Map;
/**
* sys-rest-api 스케줄러가 워커로 보내는 요청.
* from/to UTC LocalDateTime 이다.
* crm-rest-api 이미 동일 DTO 있으면 파일은 추가하지 .
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ScheduleWorkerRequestDto {
private String jobCode;
private LocalDateTime from;
private LocalDateTime to;
private Integer maxRecords;
private Map<String, Map<String, String>> config;
}

View File

@ -0,0 +1,38 @@
package com.goi.erp.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 워커 sys 스케줄러 응답. (opr-rest-api 동일 계약)
* crm-rest-api 이미 동일 DTO 있으면 파일은 추가하지 .
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ScheduleWorkerResponseDto {
private boolean success;
private int processedCount;
private int successCount;
private int failCount;
private String errorCode;
private String errorMessage;
public static ScheduleWorkerResponseDto successEmpty() {
return ScheduleWorkerResponseDto.builder()
.success(true).processedCount(0).successCount(0).failCount(0).build();
}
public static ScheduleWorkerResponseDto success(int processed, int successCount, int failCount) {
return ScheduleWorkerResponseDto.builder()
.success(true).processedCount(processed).successCount(successCount).failCount(failCount).build();
}
public static ScheduleWorkerResponseDto failure(String errorCode, String errorMessage) {
return ScheduleWorkerResponseDto.builder()
.success(false).errorCode(errorCode).errorMessage(errorMessage).build();
}
}

View File

@ -0,0 +1,19 @@
package com.goi.erp.dto.gchat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
/** integration-service /google/chat/broadcast 요청 바디. */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ChatBroadcastRequestDto {
private LocalDate date;
private List<DriverChatMessageDto> messages;
}

View File

@ -0,0 +1,12 @@
package com.goi.erp.dto.gchat;
import lombok.Data;
/** integration-service 응답. details 는 생략하고 집계 수치만 받는다. */
@Data
public class ChatBroadcastResultDto {
private int requested;
private int sent;
private int failed;
private int skipped;
}

View File

@ -0,0 +1,16 @@
package com.goi.erp.dto.gchat;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/** integration-service 로 보낼 기사 1명분 메시지. webhook URL 은 보내지 않는다(integration env 에서 해석). */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DriverChatMessageDto {
private Long driverId;
private String text;
}

View File

@ -23,6 +23,9 @@ public interface CustomerDailyUcoRepository extends JpaRepository<CustomerDailyU
List<CustomerDailyUco> findByCduDriverIdAndCduDateBetween( List<CustomerDailyUco> findByCduDriverIdAndCduDateBetween(
Long cduDriverId, LocalDate from, LocalDate to); Long cduDriverId, LocalDate from, LocalDate to);
// 해당 영업일자에 목표(qty) 설정된 행만 기사ID 순으로 조회
List<CustomerDailyUco> findByCduDateAndCduTargetQtyNotNullOrderByCduDriverId(LocalDate cduDate);
// MIS 08:00 목표 upsert (목표 컬럼만 갱신, 실측값 보존) // MIS 08:00 목표 upsert (목표 컬럼만 갱신, 실측값 보존)
@Modifying @Modifying
@Query(value = """ @Query(value = """

View File

@ -0,0 +1,109 @@
package com.goi.erp.service;
import com.goi.erp.client.GoogleChatNotifyClient;
import com.goi.erp.dto.ScheduleWorkerRequestDto;
import com.goi.erp.dto.ScheduleWorkerResponseDto;
import com.goi.erp.dto.gchat.ChatBroadcastRequestDto;
import com.goi.erp.dto.gchat.ChatBroadcastResultDto;
import com.goi.erp.dto.gchat.DriverChatMessageDto;
import com.goi.erp.entity.CustomerDailyUco;
import com.goi.erp.repository.CustomerDailyUcoRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.util.List;
/**
* 오늘자 기사별 UCO 목표를 조회해 integration-service 일괄 전송 요청한다.
* sys 스케줄러가 보낸 from/to(UTC) 기준으로 영업일(운영 TZ) 산정한다.
* 별도 repository/프로젝션 없이 기존 CustomerDailyUcoRepository + CustomerDailyUco 엔티티를 그대로 사용.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class UcoTargetNotifyService {
// cdu_date 어느 TZ 기준으로 저장되는지에 맞춰야 .
// MIS 8am 스냅샷이 토론토 영업일 기준이므로 America/Toronto .
private static final ZoneId OP_ZONE = ZoneId.of("America/Toronto");
private final CustomerDailyUcoRepository customerDailyUcoRepository;
private final GoogleChatNotifyClient googleChatNotifyClient;
public ScheduleWorkerResponseDto notifyDailyTargets(ScheduleWorkerRequestDto request) {
LocalDate targetDate = resolveTargetDate(request);
List<CustomerDailyUco> rows =
customerDailyUcoRepository
.findByCduDateAndCduTargetQtyNotNullOrderByCduDriverId(targetDate);
log.info("[UCO_NOTIFY][REQUEST] jobCode={}, date={}, drivers={}",
request.getJobCode(), targetDate, rows.size());
if (rows.isEmpty()) {
return ScheduleWorkerResponseDto.successEmpty();
}
int month = targetDate.getMonthValue();
int day = targetDate.getDayOfMonth();
List<DriverChatMessageDto> messages = rows.stream()
.map(r -> DriverChatMessageDto.builder()
.driverId(r.getCduDriverId())
.text(buildText(month, day, r))
.build())
.toList();
ChatBroadcastRequestDto body = ChatBroadcastRequestDto.builder()
.date(targetDate)
.messages(messages)
.build();
try {
ChatBroadcastResultDto result = googleChatNotifyClient.broadcast(body);
int requested = result != null ? result.getRequested() : messages.size();
int sent = result != null ? result.getSent() : 0;
// 전송 실패 + 웹훅 미설정(skipped) 모두 failCount 집계
int fail = result != null ? (result.getFailed() + result.getSkipped()) : messages.size();
log.info("[UCO_NOTIFY][DONE] requested={}, sent={}, fail(incl.skipped)={}",
requested, sent, fail);
return ScheduleWorkerResponseDto.success(requested, sent, fail);
} catch (Exception e) {
log.error("[UCO_NOTIFY][FAILED] date={}, error={}", targetDate, e.getMessage(), e);
return ScheduleWorkerResponseDto.failure("UCO_NOTIFY_ERROR", e.getMessage());
}
}
/** sys 가 보낸 to(UTC) → 운영 TZ 영업일. 없으면 운영 TZ 의 오늘. */
private LocalDate resolveTargetDate(ScheduleWorkerRequestDto request) {
if (request != null && request.getTo() != null) {
return request.getTo()
.atZone(ZoneOffset.UTC)
.withZoneSameInstant(OP_ZONE)
.toLocalDate();
}
return LocalDate.now(OP_ZONE);
}
/** Google Chat 포맷: *굵게*, \n */
private String buildText(int month, int day, CustomerDailyUco r) {
BigDecimal qty = r.getCduTargetQty();
String qtyStr = (qty == null) ? "-" : String.format("%,.0f", qty);
// ) 6/29
// 오늘의 목표량은 *4,331 L* 입니다. (4,331 L 굵게)
return month + "/" + day + "\n"
+ "오늘의 목표량은 *" + qtyStr + " L* 입니다.";
}
}

View File

@ -37,3 +37,7 @@ server:
hcm: hcm:
api: api:
base-url: http://hcm-rest-api:8081/hcm-rest-api base-url: http://hcm-rest-api:8081/hcm-rest-api
integration:
api:
base-url: http://integration-service:8091/integration-service