From 7b33e83c55dc3502aa35986e6fecaf6a3c1f3c96 Mon Sep 17 00:00:00 2001 From: Hyojin Ahn Date: Mon, 29 Jun 2026 10:41:25 -0400 Subject: [PATCH] =?UTF-8?q?scheduler=20=EA=B0=80=20UCO=20=EB=AA=A9?= =?UTF-8?q?=ED=91=9C=EB=9F=89=EC=9D=84=20=EC=A0=84=EC=86=A1=ED=95=98?= =?UTF-8?q?=EB=8A=94=20/integration-service=20=ED=98=B8=EC=B6=9C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20=EA=B2=83=20=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../erp/client/GoogleChatNotifyClient.java | 43 +++++++ .../erp/config/JwtAuthenticationFilter.java | 15 ++- .../com/goi/erp/config/SecurityConfig.java | 1 + .../controller/SchedulerWorkerController.java | 40 +++++++ .../goi/erp/dto/ScheduleWorkerRequestDto.java | 26 +++++ .../erp/dto/ScheduleWorkerResponseDto.java | 38 ++++++ .../dto/gchat/ChatBroadcastRequestDto.java | 19 +++ .../erp/dto/gchat/ChatBroadcastResultDto.java | 12 ++ .../erp/dto/gchat/DriverChatMessageDto.java | 16 +++ .../CustomerDailyUcoRepository.java | 3 + .../erp/service/UcoTargetNotifyService.java | 109 ++++++++++++++++++ src/main/resources/application.yml | 6 +- 12 files changed, 326 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/goi/erp/client/GoogleChatNotifyClient.java create mode 100644 src/main/java/com/goi/erp/controller/SchedulerWorkerController.java create mode 100644 src/main/java/com/goi/erp/dto/ScheduleWorkerRequestDto.java create mode 100644 src/main/java/com/goi/erp/dto/ScheduleWorkerResponseDto.java create mode 100644 src/main/java/com/goi/erp/dto/gchat/ChatBroadcastRequestDto.java create mode 100644 src/main/java/com/goi/erp/dto/gchat/ChatBroadcastResultDto.java create mode 100644 src/main/java/com/goi/erp/dto/gchat/DriverChatMessageDto.java create mode 100644 src/main/java/com/goi/erp/service/UcoTargetNotifyService.java diff --git a/src/main/java/com/goi/erp/client/GoogleChatNotifyClient.java b/src/main/java/com/goi/erp/client/GoogleChatNotifyClient.java new file mode 100644 index 0000000..163f494 --- /dev/null +++ b/src/main/java/com/goi/erp/client/GoogleChatNotifyClient.java @@ -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); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java b/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java index cd77e6d..501078f 100644 --- a/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/goi/erp/config/JwtAuthenticationFilter.java @@ -26,6 +26,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { 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 protected void doFilterInternal( @NonNull HttpServletRequest request, @@ -123,4 +136,4 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { filterChain.doFilter(request, response); } -} +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/config/SecurityConfig.java b/src/main/java/com/goi/erp/config/SecurityConfig.java index d014978..ea999aa 100644 --- a/src/main/java/com/goi/erp/config/SecurityConfig.java +++ b/src/main/java/com/goi/erp/config/SecurityConfig.java @@ -40,6 +40,7 @@ public class SecurityConfig { .dispatcherTypeMatchers(DispatcherType.FORWARD).permitAll() .requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() + .requestMatchers("/scheduler/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthFilter, diff --git a/src/main/java/com/goi/erp/controller/SchedulerWorkerController.java b/src/main/java/com/goi/erp/controller/SchedulerWorkerController.java new file mode 100644 index 0000000..65218bd --- /dev/null +++ b/src/main/java/com/goi/erp/controller/SchedulerWorkerController.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/dto/ScheduleWorkerRequestDto.java b/src/main/java/com/goi/erp/dto/ScheduleWorkerRequestDto.java new file mode 100644 index 0000000..7aac131 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ScheduleWorkerRequestDto.java @@ -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> config; +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/dto/ScheduleWorkerResponseDto.java b/src/main/java/com/goi/erp/dto/ScheduleWorkerResponseDto.java new file mode 100644 index 0000000..0498535 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/ScheduleWorkerResponseDto.java @@ -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(); + } +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/dto/gchat/ChatBroadcastRequestDto.java b/src/main/java/com/goi/erp/dto/gchat/ChatBroadcastRequestDto.java new file mode 100644 index 0000000..2da62f9 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/gchat/ChatBroadcastRequestDto.java @@ -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 messages; +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/dto/gchat/ChatBroadcastResultDto.java b/src/main/java/com/goi/erp/dto/gchat/ChatBroadcastResultDto.java new file mode 100644 index 0000000..c07ee0f --- /dev/null +++ b/src/main/java/com/goi/erp/dto/gchat/ChatBroadcastResultDto.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/dto/gchat/DriverChatMessageDto.java b/src/main/java/com/goi/erp/dto/gchat/DriverChatMessageDto.java new file mode 100644 index 0000000..e754771 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/gchat/DriverChatMessageDto.java @@ -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; +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/repository/CustomerDailyUcoRepository.java b/src/main/java/com/goi/erp/repository/CustomerDailyUcoRepository.java index fe56241..4fcf042 100644 --- a/src/main/java/com/goi/erp/repository/CustomerDailyUcoRepository.java +++ b/src/main/java/com/goi/erp/repository/CustomerDailyUcoRepository.java @@ -22,6 +22,9 @@ public interface CustomerDailyUcoRepository extends JpaRepository findByCduDriverIdAndCduDateBetween( Long cduDriverId, LocalDate from, LocalDate to); + + // 해당 영업일자에 목표(qty)가 설정된 행만 기사ID 순으로 조회 + List findByCduDateAndCduTargetQtyNotNullOrderByCduDriverId(LocalDate cduDate); // ── MIS 08:00 목표 upsert (목표 컬럼만 갱신, 실측값 보존) ── @Modifying diff --git a/src/main/java/com/goi/erp/service/UcoTargetNotifyService.java b/src/main/java/com/goi/erp/service/UcoTargetNotifyService.java new file mode 100644 index 0000000..c3819b9 --- /dev/null +++ b/src/main/java/com/goi/erp/service/UcoTargetNotifyService.java @@ -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 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 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* 입니다."; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6d1c366..7f6e0ef 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -36,4 +36,8 @@ server: # ================================ hcm: api: - base-url: http://hcm-rest-api:8081/hcm-rest-api \ No newline at end of file + base-url: http://hcm-rest-api:8081/hcm-rest-api + +integration: + api: + base-url: http://integration-service:8091/integration-service \ No newline at end of file