feat(mro): GUARDiA MRO 초기 커밋 (설비보전 EAM/CMMS + MRO 자재/구매/재고, 8018, com.zioinfo.mro)

This commit is contained in:
GUARDiA 2026-07-04 09:59:47 +09:00
commit 1eec79d860
185 changed files with 15521 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
target/
node_modules/
dist/
build/
.gradle/
*.log
__pycache__/

87
backend/pom.xml Normal file
View File

@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.11</version>
</parent>
<groupId>com.zioinfo</groupId>
<artifactId>guardia-mro</artifactId>
<version>1.0.0</version>
<name>GUARDiA MRO</name>
<description>AI 기반 설비보전(EAM/CMMS) + MRO 자재 플랫폼 — Claude/Ollama AI(AiTextRouter) + ITSM/ERP/MES 연계</description>
<properties>
<java.version>17</java.version>
<jjwt.version>0.12.6</jjwt.version>
<springdoc.version>2.6.0</springdoc.version>
<mybatis.version>3.0.3</mybatis.version>
<postgresql.version>42.7.7</postgresql.version>
<duckdb.version>1.1.3</duckdb.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>
<!-- DB Driver -->
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><version>${postgresql.version}</version></dependency>
<!-- DuckDB (로컬 임베디드 AI 학습/추론 저장소 — /opt/guardia-mro/data/mro_learning.duckdb) -->
<!-- 런타임 전용: 코드는 java.sql + 리플렉션 드라이버 로드로 컴파일 의존 0. 미가용 시 학습 저장만 비활성. -->
<dependency><groupId>org.duckdb</groupId><artifactId>duckdb_jdbc</artifactId><version>${duckdb.version}</version><scope>runtime</scope></dependency>
<!-- JWT -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
<!-- OpenAPI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<!-- Lombok -->
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
<!-- HTTP Client (Ollama / 연계 GUARDiA 호출) -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency>
<!-- Test -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build>
<finalName>guardia-mro-${project.version}</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,27 @@
package com.zioinfo.mro;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* GUARDiA MRO AI 기반 설비보전(EAM/CMMS) + MRO 자재 플랫폼.
*
* <p>설비보전(EAM/CMMS): 설비마스터(위치/계층)·예방보전(PM)계획·작업지시(WO)·고장/정비이력·
* 계측기 교정·다운타임·신뢰성지표(MTBF/MTTR/가동률).
* <p>MRO 자재: 자재/공구/부품 마스터·자재BOM·재고(로케이션·안전재고)·입출고·재고실사·
* 구매요청(PR)발주(PO)입고·거래처·MRO 비용/예산.
*
* <p>보안 불변 규칙: 외부 AI API 금지(Ollama localhost + Claude(api.anthropic.com) 예외만),
* 거래처 PII는 AES-256-GCM 암호화·마스킹, API 응답에 자격증명·SSH·내부IP 제외,
* 스택트레이스 미노출(에러 코드만).
*/
@SpringBootApplication
@EnableScheduling
@EnableAsync
public class MroApplication {
public static void main(String[] args) {
SpringApplication.run(MroApplication.class, args);
}
}

View File

@ -0,0 +1,96 @@
package com.zioinfo.mro.admin;
import com.zioinfo.mro.admin.dto.AuditLog;
import com.zioinfo.mro.admin.dto.MroSetting;
import com.zioinfo.mro.admin.dto.UserDto;
import com.zioinfo.mro.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* GUARDiA MRO 관리자 API.
*
* <p>RBAC(SecurityConfig requestMatchers 통제):
* <ul>
* <li>/api/admin/users/** SUPERADMIN</li>
* <li>/api/admin/audit SUPERADMIN, MANAGER</li>
* <li>/api/admin/settings GET: SUPERADMIN/MANAGER, PUT: SUPERADMIN</li>
* </ul>
*/
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminController {
private final AdminUserService userService;
private final AuditService auditService;
private final SettingService settingService;
// ===================== 1. 사용자 관리 (SUPERADMIN) =====================
@GetMapping("/users")
public ApiResponse<List<UserDto>> listUsers() {
return ApiResponse.ok(userService.list());
}
@PostMapping("/users")
public ApiResponse<UserDto> createUser(@RequestBody CreateUserRequest req) {
return ApiResponse.ok(userService.create(req.username(), req.password(), req.displayName(), req.role()));
}
@PutMapping("/users/{id}/role")
public ApiResponse<UserDto> updateRole(@PathVariable Long id, @RequestBody RoleRequest req) {
return ApiResponse.ok(userService.updateRole(id, req.role()));
}
@PutMapping("/users/{id}/active")
public ApiResponse<UserDto> updateActive(@PathVariable Long id, @RequestBody ActiveRequest req) {
return ApiResponse.ok(userService.updateActive(id, req.active()));
}
@PutMapping("/users/{id}/password")
public ApiResponse<UserDto> resetPassword(@PathVariable Long id, @RequestBody PasswordRequest req) {
return ApiResponse.ok(userService.resetPassword(id, req.password()));
}
@DeleteMapping("/users/{id}")
public ApiResponse<Void> deleteUser(@PathVariable Long id, Authentication auth) {
String currentUsername = auth != null ? auth.getName() : null;
userService.delete(id, currentUsername);
return ApiResponse.ok(null);
}
// ===================== 2. 감사 로그 (SUPERADMIN/MANAGER) =====================
@GetMapping("/audit")
public ApiResponse<List<AuditLog>> audit(
@RequestParam(value = "action", required = false) String action,
@RequestParam(value = "actor", required = false) String actor,
@RequestParam(value = "limit", defaultValue = "100") int limit) {
return ApiResponse.ok(auditService.find(action, actor, limit));
}
// ===================== 3. 시스템 설정 =====================
@GetMapping("/settings")
public ApiResponse<List<MroSetting>> settings() {
return ApiResponse.ok(settingService.list());
}
@PutMapping("/settings/{key}")
public ApiResponse<MroSetting> updateSetting(@PathVariable String key,
@RequestBody SettingRequest req) {
return ApiResponse.ok(settingService.update(key, req.value()));
}
// ===================== 요청 DTO =====================
record CreateUserRequest(String username, String password, String displayName, String role) {}
record RoleRequest(String role) {}
record ActiveRequest(boolean active) {}
record PasswordRequest(String password) {}
record SettingRequest(String value) {}
}

View File

@ -0,0 +1,118 @@
package com.zioinfo.mro.admin;
import com.zioinfo.mro.admin.dto.UserDto;
import com.zioinfo.mro.admin.mapper.AdminUserMapper;
import com.zioinfo.mro.auth.MroUser;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
/**
* 관리자 사용자 관리 서비스 (SUPERADMIN 전용).
*
* <p>RBAC 역할: SUPERADMIN / MANAGER / WORKER / VIEWER.
* 보안: 응답은 항상 {@link UserDto} 변환하여 password_hash 노출 차단.
*/
@Service
@RequiredArgsConstructor
public class AdminUserService {
private static final Set<String> VALID_ROLES = Set.of("SUPERADMIN", "MANAGER", "WORKER", "VIEWER");
private final AdminUserMapper mapper;
private final PasswordEncoder passwordEncoder;
private final AuditService auditService;
public List<UserDto> list() {
return mapper.findAll().stream().map(UserDto::from).toList();
}
public UserDto create(String username, String password, String displayName, String role) {
if (username == null || username.isBlank()) {
throw new IllegalArgumentException("ERR-USR-400: username 필수");
}
if (password == null || password.isBlank()) {
throw new IllegalArgumentException("ERR-USR-400: password 필수");
}
String resolvedRole = normalizeRole(role);
if (mapper.countByUsername(username) > 0) {
throw new RuntimeException("ERR-USR-409: 이미 존재하는 username");
}
MroUser user = new MroUser();
user.setUsername(username);
user.setPasswordHash(passwordEncoder.encode(password));
user.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName);
user.setRole(resolvedRole);
user.setActive(true);
mapper.insert(user);
auditService.log("USER_CREATE", username, "role=" + resolvedRole);
return UserDto.from(user);
}
public UserDto updateRole(Long id, String role) {
MroUser user = require(id);
String newRole = normalizeRole(role);
if ("SUPERADMIN".equals(user.getRole()) && !"SUPERADMIN".equals(newRole)
&& user.isActive() && mapper.countSuperAdmins() <= 1) {
throw new RuntimeException("ERR-USR-423: 마지막 SUPERADMIN 계정의 역할은 변경할 수 없습니다");
}
mapper.updateRole(id, newRole);
auditService.log("USER_ROLE_CHANGE", user.getUsername(), user.getRole() + " -> " + newRole);
return UserDto.from(require(id));
}
public UserDto updateActive(Long id, boolean active) {
MroUser user = require(id);
if (!active && "SUPERADMIN".equals(user.getRole()) && user.isActive()
&& mapper.countSuperAdmins() <= 1) {
throw new RuntimeException("ERR-USR-423: 마지막 SUPERADMIN 계정은 비활성화할 수 없습니다");
}
mapper.updateActive(id, active);
auditService.log("USER_ACTIVE_TOGGLE", user.getUsername(), "active=" + active);
return UserDto.from(require(id));
}
public UserDto resetPassword(Long id, String newPassword) {
if (newPassword == null || newPassword.isBlank()) {
throw new IllegalArgumentException("ERR-USR-400: password 필수");
}
MroUser user = require(id);
mapper.updatePassword(id, passwordEncoder.encode(newPassword));
auditService.log("USER_PASSWORD_RESET", user.getUsername(), "password reset");
return UserDto.from(user);
}
public void delete(Long id, String currentUsername) {
MroUser user = require(id);
if (user.getUsername().equals(currentUsername)) {
throw new RuntimeException("ERR-USR-423: 자기 자신은 삭제할 수 없습니다");
}
if ("SUPERADMIN".equals(user.getRole()) && user.isActive() && mapper.countSuperAdmins() <= 1) {
throw new RuntimeException("ERR-USR-423: 마지막 SUPERADMIN 계정은 삭제할 수 없습니다");
}
mapper.deleteById(id);
auditService.log("USER_DELETE", user.getUsername(), "role=" + user.getRole());
}
private MroUser require(Long id) {
MroUser user = mapper.findById(id);
if (user == null) {
throw new RuntimeException("ERR-USR-404: 사용자를 찾을 수 없습니다");
}
return user;
}
private String normalizeRole(String role) {
if (role == null || role.isBlank()) {
return "VIEWER";
}
String upper = role.trim().toUpperCase();
if (!VALID_ROLES.contains(upper)) {
throw new IllegalArgumentException("ERR-USR-400: 유효하지 않은 role (SUPERADMIN/MANAGER/WORKER/VIEWER)");
}
return upper;
}
}

View File

@ -0,0 +1,53 @@
package com.zioinfo.mro.admin;
import com.zioinfo.mro.admin.dto.AuditLog;
import com.zioinfo.mro.admin.mapper.AuditLogMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 감사 로그 서비스 작업지시 전이·PM·입출고·검교정·관리자 작업 주요 변경을 mro_audit_log 기록.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuditService {
private final AuditLogMapper mapper;
public void log(String action, String target, String detail) {
log(currentActor(), action, target, detail);
}
public void log(String actor, String action, String target, String detail) {
try {
AuditLog entry = new AuditLog();
entry.setActor(actor);
entry.setAction(action);
entry.setTarget(target);
entry.setDetail(detail);
mapper.insert(entry);
} catch (Exception e) {
log.warn("감사 로그 기록 실패 [{}]: {}", action, e.getMessage());
}
}
public List<AuditLog> find(String action, String actor, int limit) {
int safeLimit = (limit <= 0 || limit > 1000) ? 100 : limit;
return mapper.find(action, actor, safeLimit);
}
/** SecurityContext 의 JWT subject(username)를 추출. 없으면 system. */
public static String currentActor() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getName() != null && !auth.getName().isBlank()) {
return auth.getName();
}
return "system";
}
}

View File

@ -0,0 +1,35 @@
package com.zioinfo.mro.admin;
import com.zioinfo.mro.admin.dto.MroSetting;
import com.zioinfo.mro.admin.mapper.SettingMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class SettingService {
private final SettingMapper mapper;
private final AuditService auditService;
public List<MroSetting> list() {
return mapper.findAll();
}
public MroSetting update(String key, String value) {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("ERR-SET-400: key 필수");
}
mapper.upsert(key, value);
auditService.log("SETTING_UPDATE", key, "value=" + value);
return mapper.findByKey(key);
}
/** 설정값 조회(없으면 기본값). 다른 서비스의 임계값 참조용. */
public String get(String key, String defaultValue) {
MroSetting s = mapper.findByKey(key);
return (s == null || s.getValue() == null) ? defaultValue : s.getValue();
}
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.mro.admin.dto;
import lombok.Data;
import java.time.LocalDateTime;
/** 감사 로그 (mro_audit_log 테이블 매핑). */
@Data
public class AuditLog {
private Long id;
private String actor; // 작업 수행자 (JWT subject = username)
private String action; // WORKORDER_RELEASE, PM_PLAN_CREATE, PO_RECEIVE
private String target; // 대상 식별자
private String detail; // 부가 설명
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,13 @@
package com.zioinfo.mro.admin.dto;
import lombok.Data;
import java.time.LocalDateTime;
/** 시스템 설정 (mro_setting 테이블 매핑). */
@Data
public class MroSetting {
private String key;
private String value;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,30 @@
package com.zioinfo.mro.admin.dto;
import com.zioinfo.mro.auth.MroUser;
import lombok.Data;
import java.time.LocalDateTime;
/** 사용자 응답 DTO — password_hash 노출 차단. */
@Data
public class UserDto {
private Long id;
private String username;
private String displayName;
private String email;
private String role;
private boolean active;
private LocalDateTime createdAt;
public static UserDto from(MroUser u) {
UserDto d = new UserDto();
d.id = u.getId();
d.username = u.getUsername();
d.displayName = u.getDisplayName();
d.email = u.getEmail();
d.role = u.getRole();
d.active = u.isActive();
d.createdAt = u.getCreatedAt();
return d;
}
}

View File

@ -0,0 +1,31 @@
package com.zioinfo.mro.admin.mapper;
import com.zioinfo.mro.auth.MroUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 관리자 사용자 관리 매퍼 (mro_user). @Mapper 필수. */
@Mapper
public interface AdminUserMapper {
List<MroUser> findAll();
MroUser findById(@Param("id") Long id);
int insert(MroUser user);
int updateRole(@Param("id") Long id, @Param("role") String role);
int updateActive(@Param("id") Long id, @Param("active") boolean active);
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
int deleteById(@Param("id") Long id);
/** 활성 SUPERADMIN 계정 수 (마지막 관리자 삭제/강등 방지용). */
int countSuperAdmins();
int countByUsername(@Param("username") String username);
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.mro.admin.mapper;
import com.zioinfo.mro.admin.dto.AuditLog;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AuditLogMapper {
int insert(AuditLog log);
List<AuditLog> find(@Param("action") String action,
@Param("actor") String actor,
@Param("limit") int limit);
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.mro.admin.mapper;
import com.zioinfo.mro.admin.dto.MroSetting;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface SettingMapper {
List<MroSetting> findAll();
MroSetting findByKey(@Param("key") String key);
int upsert(@Param("key") String key, @Param("value") String value);
}

View File

@ -0,0 +1,67 @@
package com.zioinfo.mro.ai;
import com.zioinfo.mro.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* GUARDiA MRO AI 도구 API 6개 설비보전·MRO 자재 AI(AiTextRouter 경유 + Java 폴백).
*
* <p>RBAC: 조회 Viewer+ (GET), 분석 트리거 Worker+ (POST).
*/
@RestController
@RequestMapping("/api/mro/ai")
@RequiredArgsConstructor
public class AiController {
private final AiService ai;
@GetMapping("/status")
public ApiResponse<Map<String, Object>> status() {
return ApiResponse.ok(Map.of("ollamaAvailable", ai.ollamaAvailable()));
}
@PostMapping("/fault-root-cause")
public ApiResponse<Map<String, Object>> faultRootCause(@RequestBody FaultRequest req) {
return ApiResponse.ok(ai.faultRootCause(req.equipmentCode(), req.symptom(), req.history()));
}
@PostMapping("/predictive-maintenance")
public ApiResponse<Map<String, Object>> pdm(@RequestBody PdmRequest req) {
return ApiResponse.ok(ai.predictiveMaintenance(
req.equipmentCode(), req.availability(), req.downtimeCount(), req.mtbfHours()));
}
@PostMapping("/demand-forecast")
public ApiResponse<Map<String, Object>> forecast(@RequestBody ForecastRequest req) {
return ApiResponse.ok(ai.demandForecast(req.series(), req.horizon()));
}
@PostMapping("/parse-query")
public ApiResponse<Map<String, Object>> parseQuery(@RequestBody QueryRequest req) {
return ApiResponse.ok(ai.parseQuery(req.query()));
}
@PostMapping("/safety-stock")
public ApiResponse<Map<String, Object>> safetyStock(@RequestBody SafetyStockRequest req) {
return ApiResponse.ok(ai.safetyStock(
req.avgDailyDemand(), req.demandStdDev(), req.leadTimeDays(), req.serviceZ()));
}
@PostMapping("/reliability-anomaly")
public ApiResponse<Map<String, Object>> reliability(@RequestBody ReliabilityRequest req) {
return ApiResponse.ok(ai.reliabilityAnomaly(
req.mtbfHours(), req.mttrHours(), req.availability(), req.mtbfTarget()));
}
// request DTO
record FaultRequest(String equipmentCode, String symptom, List<Map<String, Object>> history) {}
record PdmRequest(String equipmentCode, double availability, int downtimeCount, double mtbfHours) {}
record ForecastRequest(List<Double> series, int horizon) {}
record QueryRequest(String query) {}
record SafetyStockRequest(double avgDailyDemand, double demandStdDev, double leadTimeDays, double serviceZ) {}
record ReliabilityRequest(double mtbfHours, double mttrHours, double availability, Double mtbfTarget) {}
}

View File

@ -0,0 +1,214 @@
package com.zioinfo.mro.ai;
import com.zioinfo.mro.ai.service.AiTextRouter;
import com.zioinfo.mro.common.ai.TextAiClient.GenResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* GUARDiA MRO AI 서비스 6개 설비보전·MRO 자재 AI 기능. AiTextRouter(ClaudeOllama 폴백) 경유 + Java 폴백.
*
* <p>보안 불변 규칙: AI 호출은 AiTextRouter 경유(외부 API anthropic 예외 금지).
* 라우터 degraded 모든 메서드가 규칙기반/통계 폴백으로 동작한다(예외 없음).
* <ol>
* <li>고장 원인 분석 증상/설비/이력 상관 추정 원인·조치</li>
* <li>예지보전 가동률/다운타임/MTBF 패턴 이상 정비 권고(위험도)</li>
* <li>자재 수요 예측 소비 추세(가중이동평균+추세) 폴백</li>
* <li>자연어 조회 Text검색필터 추출</li>
* <li>안전재고·재발주점 추천 수요 변동/리드타임 기반</li>
* <li>신뢰성 지표 이상 감지 MTBF/MTTR/가동률 임계 판정</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiService {
private final OllamaClient ollama;
/** provider 라우팅(Claude→Ollama 폴백) + 로컬 학습 로그 경유. */
private final AiTextRouter aiTextRouter;
public boolean ollamaAvailable() {
return ollama.available();
}
/** 라우터 경유 텍스트 생성 — degraded 면 null(호출부가 Java 폴백 수행). */
private String routeGenerate(String prompt) {
GenResult r = aiTextRouter.generate(prompt);
return (r != null && !r.degraded()) ? r.text() : null;
}
// 1. 고장 원인 분석 (증상/설비/정비이력 상관)
public Map<String, Object> faultRootCause(String equipmentCode, String symptom,
List<Map<String, Object>> history) {
Map<String, Object> result = new LinkedHashMap<>();
String ctx = history == null ? "" : truncate(history.toString(), 1200);
String prompt = String.format(
"당신은 설비보전(CMMS) 신뢰성 엔지니어입니다. 설비 '%s' 의 고장 증상 '%s' 와 최근 정비이력: %s. "
+ "가장 가능성 높은 추정 원인 1가지와 권고 정비 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.",
equipmentCode, symptom, ctx);
String out = routeGenerate(prompt);
if (out != null && !out.isBlank()) {
result.put("cause", firstNonBlank(extractLine(out, "CAUSE:"), out));
result.put("action", extractLine(out, "ACTION:"));
result.put("source", "ai");
return result;
}
// Java 폴백: 증상 키워드 규칙
String s = symptom == null ? "" : symptom.toLowerCase();
String cause; String action;
if (s.contains("진동") || s.contains("vib")) {
cause = "베어링 마모/정렬 불량(진동 이상)"; action = "베어링 점검·교체 + 축 정렬 재조정";
} else if (s.contains("과열") || s.contains("온도") || s.contains("heat")) {
cause = "윤활 부족 또는 냉각계통 이상(과열)"; action = "윤활유 보충·교환 + 냉각팬/필터 점검";
} else if (s.contains("소음") || s.contains("noise")) {
cause = "기어/체인 마모 또는 이물 혼입(이상 소음)"; action = "구동부 분해 점검 + 소모품 교체";
} else if (s.contains("누유") || s.contains("leak")) {
cause = "씰/개스킷 열화(누유)"; action = "씰·개스킷 교체 + 체결 토크 재점검";
} else {
cause = "원인 미상(상관 데이터 부족 — 추가 점검 필요)"; action = "정비이력·다운타임 추이 확인 + 계측 점검";
}
result.put("cause", cause);
result.put("action", action);
result.put("source", "fallback");
return result;
}
// 2. 예지보전 (가동률/다운타임/MTBF 이상 정비 권고)
public Map<String, Object> predictiveMaintenance(String equipmentCode, double availability,
int downtimeCount, double mtbfHours) {
Map<String, Object> result = new LinkedHashMap<>();
double risk = (availability < 0.7 ? 0.4 : availability < 0.85 ? 0.2 : 0.0)
+ Math.min(0.3, downtimeCount * 0.05)
+ (mtbfHours > 0 && mtbfHours < 100 ? 0.3 : mtbfHours < 300 ? 0.15 : 0.0);
risk = Math.min(1.0, risk);
String level = risk >= 0.6 ? "HIGH" : risk >= 0.3 ? "MEDIUM" : "LOW";
String recommend = switch (level) {
case "HIGH" -> "즉시 예방 정비 권고 — 가동률 저하 + 잦은 다운타임. ITSM SR 자동생성 대상.";
case "MEDIUM" -> "정비 주기 단축 검토 권고.";
default -> "정상 — 정기 점검(PM 계획) 유지.";
};
result.put("equipmentCode", equipmentCode);
result.put("risk", round2(risk));
result.put("level", level);
result.put("recommendation", recommend);
result.put("source", "fallback");
return result;
}
// 3. 자재 수요 예측 (가중이동평균 + 선형추세 폴백)
public Map<String, Object> demandForecast(List<Double> series, int horizon) {
Map<String, Object> result = new LinkedHashMap<>();
int h = horizon <= 0 ? 7 : Math.min(horizon, 90);
List<Double> preds = new ArrayList<>();
if (series == null || series.isEmpty()) {
for (int i = 0; i < h; i++) preds.add(0.0);
result.put("method", "empty");
} else {
int n = series.size();
double avg = series.stream().mapToDouble(Double::doubleValue).average().orElse(0);
double slope = n >= 2 ? (series.get(n - 1) - series.get(0)) / (n - 1) : 0;
double last = series.get(n - 1);
for (int i = 1; i <= h; i++) {
double p = Math.max(0, (last + slope * i) * 0.6 + avg * 0.4);
preds.add(round2(p));
}
result.put("method", "weighted-moving-average+trend");
}
result.put("horizon", h);
result.put("predictions", preds);
result.put("source", "fallback");
return result;
}
// 4. 자연어 조회 검색 필터 추출
public Map<String, Object> parseQuery(String naturalQuery) {
Map<String, Object> filter = new LinkedHashMap<>();
if (naturalQuery == null || naturalQuery.isBlank()) return filter;
String prompt = "다음 질의에서 검색 필터를 'STATUS:..\\nEQUIP:..\\nMATERIAL:..\\nDATE:..' 형식으로만 추출:\\n" + naturalQuery;
String out = routeGenerate(prompt);
if (out != null && !out.isBlank()) {
putIf(filter, "status", extractLine(out, "STATUS:"));
putIf(filter, "equipment", extractLine(out, "EQUIP:"));
putIf(filter, "material", extractLine(out, "MATERIAL:"));
putIf(filter, "date", extractLine(out, "DATE:"));
if (!filter.isEmpty()) { filter.put("source", "ai"); return filter; }
}
// Java 폴백: 키워드 매칭
String q = naturalQuery.toLowerCase();
if (q.contains("진행") || q.contains("배정") || q.contains("assigned")) filter.put("status", "ASSIGNED");
else if (q.contains("완료") || q.contains("done")) filter.put("status", "DONE");
else if (q.contains("대기") || q.contains("접수") || q.contains("created")) filter.put("status", "CREATED");
if (q.contains("부족") || q.contains("안전재고")) filter.put("belowSafety", true);
if (q.contains("고장") || q.contains("정지") || q.contains("down")) filter.put("equipment", "DOWN");
filter.put("source", "fallback");
return filter;
}
// 5. 안전재고·재발주점 추천 (수요 변동/리드타임 기반)
public Map<String, Object> safetyStock(double avgDailyDemand, double demandStdDev,
double leadTimeDays, double serviceZ) {
Map<String, Object> result = new LinkedHashMap<>();
double z = serviceZ <= 0 ? 1.65 : serviceZ; // 95% 서비스 수준 기본
double safety = z * demandStdDev * Math.sqrt(Math.max(0, leadTimeDays));
double reorderPoint = avgDailyDemand * leadTimeDays + safety;
result.put("safetyStock", round2(Math.max(0, safety)));
result.put("reorderPoint", round2(Math.max(0, reorderPoint)));
result.put("serviceZ", z);
result.put("source", "fallback");
return result;
}
// 6. 신뢰성 지표 이상 감지 (MTBF/MTTR/가동률 임계 판정)
public Map<String, Object> reliabilityAnomaly(double mtbfHours, double mttrHours,
double availability, Double mtbfTarget) {
Map<String, Object> result = new LinkedHashMap<>();
List<String> flags = new ArrayList<>();
if (availability < 0.85) flags.add("가동률 저하(" + round2(availability * 100) + "% < 85%)");
if (mttrHours > 8) flags.add("MTTR 과다(" + round2(mttrHours) + "h > 8h)");
double target = mtbfTarget != null && mtbfTarget > 0 ? mtbfTarget : 300;
if (mtbfHours > 0 && mtbfHours < target) flags.add("MTBF 목표 미달(" + round2(mtbfHours) + "h < " + round2(target) + "h)");
result.put("anomaly", !flags.isEmpty());
result.put("flags", flags);
result.put("availability", round2(availability));
result.put("mtbfHours", round2(mtbfHours));
result.put("mttrHours", round2(mttrHours));
result.put("source", "fallback");
return result;
}
// helpers
private void putIf(Map<String, Object> m, String k, String v) {
if (v != null && !v.isBlank()) m.put(k, v.trim());
}
private String extractLine(String out, String tag) {
if (out == null) return "";
for (String line : out.split("[\\n\\r]+")) {
String l = line.trim();
if (l.toUpperCase().startsWith(tag.toUpperCase())) {
return l.substring(tag.length()).trim();
}
}
return "";
}
private String firstNonBlank(String a, String b) {
return (a != null && !a.isBlank()) ? a : b;
}
private String truncate(String s, int max) {
if (s == null) return "";
return s.length() <= max ? s : s.substring(0, max);
}
private double round2(double d) {
return Math.round(d * 100.0) / 100.0;
}
}

View File

@ -0,0 +1,100 @@
package com.zioinfo.mro.ai;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.List;
import java.util.Map;
/**
* Ollama 온프레미스 LLM 클라이언트.
*
* <p>보안 불변 규칙: localhost Ollama만 호출. 외부 AI API 절대 금지.
* 오프라인/장애 절대 예외를 던지지 않고 문자열을 반환한다(서비스 계층이 Java 폴백 수행).
*/
@Slf4j
@Component
public class OllamaClient {
private final WebClient.Builder builder;
private final String ollamaUrl;
private final String textModel;
private final String visionModel;
public OllamaClient(WebClient.Builder builder,
@Value("${guardia.ollama-url:http://localhost:11434}") String ollamaUrl,
@Value("${guardia.ollama-text-model:llama3.2:1b}") String textModel,
@Value("${guardia.ollama-vision-model:moondream}") String visionModel) {
this.builder = builder;
this.ollamaUrl = ollamaUrl;
this.textModel = textModel;
this.visionModel = visionModel;
}
/** 프롬프트로 텍스트 생성(기본 텍스트 모델). 실패 시 빈 문자열 반환(예외 없음). */
public String generate(String prompt) {
return generate(prompt, textModel);
}
/** 지정 모델로 텍스트 생성(AiTextRouter 의 provider 별 소형 모델 선택 경유). 실패 시 빈 문자열. */
@SuppressWarnings("unchecked")
public String generate(String prompt, String model) {
String useModel = (model == null || model.isBlank()) ? textModel : model.trim();
try {
Map<String, Object> body = Map.of("model", useModel, "prompt", prompt, "stream", false);
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
.post().uri("/api/generate")
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(120))
.map(m -> (Map<String, Object>) m)
.block();
if (res == null) return "";
Object r = res.get("response");
return r == null ? "" : String.valueOf(r).trim();
} catch (Exception e) {
log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getMessage());
return "";
}
}
/** 비전 모델로 이미지(base64) 분석. 실패 시 빈 문자열. */
@SuppressWarnings("unchecked")
public String vision(String prompt, String imageBase64) {
try {
Map<String, Object> body = Map.of(
"model", visionModel,
"prompt", prompt,
"images", List.of(imageBase64),
"stream", false);
Map<String, Object> res = builder.baseUrl(ollamaUrl).build()
.post().uri("/api/generate")
.bodyValue(body)
.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(120))
.map(m -> (Map<String, Object>) m)
.block();
if (res == null) return "";
Object r = res.get("response");
return r == null ? "" : String.valueOf(r).trim();
} catch (Exception e) {
log.warn("Ollama 비전 일시 불가 — Java 폴백 사용: {}", e.getMessage());
return "";
}
}
public boolean available() {
try {
builder.baseUrl(ollamaUrl).build().get().uri("/api/tags")
.retrieve().bodyToMono(String.class).timeout(Duration.ofSeconds(3)).block();
return true;
} catch (Exception e) {
return false;
}
}
}

View File

@ -0,0 +1,50 @@
package com.zioinfo.mro.ai.controller;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.ai.dto.AiConfigDto;
import com.zioinfo.mro.ai.dto.AiConfigUpdateRequest;
import com.zioinfo.mro.ai.service.AiConfigService;
import com.zioinfo.mro.common.ApiResponse;
import lombok.RequiredArgsConstructor;
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;
import org.springframework.web.bind.annotation.RestController;
/**
* AI 플랫폼(LLM provider) 설정 관리(ADMIN). [GUARDiA-MRO]
* SecurityConfig {@code /api/admin/** hasRole(SUPERADMIN)} 게이트로 보호된다.
*
* <ul>
* <li>GET /api/admin/ai-config 현재 효과 설정( 제외, keySet 불리언만)</li>
* <li>PUT /api/admin/ai-config provider/모델 갱신(화이트리스트 검증)</li>
* <li>POST /api/admin/ai-config/test 저장 설정 기준 선택 provider 연결 테스트(요약 결과만)</li>
* </ul>
*/
@RestController
@RequestMapping("/api/admin/ai-config")
@RequiredArgsConstructor
public class AiConfigController {
private final AiConfigService service;
@GetMapping
public ApiResponse<AiConfigDto> get() {
return ApiResponse.ok(service.getConfig());
}
@PutMapping
public ApiResponse<AiConfigDto> update(@RequestBody AiConfigUpdateRequest req) {
return ApiResponse.ok(service.update(req, AuditService.currentActor()));
}
@PostMapping("/test")
public ApiResponse<AiConfigService.TestResult> test() {
AiConfigService.TestResult result = service.test();
return result.ok()
? ApiResponse.ok(result)
: new ApiResponse<>(false, result.message(), result);
}
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.mro.ai.dto;
/**
* AI 설정 조회 DTO API 미반환(claudeKeySet 으로 설정 여부만). [GUARDiA-MRO]
*
* @param provider 효과 provider(ollama/claude/qwen3/deepseek/glm)
* @param ollamaTextModel 효과 Ollama 텍스트 모델(provider 해석 결과)
* @param claudeModel 효과 Claude 모델 ID
* @param claudeKeySet 서버 환경변수 ANTHROPIC_API_KEY 존재 여부(/길이/마스킹 일절 미포함)
* @param aiEnabled 전역 AI 사용 여부(off 규칙 기반 degraded)
*/
public record AiConfigDto(
String provider,
String ollamaTextModel,
String claudeModel,
boolean claudeKeySet,
boolean aiEnabled) {
}

View File

@ -0,0 +1,14 @@
package com.zioinfo.mro.ai.dto;
/**
* AI 설정 저장 요청 화이트리스트 검증. [GUARDiA-MRO]
*
* @param provider ollama/claude/qwen3/deepseek/glm (필수)
* @param claudeModel Claude 모델 ID(선택, 미제공 기존 유지)
* @param ollamaTextModel Ollama 텍스트 모델(선택, 미제공 기존 유지)
*/
public record AiConfigUpdateRequest(
String provider,
String claudeModel,
String ollamaTextModel) {
}

View File

@ -0,0 +1,91 @@
package com.zioinfo.mro.ai.learning;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
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;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* AI 답변 피드백 수집(👍/👎 + 교정). [GUARDiA-MRO]
*
* <p>{@code POST /api/ai/feedback} 로컬 DuckDB({@link AiLearningStore}) 적재
* + 중앙 guardia-rag {@code /feedback} 전달. 하나가 실패해도 200 유지한다.
* 보안 불변: 중앙 rag 온프레미스 루프백 전용. 응답에 /스택트레이스 미포함. PII 적재 마스킹.
*/
@Slf4j
@RestController
@RequestMapping("/api/ai/feedback")
@RequiredArgsConstructor
public class AiFeedbackController {
private final AiLearningStore store;
private final WebClient.Builder webClientBuilder;
@Value("${guardia.rag.base-url:http://127.0.0.1:8020}")
private String ragBaseUrl;
@Value("${guardia.rag.enabled:true}")
private boolean ragEnabled;
@PostMapping
public ApiResponse<Map<String, Object>> feedback(@RequestBody FeedbackRequest req) {
String actor = AuditService.currentActor();
store.saveFeedback(req.feature(), req.question(), req.answer(),
req.verdict(), req.correction(), actor);
boolean forwarded = forwardToCentralRag(req);
Map<String, Object> out = new LinkedHashMap<>();
out.put("stored", store.isEnabled());
out.put("forwarded", forwarded);
return ApiResponse.ok(out);
}
private boolean forwardToCentralRag(FeedbackRequest req) {
if (!ragEnabled) {
return false;
}
try {
Map<String, Object> body = new LinkedHashMap<>();
body.put("solution", "mro");
body.put("feature", req.feature());
body.put("question", req.question());
body.put("answer", req.answer());
body.put("verdict", req.verdict());
body.put("correction", req.correction());
webClientBuilder.baseUrl(ragBaseUrl).build()
.post().uri("/feedback")
.bodyValue(body)
.retrieve()
.toBodilessEntity()
.timeout(Duration.ofSeconds(5))
.subscribe(
ok -> {},
err -> log.debug("중앙 rag /feedback 전달 실패(무시): {}", err.getClass().getSimpleName()));
return true;
} catch (Exception e) {
log.debug("중앙 rag /feedback 전달 예외(무시): {}", e.getClass().getSimpleName());
return false;
}
}
/** 피드백 요청 — verdict: up/down, correction: 교정문(선택). */
public record FeedbackRequest(
String feature,
String question,
String answer,
String verdict,
String correction) {
}
}

View File

@ -0,0 +1,163 @@
package com.zioinfo.mro.ai.learning;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.regex.Pattern;
/**
* MRO AI 로컬 학습 저장소 임베디드 <b>DuckDB</b>(단일 파일). [GUARDiA-MRO]
*
* <p>AI 피드백(👍/👎+교정) 추론 로그를 솔루션 로컬 DuckDB 파일에 적재해 오프라인 AI 분석/학습 데이터셋으로
* 사용한다. 스키마(멱등): ai_feedback / ai_infer_log.
*
* <p><b>안전성</b>: 드라이버는 리플렉션 로드(컴파일 타임 의존 0). 파일/디렉터리 미가용이면
* {@code enabled=false} 조용히 비활성 절대 예외를 던지지 않아 AI 기능은 그대로 동작한다.
* <b>보안</b>: 저장 PII(주민번호·카드·전화·이메일)·자격증명 마스킹. 솔루션별 파일 격리.
*/
@Slf4j
@Component
public class AiLearningStore {
private static final String SOLUTION = "mro";
private static final Pattern RRN = Pattern.compile("\\d{6}[- ]?\\d{7}");
private static final Pattern CARD = Pattern.compile("\\b(?:\\d[ -]?){13,16}\\b");
private static final Pattern PHONE = Pattern.compile("01[016789][- ]?\\d{3,4}[- ]?\\d{4}");
private static final Pattern EMAIL = Pattern.compile("[\\w.+-]+@[\\w.-]+\\.[A-Za-z]{2,}");
private static final int MAX_TEXT = 4000;
@Value("${mro.ai.duckdb-path:/opt/guardia-mro/data/mro_learning.duckdb}")
private String dbPath;
private volatile boolean enabled = false;
private Connection conn;
private final Object lock = new Object();
@PostConstruct
void init() {
try {
Class.forName("org.duckdb.DuckDBDriver");
File f = new File(dbPath);
File dir = f.getParentFile();
if (dir != null && !dir.exists() && !dir.mkdirs()) {
log.warn("AiLearningStore(DuckDB) 비활성 — 디렉터리 생성 불가(로컬 AI 분석 저장 생략, AI 기능은 정상)");
return;
}
conn = DriverManager.getConnection("jdbc:duckdb:" + dbPath);
try (Statement st = conn.createStatement()) {
st.execute("CREATE SEQUENCE IF NOT EXISTS ai_feedback_seq START 1");
st.execute("CREATE TABLE IF NOT EXISTS ai_feedback (" +
"id BIGINT, ts TIMESTAMP, solution VARCHAR, feature VARCHAR, question VARCHAR, " +
"answer VARCHAR, verdict VARCHAR, correction VARCHAR, user_masked VARCHAR)");
st.execute("CREATE SEQUENCE IF NOT EXISTS ai_infer_log_seq START 1");
st.execute("CREATE TABLE IF NOT EXISTS ai_infer_log (" +
"id BIGINT, ts TIMESTAMP, provider VARCHAR, model VARCHAR, latency_ms BIGINT, degraded BOOLEAN)");
}
enabled = true;
log.info("AiLearningStore(DuckDB) 준비 완료 (solution=mro)");
} catch (Throwable t) {
enabled = false; // duckdb 미로딩/파일 미가용 조용히 비활성(AI 기능 무영향)
log.warn("AiLearningStore(DuckDB) 비활성 ({}) — 로컬 AI 학습 저장 생략, AI 기능은 정상 동작",
t.getClass().getSimpleName());
}
}
public boolean isEnabled() {
return enabled;
}
/** 추론 1건 로그(provider/model/지연/폴백여부). 실패해도 예외 없음. */
public void logInfer(String provider, String model, long latencyMs, boolean degraded) {
if (!enabled) {
return;
}
synchronized (lock) {
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO ai_infer_log VALUES (nextval('ai_infer_log_seq'), ?, ?, ?, ?, ?)")) {
ps.setTimestamp(1, Timestamp.from(Instant.now()));
ps.setString(2, provider);
ps.setString(3, model);
ps.setLong(4, Math.max(0, latencyMs));
ps.setBoolean(5, degraded);
ps.executeUpdate();
} catch (Throwable t) {
log.debug("ai_infer_log insert skip: {}", t.getClass().getSimpleName());
}
}
}
/** 피드백 1건 저장(로컬). PII 마스킹 후 적재. 실패해도 예외 없음. */
public void saveFeedback(String feature, String question, String answer,
String verdict, String correction, String actor) {
if (!enabled) {
return;
}
synchronized (lock) {
try (PreparedStatement ps = conn.prepareStatement(
"INSERT INTO ai_feedback VALUES (nextval('ai_feedback_seq'), ?, ?, ?, ?, ?, ?, ?, ?)")) {
ps.setTimestamp(1, Timestamp.from(Instant.now()));
ps.setString(2, SOLUTION);
ps.setString(3, clip(feature));
ps.setString(4, mask(question));
ps.setString(5, mask(answer));
ps.setString(6, clip(verdict));
ps.setString(7, mask(correction));
ps.setString(8, maskActor(actor));
ps.executeUpdate();
} catch (Throwable t) {
log.debug("ai_feedback insert skip: {}", t.getClass().getSimpleName());
}
}
}
@PreDestroy
void close() {
if (conn != null) {
try {
conn.close();
} catch (Throwable ignored) {
// best-effort
}
}
}
// PII/자격증명 마스킹
static String mask(String s) {
if (s == null || s.isBlank()) {
return null;
}
String v = s.length() > MAX_TEXT ? s.substring(0, MAX_TEXT) : s;
v = RRN.matcher(v).replaceAll("######-#######");
v = CARD.matcher(v).replaceAll("****-****-****-****");
v = PHONE.matcher(v).replaceAll("***-****-****");
v = EMAIL.matcher(v).replaceAll("***@***");
return v;
}
/** 작성자 식별자는 앞 2자만 남기고 마스킹(감사 최소화). */
static String maskActor(String actor) {
if (actor == null || actor.isBlank()) {
return "anon";
}
String a = actor.trim();
return a.length() <= 2 ? a.charAt(0) + "*" : a.substring(0, 2) + "***";
}
private static String clip(String s) {
if (s == null) {
return null;
}
return s.length() > 200 ? s.substring(0, 200) : s;
}
}

View File

@ -0,0 +1,215 @@
package com.zioinfo.mro.ai.service;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.admin.dto.MroSetting;
import com.zioinfo.mro.admin.mapper.SettingMapper;
import com.zioinfo.mro.ai.OllamaClient;
import com.zioinfo.mro.ai.dto.AiConfigDto;
import com.zioinfo.mro.ai.dto.AiConfigUpdateRequest;
import com.zioinfo.mro.common.ai.ClaudeTextClient;
import com.zioinfo.mro.common.ai.TextAiClient.GenResult;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.Set;
/**
* AI provider 런타임 설정(mro_setting, key='ai.*') 단일 출처 서비스. [GUARDiA-MRO]
*
* <p><b>해상도</b>: DB(mro_setting) 우선 미설정 기본값/env 폴백. 시드(db/104) 미적용/ 비움
* 상태에서도 기존 Ollama 동작이 바이트 동일하게 유지된다(provider 기본 ollama).
*
* <p><b>보안</b>: Claude API 키는 서비스가 다루지 않는다. {@code claudeKeySet}
* {@link ClaudeTextClient#isConfigured()}(환경변수 존재 여부) 반환.
*
* <p><b>프로바이더</b>: claude / qwen3 / deepseek / glm / ollama.
*/
@Service
@RequiredArgsConstructor
public class AiConfigService {
public static final String K_PROVIDER = "ai.provider";
public static final String K_CLAUDE_MODEL = "ai.claude.model";
public static final String K_OLLAMA_TEXT_MODEL = "ai.ollama.textModel";
public static final String K_ENABLED = "ai.enabled";
public static final String PROVIDER_OLLAMA = "ollama";
public static final String PROVIDER_CLAUDE = "claude";
public static final String PROVIDER_QWEN3 = "qwen3";
public static final String PROVIDER_DEEPSEEK = "deepseek";
public static final String PROVIDER_GLM = "glm";
public static final String DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6";
public static final String MODEL_QWEN3 = "qwen3:1.7b";
public static final String MODEL_DEEPSEEK = "deepseek-r1:1.5b";
public static final String MODEL_GLM = "glm4:9b";
public static final Set<String> ALLOWED_PROVIDERS =
Set.of(PROVIDER_OLLAMA, PROVIDER_CLAUDE, PROVIDER_QWEN3, PROVIDER_DEEPSEEK, PROVIDER_GLM);
public static final Set<String> ALLOWED_CLAUDE_MODELS =
Set.of("claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-8");
public static final Set<String> ALLOWED_OLLAMA_MODELS =
Set.of("qwen3:1.7b", "deepseek-r1:1.5b", "glm4:9b", "llama3.2:1b");
private final SettingMapper repo;
private final ClaudeTextClient claudeClient;
private final OllamaClient ollamaClient;
private final AuditService auditService;
@Value("${guardia.ollama-text-model:llama3.2:1b}")
private String defaultOllamaTextModel;
// ---------------------------------------------------------------- 효과값(DB 우선 기본)
public String provider() {
String v = dbVal(K_PROVIDER);
if (v != null) {
String p = v.trim().toLowerCase();
if (ALLOWED_PROVIDERS.contains(p)) {
return p;
}
}
return PROVIDER_OLLAMA;
}
public String claudeModel() {
String v = dbVal(K_CLAUDE_MODEL);
if (v != null) {
String m = v.trim();
if (ALLOWED_CLAUDE_MODELS.contains(m)) {
return m;
}
}
return DEFAULT_CLAUDE_MODEL;
}
public String ollamaTextModel() {
String p = provider();
if (PROVIDER_QWEN3.equals(p)) {
return MODEL_QWEN3;
}
if (PROVIDER_DEEPSEEK.equals(p)) {
return MODEL_DEEPSEEK;
}
if (PROVIDER_GLM.equals(p)) {
return MODEL_GLM;
}
String v = dbVal(K_OLLAMA_TEXT_MODEL);
if (v != null && ALLOWED_OLLAMA_MODELS.contains(v.trim())) {
return v.trim();
}
return defaultOllamaTextModel;
}
public boolean aiEnabled() {
String v = dbVal(K_ENABLED);
return v == null || !"false".equalsIgnoreCase(v.trim());
}
public boolean claudeKeySet() {
return claudeClient.isConfigured();
}
public boolean isClaudeActive() {
return PROVIDER_CLAUDE.equals(provider()) && claudeKeySet() && aiEnabled();
}
// ---------------------------------------------------------------- ADMIN: 조회 / 갱신
public AiConfigDto getConfig() {
return new AiConfigDto(
provider(),
ollamaTextModel(),
claudeModel(),
claudeKeySet(),
aiEnabled());
}
public AiConfigDto update(AiConfigUpdateRequest req, String actor) {
String provider = req.provider() == null ? "" : req.provider().trim().toLowerCase();
if (!ALLOWED_PROVIDERS.contains(provider)) {
throw new IllegalArgumentException("ERR-AI-400: provider 는 ollama/claude/qwen3/deepseek/glm 중 하나여야 합니다.");
}
upsertAudited(K_PROVIDER, provider, actor);
if (req.claudeModel() != null && !req.claudeModel().isBlank()) {
String model = req.claudeModel().trim();
if (!ALLOWED_CLAUDE_MODELS.contains(model)) {
throw new IllegalArgumentException("ERR-AI-400: 허용되지 않은 Claude 모델 ID 입니다.");
}
upsertAudited(K_CLAUDE_MODEL, model, actor);
}
if (req.ollamaTextModel() != null && !req.ollamaTextModel().isBlank()) {
String om = req.ollamaTextModel().trim();
if (!ALLOWED_OLLAMA_MODELS.contains(om)) {
throw new IllegalArgumentException("ERR-AI-400: 허용되지 않은 Ollama 텍스트 모델입니다.");
}
upsertAudited(K_OLLAMA_TEXT_MODEL, om, actor);
}
return getConfig();
}
// ---------------------------------------------------------------- ADMIN: 연결 테스트
public TestResult test() {
if (isClaudeActive()) {
String model = claudeModel();
long start = System.currentTimeMillis();
GenResult r = claudeClient.generate("ping", model, 8);
long ms = System.currentTimeMillis() - start;
if (!r.degraded() && r.text() != null && !r.text().isBlank()) {
return new TestResult(true, false, "정상 · " + model + " · " + fmtMs(ms));
}
return new TestResult(false, true, "Claude 연결 실패 · 모델/네트워크/키 설정을 확인하세요.");
}
if (PROVIDER_CLAUDE.equals(provider()) && !claudeKeySet()) {
return new TestResult(false, true,
"Claude API 키가 설정되지 않아 Ollama(온프레미스)로 폴백 동작합니다. 서버 환경변수 설정 후 다시 시도하세요.");
}
if (!aiEnabled()) {
return new TestResult(false, true, "AI 기능이 비활성 상태입니다. 모든 AI 결과는 규칙 기반(degraded)으로 동작합니다.");
}
String model = ollamaTextModel();
long start = System.currentTimeMillis();
String txt = ollamaClient.generate("ping", model);
long ms = System.currentTimeMillis() - start;
if (txt != null && !txt.isBlank()) {
return new TestResult(true, false, "정상 · " + model + " · " + fmtMs(ms));
}
String hint = MODEL_GLM.equals(model)
? "Ollama 연결 실패 또는 모델 미가용 — glm4:9b 는 RAM 여유가 필요합니다(설정 확인)."
: "Ollama 연결 실패 또는 모델 미가용 — 설정을 확인하세요.";
return new TestResult(false, true, hint);
}
private static String fmtMs(long ms) {
return String.format("%.1fs", ms / 1000.0);
}
/** 연결 테스트 결과(요약만 — 키/스택/IP 미노출). */
public record TestResult(boolean ok, boolean degraded, String message) {
}
// ---------------------------------------------------------------- helpers
private String dbVal(String key) {
MroSetting c = repo.findByKey(key);
if (c == null) {
return null;
}
String v = c.getValue();
return (v != null && !v.isBlank()) ? v : null;
}
private void upsertAudited(String key, String val, String actor) {
MroSetting before = repo.findByKey(key);
String prev = before == null ? "(none)" : before.getValue();
if (val.equals(prev)) {
return;
}
repo.upsert(key, val);
auditService.log(actor == null ? "SYSTEM" : actor, "AI_CONFIG_CHANGE", key, prev + " -> " + val);
}
}

View File

@ -0,0 +1,63 @@
package com.zioinfo.mro.ai.service;
import com.zioinfo.mro.ai.OllamaClient;
import com.zioinfo.mro.ai.learning.AiLearningStore;
import com.zioinfo.mro.common.ai.ClaudeTextClient;
import com.zioinfo.mro.common.ai.TextAiClient;
import com.zioinfo.mro.common.ai.TextAiClient.GenResult;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
/**
* AI provider 선택 라우팅(런타임). 평문 텍스트 생성 진입점(고장원인분석·자연어조회 ). [GUARDiA-MRO]
* {@link AiConfigService#provider()} 읽어 Claude Ollama(qwen3/deepseek/glm/기존소형) 선택한다.
*
* <p><b>선택/폴백 정책</b>:
* <ul>
* <li>provider=claude · 설정됨 · AI 활성 {@link ClaudeTextClient}. 실패(degraded) <b>Ollama 자동 폴백</b>.</li>
* <li>provider=qwen3/deepseek/glm/ollama 해당 Ollama 텍스트 모델로 generate.</li>
* <li>provider=claude 인데 미설정 곧장 Ollama(폴백모델).</li>
* </ul>
* 경로 모두 실패 {@code GenResult.degraded=true·text=null} 호출자가 기존 규칙기반 폴백 유지(무회귀).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiTextRouter implements TextAiClient {
private final AiConfigService aiConfig;
private final ClaudeTextClient claudeClient;
private final OllamaClient ollamaClient;
private final AiLearningStore learningStore;
@Override
public GenResult generate(String prompt) {
if (aiConfig.isClaudeActive()) {
String model = aiConfig.claudeModel();
long start = System.currentTimeMillis();
GenResult r = claudeClient.generate(prompt, model);
long ms = System.currentTimeMillis() - start;
if (!r.degraded() && r.text() != null && !r.text().isBlank()) {
learningStore.logInfer(AiConfigService.PROVIDER_CLAUDE, model, ms, false);
return r;
}
learningStore.logInfer(AiConfigService.PROVIDER_CLAUDE, model, ms, true);
log.warn("Claude path degraded -> Ollama fallback");
return ollamaGenerate(prompt, aiConfig.ollamaTextModel());
}
return ollamaGenerate(prompt, aiConfig.ollamaTextModel());
}
private GenResult ollamaGenerate(String prompt, String model) {
long start = System.currentTimeMillis();
String txt = ollamaClient.generate(prompt, model);
long ms = System.currentTimeMillis() - start;
boolean degraded = (txt == null || txt.isBlank());
learningStore.logInfer(aiConfig.provider(), model, ms, degraded);
if (degraded) {
return new GenResult(null, true);
}
return new GenResult(txt, false);
}
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.mro.auth;
import com.zioinfo.mro.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/** MRO 인증 컨트롤러 — 로그인/내정보. */
@RestController
@RequestMapping("/api/mro/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
return ApiResponse.ok(authService.login(req.username(), req.password()));
}
@GetMapping("/me")
public ApiResponse<Map<String, Object>> me(@RequestHeader("Authorization") String header) {
String token = header.replace("Bearer ", "");
return ApiResponse.ok(authService.me(token));
}
record LoginRequest(String username, String password) {}
}

View File

@ -0,0 +1,48 @@
package com.zioinfo.mro.auth;
import com.zioinfo.mro.auth.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
/**
* MRO 인증 서비스 단일 JWT 로그인.
*
* <p>Phase3 인증 에이전트가 2FA(이메일/OTP)·ADMIN_PASSWORD_ENC 재시드 레이어를 후속 추가한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
public Map<String, String> login(String username, String password) {
MroUser user = userMapper.findByUsername(username);
if (user == null || !user.isActive()) {
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
}
String token = jwtUtil.generate(username, user.getRole());
return Map.of("token", token, "type", "Bearer", "role", user.getRole());
}
public Map<String, Object> me(String token) {
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
MroUser u = userMapper.findByUsername(username);
Map<String, Object> m = new HashMap<>();
m.put("username", username);
m.put("role", role);
m.put("displayName", u != null ? u.getDisplayName() : username);
return m;
}
}

View File

@ -0,0 +1,40 @@
package com.zioinfo.mro.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtUtil.isValid(token)) {
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
var auth = new UsernamePasswordAuthenticationToken(
username, null, List.of(new SimpleGrantedAuthority("ROLE_" + role))
);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(req, res);
}
}

View File

@ -0,0 +1,59 @@
package com.zioinfo.mro.auth;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Slf4j
@Component
public class JwtUtil {
@Value("${guardia.jwt.secret:guardia-mro-jwt-secret-2026-minimum-256bit-key-zioinfo}")
private String secret;
@Value("${guardia.jwt.expiration:86400000}")
private long expirationMs;
private SecretKey key() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generate(String username, String role) {
return Jwts.builder()
.subject(username)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(key())
.compact();
}
public Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token).getPayload();
}
public boolean isValid(String token) {
try {
parse(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
log.debug("JWT 검증 실패: {}", e.getMessage());
return false;
}
}
public String getUsername(String token) {
return parse(token).getSubject();
}
public String getRole(String token) {
return parse(token).get("role", String.class);
}
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.mro.auth;
import lombok.Data;
import java.time.LocalDateTime;
/**
* MRO 운영자 계정 (mro_user 테이블).
*
* <p>role: SUPERADMIN / MANAGER / WORKER / VIEWER.
* <p>Phase3 인증 에이전트가 2FA/OTP·ADMIN_PASSWORD_ENC 재시드 컬럼을 후속 ALTER 추가한다.
*/
@Data
public class MroUser {
private Long id;
private String username;
private String passwordHash;
private String displayName;
private String email;
private String role;
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,14 @@
package com.zioinfo.mro.auth.mapper;
import com.zioinfo.mro.auth.MroUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/** 인증용 사용자 매퍼 (mro_user). @Mapper 필수 (annotationClass=Mapper.class 스캔). */
@Mapper
public interface UserMapper {
MroUser findByUsername(@Param("username") String username);
int insert(MroUser user);
}

View File

@ -0,0 +1,51 @@
package com.zioinfo.mro.calibration;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 계측기 교정 API. 조회 Viewer+, 생성/수정/삭제 Manager+(기준정보 가드).
*/
@RestController
@RequestMapping("/api/mro/calibrations")
@RequiredArgsConstructor
public class CalibrationController {
private final CalibrationService service;
@GetMapping
public ApiResponse<List<MroCalibration>> list(@RequestParam(required = false) String equipmentCode,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(equipmentCode, status, keyword));
}
@GetMapping("/{id}")
public ApiResponse<MroCalibration> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroCalibration> create(@RequestBody MroCalibration c, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.create(c, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MroCalibration> update(@PathVariable Long id, @RequestBody MroCalibration c, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.update(id, c, AuthSupport.actor(auth)));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,72 @@
package com.zioinfo.mro.calibration;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.calibration.mapper.CalibrationMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
/**
* 계측기 교정 서비스 기준정보(생성/수정/삭제는 컨트롤러 MANAGER+ 가드 병행).
*/
@Service
@RequiredArgsConstructor
public class CalibrationService {
private static final Set<String> CAL_RESULT = Set.of("PASS", "FAIL", "ADJUSTED");
private final CalibrationMapper mapper;
private final AuditService audit;
public List<MroCalibration> list(String equipmentCode, String status, String keyword) {
return mapper.findAll(equipmentCode, status, keyword);
}
public MroCalibration get(Long id) {
MroCalibration c = mapper.findById(id);
if (c == null) throw new RuntimeException("ERR-CAL-404: 계측기 교정 없음");
return c;
}
public MroCalibration create(MroCalibration c, String actor) {
validate(c);
if (mapper.countByCode(c.getInstrumentCode(), null) > 0) {
throw new RuntimeException("ERR-CAL-409: 중복 계측기코드");
}
if (c.getStatus() == null) c.setStatus("ACTIVE");
if (c.getCalResult() == null) c.setCalResult("PASS");
c.setCreatedBy(actor);
mapper.insert(c);
audit.log("CALIBRATION_CREATE", c.getInstrumentCode(), "result=" + c.getCalResult());
return mapper.findById(c.getId());
}
public MroCalibration update(Long id, MroCalibration c, String actor) {
MroCalibration cur = get(id);
validate(c);
if (mapper.countByCode(c.getInstrumentCode(), id) > 0) {
throw new RuntimeException("ERR-CAL-409: 중복 계측기코드");
}
c.setId(id);
mapper.update(c);
audit.log("CALIBRATION_UPDATE", cur.getInstrumentCode(), "id=" + id);
return mapper.findById(id);
}
public void delete(Long id) {
MroCalibration cur = get(id);
mapper.delete(id);
audit.log("CALIBRATION_DELETE", cur.getInstrumentCode(), "id=" + id);
}
private void validate(MroCalibration c) {
if (c.getInstrumentCode() == null || c.getInstrumentCode().isBlank())
throw new IllegalArgumentException("ERR-CAL-400: instrumentCode 필수");
if (c.getInstrumentName() == null || c.getInstrumentName().isBlank())
throw new IllegalArgumentException("ERR-CAL-400: instrumentName 필수");
if (c.getCalResult() != null && !CAL_RESULT.contains(c.getCalResult().toUpperCase()))
throw new IllegalArgumentException("ERR-CAL-400: calResult 은 PASS/FAIL/ADJUSTED");
}
}

View File

@ -0,0 +1,26 @@
package com.zioinfo.mro.calibration;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 계측기 교정 (mro_calibration) 계측기 교정 주기·차기 교정일·교정 결과.
*/
@Data
public class MroCalibration {
private Long id;
private String instrumentCode;
private String instrumentName;
private String equipmentCode;
private Integer calCycleDays;
private LocalDate lastCalDate;
private LocalDate nextCalDate;
private String calResult; // PASS / FAIL / ADJUSTED
private String calAgency;
private String certificateNo;
private String status; // ACTIVE / INACTIVE
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.mro.calibration.mapper;
import com.zioinfo.mro.calibration.MroCalibration;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface CalibrationMapper {
List<MroCalibration> findAll(@Param("equipmentCode") String equipmentCode,
@Param("status") String status,
@Param("keyword") String keyword);
MroCalibration findById(@Param("id") Long id);
MroCalibration findByCode(@Param("instrumentCode") String instrumentCode);
int countByCode(@Param("instrumentCode") String instrumentCode, @Param("excludeId") Long excludeId);
int insert(MroCalibration c);
int update(MroCalibration c);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.mro.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
/** 표준 API 응답 봉투 — { success, message, data }. */
@Getter
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, "OK", data);
}
public static <T> ApiResponse<T> fail(String message) {
return new ApiResponse<>(false, message, null);
}
}

View File

@ -0,0 +1,36 @@
package com.zioinfo.mro.common;
import org.springframework.security.core.Authentication;
import java.util.Set;
/**
* 컨트롤러 공통 Authentication 에서 actor(username)/role 추출 + MANAGER+ 가드.
*
* <p>기준정보 마감·승인 MANAGER 이상만 허용하는 작업은 {@link #requireManager} 추가 가드한다
* (SecurityConfig URL 매칭은 WORKER+까지 허용하므로 메서드 레벨 보강).
*/
public final class AuthSupport {
private static final Set<String> MANAGER_ROLES = Set.of("MANAGER", "SUPERADMIN");
private AuthSupport() {
}
public static String actor(Authentication a) {
return a != null ? a.getName() : "system";
}
public static String role(Authentication a) {
if (a == null || a.getAuthorities().isEmpty()) return "VIEWER";
String r = a.getAuthorities().iterator().next().getAuthority();
return r.startsWith("ROLE_") ? r.substring(5) : r;
}
/** MANAGER 이상이 아니면 예외(기준정보/마감/승인 가드). */
public static void requireManager(Authentication a) {
if (!MANAGER_ROLES.contains(role(a).toUpperCase())) {
throw new RuntimeException("ERR-MRO-403: 기준정보/마감/승인은 MANAGER 이상만 가능합니다");
}
}
}

View File

@ -0,0 +1,93 @@
package com.zioinfo.mro.common;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
/**
* 거래처 PII / 자격증명 암호화 유틸 AES-256-GCM.
*
* <p>GUARDiA 보안 불변 규칙: 공급사 담당자 연락처 PII는 평문 저장 금지.
* {@code *_enc} 컬럼에 유틸로 암호화 저장하고, API 응답에는 마스킹/제외한다.
*
* <p>저장 포맷: Base64( IV(12B) || ciphertext || GCM tag(16B) ).
*/
@Component
public class CryptoUtil {
private static final int IV_LEN = 12;
private static final int TAG_BITS = 128;
private final SecretKeySpec key;
private final SecureRandom random = new SecureRandom();
public CryptoUtil(@Value("${guardia.crypto.secret:guardia-mro-aes-256-gcm-master-key-2026-zioinfo}") String secret) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8));
this.key = new SecretKeySpec(digest, "AES");
} catch (Exception e) {
throw new IllegalStateException("암호화 키 초기화 실패", e);
}
}
/** 평문을 AES-256-GCM으로 암호화하여 Base64 문자열로 반환. null/빈 입력은 그대로 반환. */
public String encrypt(String plain) {
if (plain == null || plain.isEmpty()) {
return plain;
}
try {
byte[] iv = new byte[IV_LEN];
random.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
byte[] ct = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
byte[] out = new byte[iv.length + ct.length];
System.arraycopy(iv, 0, out, 0, iv.length);
System.arraycopy(ct, 0, out, iv.length, ct.length);
return Base64.getEncoder().encodeToString(out);
} catch (Exception e) {
throw new RuntimeException("ERR-MRO-CRYPTO-01: 암호화 실패");
}
}
/** Base64 암호문을 복호화. 복호화 실패 시 빈 문자열. */
public String decrypt(String enc) {
if (enc == null || enc.isEmpty()) {
return enc;
}
try {
byte[] all = Base64.getDecoder().decode(enc);
byte[] iv = new byte[IV_LEN];
System.arraycopy(all, 0, iv, 0, IV_LEN);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
byte[] pt = cipher.doFinal(all, IV_LEN, all.length - IV_LEN);
return new String(pt, StandardCharsets.UTF_8);
} catch (Exception e) {
return "";
}
}
/** 이메일/전화 등 PII 마스킹 (응답용). 예: ab***@x.com, 010****5678. */
public static String mask(String value) {
if (value == null || value.isEmpty()) {
return value;
}
int at = value.indexOf('@');
if (at > 0) {
String local = value.substring(0, at);
String shown = local.length() <= 2 ? local.substring(0, 1) : local.substring(0, 2);
return shown + "***" + value.substring(at);
}
if (value.length() <= 4) {
return "****";
}
return value.substring(0, value.length() - 4).replaceAll(".", "*") + value.substring(value.length() - 4);
}
}

View File

@ -0,0 +1,65 @@
package com.zioinfo.mro.common;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
/**
* 전역 예외 처리.
*
* <p>보안 불변 규칙: 스택트레이스를 응답에 절대 노출하지 않는다.
* 에러 코드 + 요약 메시지만 반환한다.
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MaxUploadSizeExceededException.class)
@ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE)
public ApiResponse<Void> handleMaxSize(MaxUploadSizeExceededException e) {
log.warn("업로드 크기 초과: {}", e.getMessage());
return ApiResponse.fail("ERR-MRO-413: 파일 크기 초과 (최대 20MB)");
}
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ApiResponse<Void> handleAccessDenied(AccessDeniedException e) {
log.warn("권한 거부: {}", e.getMessage());
return ApiResponse.fail("ERR-MRO-403: 권한이 없습니다");
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleIllegalArg(IllegalArgumentException e) {
log.warn("잘못된 요청: {}", e.getMessage());
return ApiResponse.fail(e.getMessage());
}
@ExceptionHandler(DataAccessException.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleDataAccess(DataAccessException e) {
// 보안 불변 규칙: SQL/테이블/쿼리/스택 상세 절대 미노출 내부 로그만 남기고 일반 메시지 반환
log.error("DB 오류", e);
return ApiResponse.fail("ERR-MRO-DB: 데이터 처리 중 오류가 발생했습니다");
}
@ExceptionHandler(RuntimeException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleRuntime(RuntimeException e) {
// 스택트레이스 미노출 에러 코드/요약만 반환
log.warn("업무 오류: {}", e.getMessage());
return ApiResponse.fail(e.getMessage());
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleGeneral(Exception e) {
log.error("시스템 오류", e);
return ApiResponse.fail("ERR-SYS-001");
}
}

View File

@ -0,0 +1,159 @@
package com.zioinfo.mro.common.ai;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zioinfo.mro.common.ai.TextAiClient.GenResult;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* 외부 Claude(Anthropic) Messages API 텍스트 생성 클라이언트. [ISSUER=GUARDiA-MRO]
*
* <p><b>외부 호출 예외 허용</b>: 클라이언트는 소유자 승인에 따라 {@code api.anthropic.com}
* 호출이 허용된 유일한 외부 경로다. 외부 API 호출은 여전히 금지(Ollama localhost 전용).
*
* <p><b>API 보안(최우선)</b>: 키는 환경변수 {@code ANTHROPIC_API_KEY} 에서만 로드하며,
* DB·코드·커밋·로그·응답·예외 메시지 어디에도 기록하지 않는다. 키는 오직 HTTP 헤더 {@code x-api-key}
* 로만 전달된다. 로그는 상태코드/예외 클래스명만 남긴다(본문· 미기록).
*
* <p><b>실패 처리</b>: 미설정/타임아웃/비200/예외 {@code GenResult.degraded=true·text=null} 반환
* (예외를 던지지 않음 호출자는 Ollama 폴백). 신규 라이브러리 0 JDK {@link HttpClient} 사용.
*/
@Slf4j
@Service
public class ClaudeTextClient implements TextAiClient {
private static final String API_URL = "https://api.anthropic.com/v1/messages";
private static final String ANTHROPIC_VERSION = "2023-06-01";
static final String DEFAULT_MODEL = "claude-sonnet-4-6";
private static final int DEFAULT_MAX_TOKENS = 1024;
private static final int TIMEOUT_SEC = 120;
private static final int CONNECT_TIMEOUT_SEC = 15;
private static final ObjectMapper MAPPER = new ObjectMapper();
/** 환경변수 전용 주입. 미설정 시 빈 문자열(=미구성). 값은 절대 외부 노출/로그 금지. */
@Value("${ANTHROPIC_API_KEY:}")
private String apiKey;
/** API 키가 환경변수로 설정되어 있는지(여부만 — 값/길이/마스킹 일절 미노출). */
public boolean isConfigured() {
return apiKey != null && !apiKey.isBlank();
}
@Override
public GenResult generate(String prompt) {
return generate(prompt, DEFAULT_MODEL);
}
public GenResult generate(String prompt, String model) {
return generate(prompt, model, DEFAULT_MAX_TOKENS);
}
/**
* Anthropic Messages API 호출. 실패 {@code degraded=true·text=null}.
* ·요청본문·응답본문은 로그에 남기지 않는다(상태코드/예외 클래스명만).
*/
public GenResult generate(String prompt, String model, int maxTokens) {
if (!isConfigured()) {
return new GenResult(null, true);
}
if (prompt == null || prompt.isBlank()) {
return new GenResult(null, true);
}
String useModel = (model == null || model.isBlank()) ? DEFAULT_MODEL : model.trim();
int tokens = maxTokens > 0 ? maxTokens : DEFAULT_MAX_TOKENS;
try {
String payload = "{"
+ "\"model\":" + str(useModel) + ","
+ "\"max_tokens\":" + tokens + ","
+ "\"messages\":[{\"role\":\"user\",\"content\":" + str(prompt) + "}]"
+ "}";
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SEC))
.build();
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(API_URL))
.timeout(Duration.ofSeconds(TIMEOUT_SEC))
.header("content-type", "application/json")
.header("x-api-key", apiKey) // 키는 헤더로만 로그/응답 미노출
.header("anthropic-version", ANTHROPIC_VERSION)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() != 200) {
log.warn("Claude API status {} -> degraded fallback", resp.statusCode()); // 본문 미기록
return new GenResult(null, true);
}
String text = extractText(resp.body());
if (text == null || text.isBlank()) {
return new GenResult(null, true);
}
return new GenResult(text.trim(), false);
} catch (Exception e) {
log.warn("Claude API call failed -> degraded fallback: {}", e.getClass().getSimpleName()); // 메시지· 미기록
return new GenResult(null, true);
}
}
/** Messages API 응답에서 content[].text(type=text) 추출·연결. */
private static String extractText(String body) {
if (body == null || body.isBlank()) {
return null;
}
try {
JsonNode root = MAPPER.readTree(body);
JsonNode content = root.get("content");
if (content == null || !content.isArray()) {
return null;
}
StringBuilder sb = new StringBuilder();
for (JsonNode block : content) {
JsonNode type = block.get("type");
if (type != null && "text".equals(type.asText())) {
JsonNode t = block.get("text");
if (t != null && !t.isNull()) {
sb.append(t.asText());
}
}
}
String out = sb.toString();
return out.isBlank() ? null : out;
} catch (Exception e) {
return null;
}
}
/** JSON 문자열 이스케이프(프롬프트 직렬화 규칙). */
private static String str(String s) {
if (s == null) {
return "\"\"";
}
StringBuilder sb = new StringBuilder("\"");
for (char c : s.toCharArray()) {
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
}
return sb.append("\"").toString();
}
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.mro.common.ai;
/**
* 텍스트 생성 공용 인터페이스 (AI provider 추상화). [GUARDiA-MRO]
*
* <p>온프레미스 Ollama({@code ai.OllamaClient#generate}) 외부 Claude({@link ClaudeTextClient})
* 동일 계약으로 다루기 위한 얇은 추상화. 구현체는 어떤 사유로든(비활성/실패/타임아웃/키미설정) 실패
* {@link GenResult#degraded()}=true · {@link GenResult#text()}=null 반환하고, 호출자가 폴백을 책임진다.
*
* <p>provider 선택/폴백 라우팅은 {@code ai.service.AiTextRouter} 담당한다( 인터페이스를 구현).
*/
public interface TextAiClient {
/**
* 프롬프트로 텍스트를 생성한다. 실패 {@code degraded=true·text=null}(예외를 던지지 않음).
*/
GenResult generate(String prompt);
/** 생성 결과: 텍스트(폴백 시 null) + degraded(폴백 여부). */
record GenResult(String text, boolean degraded) {
}
}

View File

@ -0,0 +1,25 @@
package com.zioinfo.mro.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@Configuration
public class CorsConfig {
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOriginPatterns(List.of("*"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}

View File

@ -0,0 +1,36 @@
package com.zioinfo.mro.config;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import javax.sql.DataSource;
/**
* MyBatis 설정.
*
* <p>불변 규칙(형제 솔루션 TemplateMapper 빈누락 크래시 함정 준수):
* {@code @MapperScan(annotationClass=Mapper.class)} basePackages 금지 대신 전체 베이스 패키지에서
* {@code @Mapper} 인터페이스만 등록. 모든 매퍼 인터페이스에 {@code @Mapper} 필수, XML namespace=FQN.
*/
@Configuration
@MapperScan(basePackages = "com.zioinfo.mro", annotationClass = Mapper.class)
public class MyBatisConfig {
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
factory.setDataSource(dataSource);
factory.setMapperLocations(
new PathMatchingResourcePatternResolver().getResources("classpath:mapper/**/*.xml")
);
org.apache.ibatis.session.Configuration config = new org.apache.ibatis.session.Configuration();
config.setMapUnderscoreToCamelCase(true);
factory.setConfiguration(config);
return factory.getObject();
}
}

View File

@ -0,0 +1,85 @@
package com.zioinfo.mro.config;
import com.zioinfo.mro.auth.JwtFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* GUARDiA MRO 보안 설정 JWT 무상태 인증 + RBAC.
*
* <p>RBAC 역할(상위 하위): SUPERADMIN MANAGER WORKER VIEWER.
* <ul>
* <li>auth/health/swagger/정적 permitAll</li>
* <li>조회(GET /api/mro/**) 인증 사용자 전체(Viewer+)</li>
* <li>작업지시/실적/입출고/재고이동 입력(POST/PUT/PATCH/DELETE) Worker 이상</li>
* <li>기준정보 마감·승인 Manager 이상(서비스/메서드 가드 병행)</li>
* <li>관리자 API(/api/admin/**) SuperAdmin 전용(감사로그 조회는 Manager+)</li>
* </ul>
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> {})
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/mro/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/mro/docs/**", "/api/mro/swagger/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/public/**").permitAll()
// 정적 프론트 번들
.requestMatchers("/", "/index.html", "/assets/**", "/favicon.ico").permitAll()
// 관리자 사용자/설정 관리는 SUPERADMIN 전용
.requestMatchers("/api/admin/users/**").hasRole("SUPERADMIN")
.requestMatchers(HttpMethod.GET, "/api/admin/settings").hasAnyRole("SUPERADMIN", "MANAGER")
.requestMatchers("/api/admin/settings/**").hasRole("SUPERADMIN")
.requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "MANAGER")
.requestMatchers("/api/admin/**").hasRole("SUPERADMIN")
// 변경(작업지시·실적·입출고·재고이동) WORKER 이상 (기준정보/마감/승인은 서비스에서 MANAGER+ 가드)
.requestMatchers(HttpMethod.POST, "/api/mro/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")
.requestMatchers(HttpMethod.PUT, "/api/mro/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")
.requestMatchers(HttpMethod.PATCH, "/api/mro/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")
.requestMatchers(HttpMethod.DELETE, "/api/mro/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")
// 조회 인증 사용자 전체(Viewer+)
.requestMatchers(HttpMethod.GET, "/api/mro/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER", "VIEWER")
// AI 피드백 인증 사용자
.requestMatchers("/api/ai/**").authenticated()
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}

View File

@ -0,0 +1,26 @@
package com.zioinfo.mro.config;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.stereotype.Controller;
/**
* SPA 포워딩 vite 정적 번들(index.html) 클라이언트 라우팅 경로로 포워드.
*
* <p>/api, /actuator, /swagger 등은 제외하고 -정적 경로를 index.html 포워딩한다.
* (형제 솔루션 단일 jar 패턴: frontend backend static)
*/
@Controller
public class SpaForwardController {
@RequestMapping(value = {"/admin", "/admin/**"})
public String adminSpa() {
return "forward:/index.html";
}
@GetMapping("/health-lite")
@org.springframework.web.bind.annotation.ResponseBody
public String healthLite() {
return "GUARDiA MRO UP";
}
}

View File

@ -0,0 +1,55 @@
package com.zioinfo.mro.cost;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* MRO 비용 API 조회/집계 Viewer+, 생성/수정 Worker+, 삭제 Manager+.
*/
@RestController
@RequestMapping("/api/mro/costs")
@RequiredArgsConstructor
public class CostController {
private final CostService service;
@GetMapping
public ApiResponse<List<MroCost>> list(@RequestParam(required = false) String costType,
@RequestParam(required = false) String equipmentCode,
@RequestParam(required = false) String deptCode) {
return ApiResponse.ok(service.list(costType, equipmentCode, deptCode));
}
@GetMapping("/summary")
public ApiResponse<List<Map<String, Object>>> summary() {
return ApiResponse.ok(service.summary());
}
@GetMapping("/{id}")
public ApiResponse<MroCost> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroCost> create(@RequestBody MroCost c, Authentication auth) {
return ApiResponse.ok(service.create(c, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MroCost> update(@PathVariable Long id, @RequestBody MroCost c, Authentication auth) {
return ApiResponse.ok(service.update(id, c, AuthSupport.actor(auth)));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
service.delete(id, AuthSupport.actor(auth));
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,74 @@
package com.zioinfo.mro.cost;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.cost.mapper.CostMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* MRO 비용 서비스 CRUD + costType 합계 집계(summary).
*/
@Service
@RequiredArgsConstructor
public class CostService {
private static final Set<String> COST_TYPES = Set.of("LABOR", "PART", "OUTSOURCE", "OTHER");
private final CostMapper mapper;
private final AuditService audit;
public List<MroCost> list(String costType, String equipmentCode, String deptCode) {
return mapper.findAll(costType, equipmentCode, deptCode);
}
public MroCost get(Long id) {
MroCost c = mapper.findById(id);
if (c == null) throw new RuntimeException("ERR-CST-404: 비용 없음");
return c;
}
public MroCost create(MroCost c, String actor) {
validate(c);
if (c.getCostType() == null) c.setCostType("PART");
if (c.getCurrency() == null) c.setCurrency("KRW");
if (c.getCostNo() == null || c.getCostNo().isBlank()) {
c.setCostNo("CST-" + System.currentTimeMillis());
}
c.setCreatedBy(actor);
mapper.insert(c);
audit.log(actor, "COST_CREATE", c.getCostNo(), "type=" + c.getCostType() + ",amount=" + c.getAmount());
return mapper.findById(c.getId());
}
public MroCost update(Long id, MroCost c, String actor) {
MroCost cur = get(id);
validate(c);
if (c.getCostType() == null) c.setCostType(cur.getCostType());
if (c.getCurrency() == null) c.setCurrency(cur.getCurrency());
c.setId(id);
c.setCostNo(cur.getCostNo());
mapper.update(c);
audit.log(actor, "COST_UPDATE", cur.getCostNo(), "id=" + id);
return mapper.findById(id);
}
public void delete(Long id, String actor) {
MroCost cur = get(id);
mapper.delete(id);
audit.log(actor, "COST_DELETE", cur.getCostNo(), "id=" + id);
}
/** costType 별 합계 amount 집계. */
public List<Map<String, Object>> summary() {
return mapper.summaryByType();
}
private void validate(MroCost c) {
if (c.getCostType() != null && !COST_TYPES.contains(c.getCostType().toUpperCase()))
throw new IllegalArgumentException("ERR-CST-400: costType 은 LABOR/PART/OUTSOURCE/OTHER");
}
}

View File

@ -0,0 +1,26 @@
package com.zioinfo.mro.cost;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* MRO 비용 (mro_cost) 인건비/부품/외주/기타 비용 기록 + 예산.
*/
@Data
public class MroCost {
private Long id;
private String costNo;
private String costType; // LABOR/PART/OUTSOURCE/OTHER
private String equipmentCode;
private String woNo;
private String deptCode;
private String budgetCode;
private Double amount;
private String currency;
private LocalDate costDate;
private String description;
private Double budgetAmount;
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.mro.cost.mapper;
import com.zioinfo.mro.cost.MroCost;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface CostMapper {
List<MroCost> findAll(@Param("costType") String costType,
@Param("equipmentCode") String equipmentCode,
@Param("deptCode") String deptCode);
MroCost findById(@Param("id") Long id);
int insert(MroCost c);
int update(MroCost c);
int delete(@Param("id") Long id);
List<Map<String, Object>> summaryByType();
}

View File

@ -0,0 +1,40 @@
package com.zioinfo.mro.dashboard;
import com.zioinfo.mro.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 대시보드 KPI 집계 API 전부 조회 전용(GET, Viewer+). 순수 매퍼 집계로 도메인 클래스에 의존하지 않는다.
*/
@RestController
@RequestMapping("/api/mro/dashboard")
@RequiredArgsConstructor
public class DashboardController {
private final DashboardService service;
/** 설비·WO·재고·구매·다운타임 핵심 지표 단일 요약. */
@GetMapping("/summary")
public ApiResponse<Map<String, Object>> summary() {
return ApiResponse.ok(service.summary());
}
/** 작업지시 status 별 카운트 목록(GROUP BY status). */
@GetMapping("/wo-status")
public ApiResponse<List<Map<String, Object>>> woStatus() {
return ApiResponse.ok(service.woStatus());
}
/** 향후 N일 내 next_due_date 도래 PM 계획 목록(기본 7일). */
@GetMapping("/pm-due")
public ApiResponse<List<Map<String, Object>>> pmDue(@RequestParam(defaultValue = "7") int days) {
return ApiResponse.ok(service.pmDue(days));
}
}

View File

@ -0,0 +1,68 @@
package com.zioinfo.mro.dashboard;
import com.zioinfo.mro.dashboard.mapper.DashboardMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 대시보드 KPI 조회 서비스 지표를 개별 매퍼 select 조회 단일 요약 맵으로 조립.
* 순수 집계라 도메인 서비스/엔티티에 의존하지 않는다.
*/
@Service
@RequiredArgsConstructor
public class DashboardService {
private final DashboardMapper mapper;
/** 설비·WO·재고·구매·다운타임 핵심 지표 단일 요약. */
public Map<String, Object> summary() {
Map<String, Object> equip = mapper.equipmentCounts();
Map<String, Object> wo = mapper.woStatusSummary();
Map<String, Object> out = new LinkedHashMap<>();
out.put("totalEquipment", num(equip, "totalEquipment"));
out.put("runningEquipment", num(equip, "runCount"));
out.put("downEquipment", num(equip, "downCount"));
Map<String, Object> woStatus = new LinkedHashMap<>();
woStatus.put("CREATED", num(wo, "created"));
woStatus.put("ASSIGNED", num(wo, "assigned"));
woStatus.put("INPROGRESS", num(wo, "inprogress"));
woStatus.put("DONE", num(wo, "done"));
out.put("woStatus", woStatus);
out.put("shortageMaterials", mapper.shortageMaterialCount());
out.put("prPending", mapper.prPendingCount());
out.put("poInProgress", mapper.poInProgressCount());
out.put("monthDowntimeMin", mapper.monthDowntimeSum());
return out;
}
/** WO status 별 카운트 목록. */
public List<Map<String, Object>> woStatus() {
return mapper.woStatusCounts();
}
/** 향후 days 일 내 도래 PM 계획 목록(days<=0 이면 7일로 보정). */
public List<Map<String, Object>> pmDue(int days) {
int d = (days <= 0 || days > 3650) ? 7 : days;
return mapper.pmDue(d);
}
/** 맵 값(Number/문자)을 long 으로 NULL 안전 변환. */
private static long num(Map<String, Object> m, String key) {
if (m == null) return 0L;
Object v = m.get(key);
if (v instanceof Number n) return n.longValue();
if (v == null) return 0L;
try {
return Long.parseLong(v.toString());
} catch (NumberFormatException e) {
return 0L;
}
}
}

View File

@ -0,0 +1,38 @@
package com.zioinfo.mro.dashboard.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* 대시보드 집계 매퍼 지표를 별도 select 조회(조회 전용). NULL 안전(COALESCE).
*/
@Mapper
public interface DashboardMapper {
/** 설비 총수·가동중(RUN)·다운(DOWN) 카운트. */
Map<String, Object> equipmentCounts();
/** WO 상태별 단일 요약(created/assigned/inprogress/done). */
Map<String, Object> woStatusSummary();
/** WO status 별 카운트 목록(GROUP BY status). */
List<Map<String, Object>> woStatusCounts();
/** 안전재고 미달(available_qty < safety_stock) 자재 수. */
long shortageMaterialCount();
/** 구매요청 대기 수(status='SUBMITTED'). */
long prPendingCount();
/** 발주 진행 수(status IN ('ORDERED','PARTIAL')). */
long poInProgressCount();
/** 이번 달 다운타임 합계(분). */
double monthDowntimeSum();
/** 향후 days 일 내 next_due_date 도래 PM 계획 목록. */
List<Map<String, Object>> pmDue(@Param("days") int days);
}

View File

@ -0,0 +1,48 @@
package com.zioinfo.mro.downtime;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* MRO 다운타임 API 조회 Viewer+, 등록/수정 Worker+, 삭제 Manager+.
*/
@RestController
@RequestMapping("/api/mro/downtimes")
@RequiredArgsConstructor
public class DowntimeController {
private final DowntimeService service;
@GetMapping
public ApiResponse<List<MroDowntime>> list(@RequestParam(required = false) String equipmentCode,
@RequestParam(required = false) String downtimeType) {
return ApiResponse.ok(service.list(equipmentCode, downtimeType));
}
@GetMapping("/{id}")
public ApiResponse<MroDowntime> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroDowntime> create(@RequestBody MroDowntime dt, Authentication auth) {
return ApiResponse.ok(service.create(dt, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MroDowntime> update(@PathVariable Long id, @RequestBody MroDowntime dt, Authentication auth) {
return ApiResponse.ok(service.update(id, dt, AuthSupport.actor(auth)));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
service.delete(id, AuthSupport.actor(auth));
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,73 @@
package com.zioinfo.mro.downtime;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.downtime.mapper.DowntimeMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.List;
import java.util.Set;
/**
* MRO 다운타임 서비스 등록/수정(Worker+)·삭제(Manager+). start/end 있으면 duration_min 자동계산.
*/
@Service
@RequiredArgsConstructor
public class DowntimeService {
private static final Set<String> DOWNTIME_TYPES = Set.of("FAULT", "PM", "SETUP", "MATERIAL", "OTHER");
private final DowntimeMapper mapper;
private final AuditService audit;
public List<MroDowntime> list(String equipmentCode, String downtimeType) {
return mapper.findAll(equipmentCode, downtimeType);
}
public MroDowntime get(Long id) {
MroDowntime dt = mapper.findById(id);
if (dt == null) throw new RuntimeException("ERR-DT-404: 다운타임 없음");
return dt;
}
public MroDowntime create(MroDowntime dt, String actor) {
validate(dt);
if (dt.getDowntimeType() == null) dt.setDowntimeType("FAULT");
dt.setDurationMin(computeDuration(dt));
dt.setCreatedBy(actor);
mapper.insert(dt);
audit.log(actor, "DOWNTIME_CREATE", dt.getEquipmentCode(),
"type=" + dt.getDowntimeType() + " min=" + dt.getDurationMin());
return mapper.findById(dt.getId());
}
public MroDowntime update(Long id, MroDowntime dt, String actor) {
MroDowntime cur = get(id);
validate(dt);
dt.setId(id);
dt.setDurationMin(computeDuration(dt));
mapper.update(dt);
audit.log(actor, "DOWNTIME_UPDATE", cur.getEquipmentCode(), "id=" + id);
return mapper.findById(id);
}
public void delete(Long id, String actor) {
MroDowntime cur = get(id);
mapper.delete(id);
audit.log(actor, "DOWNTIME_DELETE", cur.getEquipmentCode(), "id=" + id);
}
/** start_at/end_at 둘 다 있으면 분(minutes)으로 계산, 아니면 요청값 유지. */
private Double computeDuration(MroDowntime dt) {
if (dt.getStartAt() != null && dt.getEndAt() != null) {
return (double) Duration.between(dt.getStartAt(), dt.getEndAt()).toMinutes();
}
return dt.getDurationMin();
}
private void validate(MroDowntime dt) {
if (dt.getDowntimeType() != null && !DOWNTIME_TYPES.contains(dt.getDowntimeType().toUpperCase()))
throw new IllegalArgumentException("ERR-DT-400: downtimeType 은 FAULT/PM/SETUP/MATERIAL/OTHER");
}
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.mro.downtime;
import lombok.Data;
import java.time.LocalDateTime;
/**
* MRO 다운타임 (mro_downtime) 설비 비가동 이력(신뢰성지표 MTBF/MTTR 근거).
*
* <p>downtimeType: FAULT/PM/SETUP/MATERIAL/OTHER. start_at/end_at 있으면 duration_min 자동계산().
*/
@Data
public class MroDowntime {
private Long id;
private String equipmentCode;
private String woNo;
private String downtimeType;
private LocalDateTime startAt;
private LocalDateTime endAt;
private Double durationMin;
private String reason;
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.mro.downtime.mapper;
import com.zioinfo.mro.downtime.MroDowntime;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface DowntimeMapper {
List<MroDowntime> findAll(@Param("equipmentCode") String equipmentCode,
@Param("downtimeType") String downtimeType);
MroDowntime findById(@Param("id") Long id);
int insert(MroDowntime dt);
int update(MroDowntime dt);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,59 @@
package com.zioinfo.mro.equipment;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 설비 마스터 API. 조회 Viewer+, 생성/수정/삭제 Manager+(기준정보 가드), 가동상태 전이 Worker+.
*/
@RestController
@RequestMapping("/api/mro/equipment")
@RequiredArgsConstructor
public class EquipmentController {
private final EquipmentService service;
@GetMapping
public ApiResponse<List<MroEquipment>> list(@RequestParam(required = false) String category,
@RequestParam(required = false) String runStatus,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(category, runStatus, keyword));
}
@GetMapping("/{id}")
public ApiResponse<MroEquipment> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroEquipment> create(@RequestBody MroEquipment e, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.create(e, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MroEquipment> update(@PathVariable Long id, @RequestBody MroEquipment e, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.update(id, e, AuthSupport.actor(auth)));
}
/** 가동상태 전이 — Worker+ (기준정보 가드 없음). */
@PatchMapping("/{id}/run-status")
public ApiResponse<MroEquipment> changeRunStatus(@PathVariable Long id, @RequestBody Map<String, String> body,
Authentication auth) {
return ApiResponse.ok(service.changeRunStatus(id, body.get("runStatus"), AuthSupport.actor(auth)));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,88 @@
package com.zioinfo.mro.equipment;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.equipment.mapper.EquipmentMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
/**
* 설비 마스터 서비스 기준정보(MANAGER+ 가드는 컨트롤러 병행). 가동상태 변경은 WORKER+.
*/
@Service
@RequiredArgsConstructor
public class EquipmentService {
private static final Set<String> RUN_STATUS = Set.of("RUN", "IDLE", "DOWN", "MAINT");
private static final Set<String> CRITICALITY = Set.of("A", "B", "C");
private final EquipmentMapper mapper;
private final AuditService audit;
public List<MroEquipment> list(String category, String runStatus, String keyword) {
return mapper.findAll(category, runStatus, keyword);
}
public MroEquipment get(Long id) {
MroEquipment e = mapper.findById(id);
if (e == null) throw new RuntimeException("ERR-EQ-404: 설비 없음");
return e;
}
public MroEquipment create(MroEquipment e, String actor) {
validate(e);
if (mapper.countByCode(e.getEquipmentCode(), null) > 0) {
throw new RuntimeException("ERR-EQ-409: 중복 설비코드");
}
if (e.getStatus() == null) e.setStatus("ACTIVE");
if (e.getRunStatus() == null) e.setRunStatus("IDLE");
if (e.getCriticality() == null) e.setCriticality("B");
e.setCreatedBy(actor);
mapper.insert(e);
audit.log("EQUIPMENT_CREATE", e.getEquipmentCode(), "criticality=" + e.getCriticality());
return mapper.findById(e.getId());
}
public MroEquipment update(Long id, MroEquipment e, String actor) {
MroEquipment cur = get(id);
validate(e);
if (mapper.countByCode(e.getEquipmentCode(), id) > 0) {
throw new RuntimeException("ERR-EQ-409: 중복 설비코드");
}
e.setId(id);
mapper.update(e);
audit.log("EQUIPMENT_UPDATE", cur.getEquipmentCode(), "id=" + id);
return mapper.findById(id);
}
/** 가동상태 전이 (RUN/IDLE/DOWN/MAINT) — WORKER+. */
public MroEquipment changeRunStatus(Long id, String runStatus, String actor) {
MroEquipment cur = get(id);
String s = runStatus == null ? "" : runStatus.trim().toUpperCase();
if (!RUN_STATUS.contains(s)) {
throw new IllegalArgumentException("ERR-EQ-400: runStatus 은 RUN/IDLE/DOWN/MAINT");
}
mapper.updateRunStatus(id, s);
audit.log(actor, "EQUIPMENT_RUNSTATUS", cur.getEquipmentCode(), cur.getRunStatus() + " -> " + s);
return mapper.findById(id);
}
public void delete(Long id) {
MroEquipment cur = get(id);
mapper.delete(id);
audit.log("EQUIPMENT_DELETE", cur.getEquipmentCode(), "id=" + id);
}
private void validate(MroEquipment e) {
if (e.getEquipmentCode() == null || e.getEquipmentCode().isBlank())
throw new IllegalArgumentException("ERR-EQ-400: equipmentCode 필수");
if (e.getEquipmentName() == null || e.getEquipmentName().isBlank())
throw new IllegalArgumentException("ERR-EQ-400: equipmentName 필수");
if (e.getCriticality() != null && !CRITICALITY.contains(e.getCriticality().toUpperCase()))
throw new IllegalArgumentException("ERR-EQ-400: criticality 은 A/B/C");
if (e.getRunStatus() != null && !RUN_STATUS.contains(e.getRunStatus().toUpperCase()))
throw new IllegalArgumentException("ERR-EQ-400: runStatus 은 RUN/IDLE/DOWN/MAINT");
}
}

View File

@ -0,0 +1,30 @@
package com.zioinfo.mro.equipment;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 설비 마스터 (mro_equipment) 위치/계층(parent_code), 중요도(A/B/C), 가동상태.
*/
@Data
public class MroEquipment {
private Long id;
private String equipmentCode;
private String equipmentName;
private String category;
private String parentCode; // 상위 설비(계층)
private String location;
private String deptCode;
private String manufacturer;
private String modelNo;
private String serialNo;
private LocalDate installDate;
private String criticality; // A / B / C
private String runStatus; // RUN / IDLE / DOWN / MAINT
private String attributes; // JSONB
private String status; // ACTIVE / INACTIVE
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.mro.equipment.mapper;
import com.zioinfo.mro.equipment.MroEquipment;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface EquipmentMapper {
List<MroEquipment> findAll(@Param("category") String category,
@Param("runStatus") String runStatus,
@Param("keyword") String keyword);
MroEquipment findById(@Param("id") Long id);
MroEquipment findByCode(@Param("equipmentCode") String equipmentCode);
int countByCode(@Param("equipmentCode") String equipmentCode, @Param("excludeId") Long excludeId);
int insert(MroEquipment e);
int update(MroEquipment e);
int updateRunStatus(@Param("id") Long id, @Param("runStatus") String runStatus);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,49 @@
package com.zioinfo.mro.inventory;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* MRO 재고 API 조회 Viewer+, upsert/수량 조정 Worker+.
*/
@RestController
@RequestMapping("/api/mro/inventory")
@RequiredArgsConstructor
public class InventoryController {
private final InventoryService service;
@GetMapping
public ApiResponse<List<MroInventory>> list(@RequestParam(required = false) String materialCode,
@RequestParam(required = false) String locationCode,
@RequestParam(required = false) Boolean belowSafety) {
return ApiResponse.ok(service.list(materialCode, locationCode, belowSafety));
}
@GetMapping("/lookup")
public ApiResponse<MroInventory> lookup(@RequestParam String materialCode,
@RequestParam(required = false) String locationCode) {
return ApiResponse.ok(service.findByMaterialLoc(materialCode, locationCode));
}
@GetMapping("/{id}")
public ApiResponse<MroInventory> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroInventory> upsert(@RequestBody MroInventory inv, Authentication auth) {
return ApiResponse.ok(service.upsert(inv, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MroInventory> adjust(@PathVariable Long id, @RequestBody MroInventory patch,
Authentication auth) {
return ApiResponse.ok(service.adjust(id, patch, AuthSupport.actor(auth)));
}
}

View File

@ -0,0 +1,73 @@
package com.zioinfo.mro.inventory;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.inventory.mapper.InventoryMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* MRO 재고 서비스 조회(Viewer+)·upsert/수량 조정(Worker+).
*
* <p>available_qty = on_hand_qty - allocated_qty 저장 자동 계산한다.
*/
@Service
@RequiredArgsConstructor
public class InventoryService {
private final InventoryMapper mapper;
private final AuditService audit;
public List<MroInventory> list(String materialCode, String locationCode, Boolean belowSafety) {
return mapper.findAll(materialCode, locationCode, belowSafety);
}
public MroInventory get(Long id) {
MroInventory inv = mapper.findById(id);
if (inv == null) throw new RuntimeException("ERR-INV-404: 재고 없음");
return inv;
}
public MroInventory findByMaterialLoc(String materialCode, String locationCode) {
if (materialCode == null || materialCode.isBlank())
throw new IllegalArgumentException("ERR-INV-400: materialCode 필수");
String loc = (locationCode == null || locationCode.isBlank()) ? "WH-MRO" : locationCode;
MroInventory inv = mapper.findByMaterialLoc(materialCode, loc);
if (inv == null) throw new RuntimeException("ERR-INV-404: 재고 없음");
return inv;
}
/** 재고 upsert (멱등, ON CONFLICT material_code+location_code) — Worker+. */
public MroInventory upsert(MroInventory inv, String actor) {
if (inv.getMaterialCode() == null || inv.getMaterialCode().isBlank())
throw new IllegalArgumentException("ERR-INV-400: materialCode 필수");
if (inv.getLocationCode() == null || inv.getLocationCode().isBlank()) inv.setLocationCode("WH-MRO");
if (inv.getOnHandQty() == null) inv.setOnHandQty(0d);
if (inv.getAllocatedQty() == null) inv.setAllocatedQty(0d);
if (inv.getSafetyStock() == null) inv.setSafetyStock(0d);
if (inv.getUnit() == null) inv.setUnit("EA");
inv.setAvailableQty(inv.getOnHandQty() - inv.getAllocatedQty());
mapper.upsert(inv);
audit.log(actor, "INVENTORY_UPSERT", inv.getMaterialCode(),
"loc=" + inv.getLocationCode() + " onHand=" + inv.getOnHandQty());
return mapper.findByMaterialLoc(inv.getMaterialCode(), inv.getLocationCode());
}
/** 안전재고/수량 조정 (PUT /{id}) — Worker+. */
public MroInventory adjust(Long id, MroInventory patch, String actor) {
MroInventory cur = get(id);
if (patch.getOnHandQty() != null) cur.setOnHandQty(patch.getOnHandQty());
if (patch.getAllocatedQty() != null) cur.setAllocatedQty(patch.getAllocatedQty());
if (patch.getSafetyStock() != null) cur.setSafetyStock(patch.getSafetyStock());
if (patch.getUnit() != null) cur.setUnit(patch.getUnit());
if (patch.getLastCountDate() != null) cur.setLastCountDate(patch.getLastCountDate());
double onHand = cur.getOnHandQty() == null ? 0d : cur.getOnHandQty();
double alloc = cur.getAllocatedQty() == null ? 0d : cur.getAllocatedQty();
cur.setAvailableQty(onHand - alloc);
mapper.update(cur);
audit.log(actor, "INVENTORY_ADJUST", cur.getMaterialCode(),
"id=" + id + " onHand=" + cur.getOnHandQty() + " safety=" + cur.getSafetyStock());
return mapper.findById(id);
}
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.mro.inventory;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* MRO 재고 (mro_inventory) 자재/로케이션별 재고 현황. (material_code+location_code 유니크)
*
* <p>available_qty = on_hand_qty - allocated_qty 서비스에서 자동 계산한다.
*/
@Data
public class MroInventory {
private Long id;
private String materialCode;
private String locationCode; // default 'WH-MRO'
private Double onHandQty;
private Double allocatedQty;
private Double availableQty;
private Double safetyStock;
private String unit;
private LocalDate lastCountDate;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.mro.inventory.mapper;
import com.zioinfo.mro.inventory.MroInventory;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface InventoryMapper {
List<MroInventory> findAll(@Param("materialCode") String materialCode,
@Param("locationCode") String locationCode,
@Param("belowSafety") Boolean belowSafety);
MroInventory findById(@Param("id") Long id);
MroInventory findByMaterialLoc(@Param("materialCode") String materialCode,
@Param("locationCode") String locationCode);
int upsert(MroInventory inv);
int update(MroInventory inv);
}

View File

@ -0,0 +1,37 @@
package com.zioinfo.mro.inventorytxn;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* MRO 입출고 API 조회 Viewer+, 입출고 등록 Worker+(재고 반영).
*/
@RestController
@RequestMapping("/api/mro/inventory-txns")
@RequiredArgsConstructor
public class InventoryTxnController {
private final InventoryTxnService service;
@GetMapping
public ApiResponse<List<MroInventoryTxn>> list(@RequestParam(required = false) String materialCode,
@RequestParam(required = false) String txnType,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(materialCode, txnType, keyword));
}
@GetMapping("/{id}")
public ApiResponse<MroInventoryTxn> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroInventoryTxn> post(@RequestBody MroInventoryTxn txn, Authentication auth) {
return ApiResponse.ok(service.post(txn, AuthSupport.actor(auth)));
}
}

View File

@ -0,0 +1,79 @@
package com.zioinfo.mro.inventorytxn;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.inventorytxn.mapper.InventoryTxnMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Set;
/**
* MRO 입출고 서비스 트랜잭션 등록(Worker+) 동시에 mro_inventory 재고를 반영.
*
* <p>부호 규칙: IN/RETURN=+qty, OUT=-qty, ADJUST=요청 qty 그대로, MOVE=재고 미반영(단순 기록).
*/
@Service
@RequiredArgsConstructor
public class InventoryTxnService {
private static final Set<String> TXN_TYPES = Set.of("IN", "OUT", "ADJUST", "MOVE", "RETURN");
private final InventoryTxnMapper mapper;
private final AuditService audit;
public List<MroInventoryTxn> list(String materialCode, String txnType, String keyword) {
return mapper.findAll(materialCode, txnType, keyword);
}
public MroInventoryTxn get(Long id) {
MroInventoryTxn txn = mapper.findById(id);
if (txn == null) throw new RuntimeException("ERR-TXN-404: 입출고 이력 없음");
return txn;
}
/** 입출고 등록 — 재고 반영과 함께 트랜잭션 처리(Worker+). */
@Transactional
public MroInventoryTxn post(MroInventoryTxn txn, String actor) {
if (txn.getMaterialCode() == null || txn.getMaterialCode().isBlank())
throw new IllegalArgumentException("ERR-TXN-400: materialCode 필수");
String type = txn.getTxnType() == null ? "" : txn.getTxnType().trim().toUpperCase();
if (!TXN_TYPES.contains(type))
throw new IllegalArgumentException("ERR-TXN-400: txnType 은 IN/OUT/ADJUST/MOVE/RETURN");
if (txn.getQty() == null)
throw new IllegalArgumentException("ERR-TXN-400: qty 필수");
txn.setTxnType(type);
if (txn.getLocationCode() == null || txn.getLocationCode().isBlank()) txn.setLocationCode("WH-MRO");
if (txn.getUnit() == null) txn.setUnit("EA");
txn.setTxnNo("TXN-" + System.currentTimeMillis());
txn.setTxnBy(actor);
double qty = txn.getQty();
double delta;
switch (type) {
case "IN":
case "RETURN":
delta = Math.abs(qty);
break;
case "OUT":
delta = -Math.abs(qty);
break;
case "ADJUST":
delta = qty;
break;
default: // MOVE 단순 기록, 재고 미반영
delta = 0d;
}
if (delta != 0d) {
mapper.applyStockDelta(txn.getMaterialCode(), txn.getLocationCode(), delta);
}
Double balance = mapper.currentOnHand(txn.getMaterialCode(), txn.getLocationCode());
txn.setBalanceAfter(balance);
mapper.insert(txn);
audit.log(actor, "INVENTORY_TXN", txn.getMaterialCode(),
type + " qty=" + qty + " balance=" + balance);
return mapper.findById(txn.getId());
}
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.mro.inventorytxn;
import lombok.Data;
import java.time.LocalDateTime;
/**
* MRO 입출고 트랜잭션 (mro_inventory_txn) 입고/출고/조정/이동/반품 이력.
*
* <p>txnType: IN/OUT/ADJUST/MOVE/RETURN. 등록과 동시에 mro_inventory 재고를 반영한다.
*/
@Data
public class MroInventoryTxn {
private Long id;
private String txnNo;
private String materialCode;
private String locationCode;
private String txnType;
private Double qty;
private String unit;
private String refType; // PO/WO/STOCKTAKE/MANUAL
private String refNo;
private String woNo;
private String reason;
private Double balanceAfter;
private String txnBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,25 @@
package com.zioinfo.mro.inventorytxn.mapper;
import com.zioinfo.mro.inventorytxn.MroInventoryTxn;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface InventoryTxnMapper {
List<MroInventoryTxn> findAll(@Param("materialCode") String materialCode,
@Param("txnType") String txnType,
@Param("keyword") String keyword);
MroInventoryTxn findById(@Param("id") Long id);
int insert(MroInventoryTxn txn);
/** mro_inventory 재고 증감(멱등 upsert) — on_hand/available 에 delta 반영. */
int applyStockDelta(@Param("materialCode") String materialCode,
@Param("locationCode") String locationCode,
@Param("delta") Double delta);
/** 반영 후 on_hand 잔량 조회(balance_after 산출용). */
Double currentOnHand(@Param("materialCode") String materialCode,
@Param("locationCode") String locationCode);
}

View File

@ -0,0 +1,50 @@
package com.zioinfo.mro.maintenance;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 정비이력 API 조회 Viewer+, 생성/수정 Worker+, 삭제 Manager+.
*/
@RestController
@RequestMapping("/api/mro/maintenance-history")
@RequiredArgsConstructor
public class MaintenanceController {
private final MaintenanceService service;
@GetMapping
public ApiResponse<List<MroMaintenanceHistory>> list(@RequestParam(required = false) String equipmentCode,
@RequestParam(required = false) String maintType,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(equipmentCode, maintType, keyword));
}
@GetMapping("/{id}")
public ApiResponse<MroMaintenanceHistory> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroMaintenanceHistory> create(@RequestBody MroMaintenanceHistory m, Authentication auth) {
return ApiResponse.ok(service.create(m, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MroMaintenanceHistory> update(@PathVariable Long id, @RequestBody MroMaintenanceHistory m,
Authentication auth) {
return ApiResponse.ok(service.update(id, m, AuthSupport.actor(auth)));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
service.delete(id, AuthSupport.actor(auth));
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,75 @@
package com.zioinfo.mro.maintenance;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.maintenance.mapper.MaintenanceMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.List;
import java.util.Set;
/**
* 정비이력 서비스 CRUD. fault_at/repaired_at 있으면 repair_min 자동계산().
*/
@Service
@RequiredArgsConstructor
public class MaintenanceService {
private static final Set<String> MAINT_TYPES = Set.of("FAULT", "REPAIR", "PM");
private final MaintenanceMapper mapper;
private final AuditService audit;
public List<MroMaintenanceHistory> list(String equipmentCode, String maintType, String keyword) {
return mapper.findAll(equipmentCode, maintType, keyword);
}
public MroMaintenanceHistory get(Long id) {
MroMaintenanceHistory m = mapper.findById(id);
if (m == null) throw new RuntimeException("ERR-MNT-404: 정비이력 없음");
return m;
}
public MroMaintenanceHistory create(MroMaintenanceHistory m, String actor) {
validate(m);
if (m.getMaintType() == null) m.setMaintType("REPAIR");
computeRepairMin(m);
m.setCreatedBy(actor);
mapper.insert(m);
audit.log(actor, "MAINTENANCE_CREATE", m.getEquipmentCode(), "type=" + m.getMaintType());
return mapper.findById(m.getId());
}
public MroMaintenanceHistory update(Long id, MroMaintenanceHistory m, String actor) {
MroMaintenanceHistory cur = get(id);
validate(m);
if (m.getMaintType() == null) m.setMaintType(cur.getMaintType());
computeRepairMin(m);
m.setId(id);
mapper.update(m);
audit.log(actor, "MAINTENANCE_UPDATE", cur.getEquipmentCode(), "id=" + id);
return mapper.findById(id);
}
public void delete(Long id, String actor) {
MroMaintenanceHistory cur = get(id);
mapper.delete(id);
audit.log(actor, "MAINTENANCE_DELETE", cur.getEquipmentCode(), "id=" + id);
}
private void validate(MroMaintenanceHistory m) {
if (m.getEquipmentCode() == null || m.getEquipmentCode().isBlank())
throw new IllegalArgumentException("ERR-MNT-400: equipmentCode 필수");
if (m.getMaintType() != null && !MAINT_TYPES.contains(m.getMaintType().toUpperCase()))
throw new IllegalArgumentException("ERR-MNT-400: maintType 은 FAULT/REPAIR/PM");
}
/** fault_at/repaired_at 둘 다 있으면 repair_min 자동계산(분). */
private void computeRepairMin(MroMaintenanceHistory m) {
if (m.getFaultAt() != null && m.getRepairedAt() != null) {
long minutes = Duration.between(m.getFaultAt(), m.getRepairedAt()).toMinutes();
m.setRepairMin((double) minutes);
}
}
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.mro.maintenance;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 정비이력 (mro_maintenance_history) 고장/정비/PM 이력. fault_at·repaired_at repair_min 자동계산.
*/
@Data
public class MroMaintenanceHistory {
private Long id;
private String equipmentCode;
private String woNo;
private String maintType; // FAULT/REPAIR/PM
private String symptom;
private String cause;
private String action;
private String partUsed;
private LocalDateTime faultAt;
private LocalDateTime repairedAt;
private Double repairMin;
private Double downtimeMin;
private Double cost;
private String technician;
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.mro.maintenance.mapper;
import com.zioinfo.mro.maintenance.MroMaintenanceHistory;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface MaintenanceMapper {
List<MroMaintenanceHistory> findAll(@Param("equipmentCode") String equipmentCode,
@Param("maintType") String maintType,
@Param("keyword") String keyword);
MroMaintenanceHistory findById(@Param("id") Long id);
int insert(MroMaintenanceHistory m);
int update(MroMaintenanceHistory m);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,51 @@
package com.zioinfo.mro.material;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* MRO 자재 마스터 API. 조회 Viewer+, 생성/수정/삭제 Manager+(기준정보 가드).
*/
@RestController
@RequestMapping("/api/mro/materials")
@RequiredArgsConstructor
public class MaterialController {
private final MaterialService service;
@GetMapping
public ApiResponse<List<MroMaterial>> list(@RequestParam(required = false) String materialType,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(materialType, status, keyword));
}
@GetMapping("/{id}")
public ApiResponse<MroMaterial> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroMaterial> create(@RequestBody MroMaterial m, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.create(m, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MroMaterial> update(@PathVariable Long id, @RequestBody MroMaterial m, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.update(id, m, AuthSupport.actor(auth)));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,73 @@
package com.zioinfo.mro.material;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.material.mapper.MaterialMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
/**
* MRO 자재 마스터 서비스 기준정보(생성/수정/삭제는 컨트롤러 MANAGER+ 가드 병행).
*/
@Service
@RequiredArgsConstructor
public class MaterialService {
private static final Set<String> MATERIAL_TYPE = Set.of("SPARE", "TOOL", "CONSUMABLE", "PART");
private final MaterialMapper mapper;
private final AuditService audit;
public List<MroMaterial> list(String materialType, String status, String keyword) {
return mapper.findAll(materialType, status, keyword);
}
public MroMaterial get(Long id) {
MroMaterial m = mapper.findById(id);
if (m == null) throw new RuntimeException("ERR-MAT-404: 자재 없음");
return m;
}
public MroMaterial create(MroMaterial m, String actor) {
validate(m);
if (mapper.countByCode(m.getMaterialCode(), null) > 0) {
throw new RuntimeException("ERR-MAT-409: 중복 자재코드");
}
if (m.getStatus() == null) m.setStatus("ACTIVE");
if (m.getMaterialType() == null) m.setMaterialType("SPARE");
if (m.getUnit() == null) m.setUnit("EA");
m.setCreatedBy(actor);
mapper.insert(m);
audit.log("MATERIAL_CREATE", m.getMaterialCode(), "type=" + m.getMaterialType());
return mapper.findById(m.getId());
}
public MroMaterial update(Long id, MroMaterial m, String actor) {
MroMaterial cur = get(id);
validate(m);
if (mapper.countByCode(m.getMaterialCode(), id) > 0) {
throw new RuntimeException("ERR-MAT-409: 중복 자재코드");
}
m.setId(id);
mapper.update(m);
audit.log("MATERIAL_UPDATE", cur.getMaterialCode(), "id=" + id);
return mapper.findById(id);
}
public void delete(Long id) {
MroMaterial cur = get(id);
mapper.delete(id);
audit.log("MATERIAL_DELETE", cur.getMaterialCode(), "id=" + id);
}
private void validate(MroMaterial m) {
if (m.getMaterialCode() == null || m.getMaterialCode().isBlank())
throw new IllegalArgumentException("ERR-MAT-400: materialCode 필수");
if (m.getMaterialName() == null || m.getMaterialName().isBlank())
throw new IllegalArgumentException("ERR-MAT-400: materialName 필수");
if (m.getMaterialType() != null && !MATERIAL_TYPE.contains(m.getMaterialType().toUpperCase()))
throw new IllegalArgumentException("ERR-MAT-400: materialType 은 SPARE/TOOL/CONSUMABLE/PART");
}
}

View File

@ -0,0 +1,31 @@
package com.zioinfo.mro.material;
import lombok.Data;
import java.time.LocalDateTime;
/**
* MRO 자재 마스터 (mro_material) 예비품/공구/소모품/부품, 안전재고·재발주점·리드타임.
*/
@Data
public class MroMaterial {
private Long id;
private String materialCode;
private String materialName;
private String materialType; // SPARE / TOOL / CONSUMABLE / PART
private String category;
private String unit;
private String spec;
private String manufacturer;
private String barcode;
private Double unitPrice;
private Double safetyStock;
private Double reorderPoint;
private Integer leadTimeDays;
private String locationCode;
private String partnerCode;
private String attributes; // JSONB
private String status; // ACTIVE / INACTIVE
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.mro.material.mapper;
import com.zioinfo.mro.material.MroMaterial;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface MaterialMapper {
List<MroMaterial> findAll(@Param("materialType") String materialType,
@Param("status") String status,
@Param("keyword") String keyword);
MroMaterial findById(@Param("id") Long id);
MroMaterial findByCode(@Param("materialCode") String materialCode);
int countByCode(@Param("materialCode") String materialCode, @Param("excludeId") Long excludeId);
int insert(MroMaterial m);
int update(MroMaterial m);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,50 @@
package com.zioinfo.mro.materialbom;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 자재 BOM API. 조회 Viewer+, 생성/수정/삭제 Manager+(기준정보 가드).
*/
@RestController
@RequestMapping("/api/mro/material-boms")
@RequiredArgsConstructor
public class MaterialBomController {
private final MaterialBomService service;
@GetMapping
public ApiResponse<List<MroMaterialBom>> list(@RequestParam(required = false) String equipmentCode,
@RequestParam(required = false) String materialCode) {
return ApiResponse.ok(service.list(equipmentCode, materialCode));
}
@GetMapping("/{id}")
public ApiResponse<MroMaterialBom> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroMaterialBom> create(@RequestBody MroMaterialBom b, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.create(b, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MroMaterialBom> update(@PathVariable Long id, @RequestBody MroMaterialBom b, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.update(id, b, AuthSupport.actor(auth)));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,62 @@
package com.zioinfo.mro.materialbom;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.materialbom.mapper.MaterialBomMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 자재 BOM 서비스 설비-자재 소요관계 기준정보(생성/수정/삭제는 컨트롤러 MANAGER+ 가드 병행).
*/
@Service
@RequiredArgsConstructor
public class MaterialBomService {
private final MaterialBomMapper mapper;
private final AuditService audit;
public List<MroMaterialBom> list(String equipmentCode, String materialCode) {
return mapper.findAll(equipmentCode, materialCode);
}
public MroMaterialBom get(Long id) {
MroMaterialBom b = mapper.findById(id);
if (b == null) throw new RuntimeException("ERR-MBOM-404: BOM 없음");
return b;
}
public MroMaterialBom create(MroMaterialBom b, String actor) {
validate(b);
if (b.getStatus() == null) b.setStatus("ACTIVE");
if (b.getUnit() == null) b.setUnit("EA");
if (b.getQtyPer() == null) b.setQtyPer(1.0);
b.setCreatedBy(actor);
mapper.insert(b);
audit.log("MATERIAL_BOM_CREATE", b.getEquipmentCode(), "material=" + b.getMaterialCode());
return mapper.findById(b.getId());
}
public MroMaterialBom update(Long id, MroMaterialBom b, String actor) {
MroMaterialBom cur = get(id);
validate(b);
b.setId(id);
mapper.update(b);
audit.log("MATERIAL_BOM_UPDATE", cur.getEquipmentCode(), "id=" + id);
return mapper.findById(id);
}
public void delete(Long id) {
MroMaterialBom cur = get(id);
mapper.delete(id);
audit.log("MATERIAL_BOM_DELETE", cur.getEquipmentCode(), "id=" + id);
}
private void validate(MroMaterialBom b) {
if (b.getEquipmentCode() == null || b.getEquipmentCode().isBlank())
throw new IllegalArgumentException("ERR-MBOM-400: equipmentCode 필수");
if (b.getMaterialCode() == null || b.getMaterialCode().isBlank())
throw new IllegalArgumentException("ERR-MBOM-400: materialCode 필수");
}
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.mro.materialbom;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 자재 BOM (mro_material_bom) 설비별 소요 부품/자재.
*/
@Data
public class MroMaterialBom {
private Long id;
private String equipmentCode;
private String materialCode;
private Double qtyPer;
private String unit;
private String position;
private String remark;
private String status; // ACTIVE / INACTIVE
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.mro.materialbom.mapper;
import com.zioinfo.mro.materialbom.MroMaterialBom;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface MaterialBomMapper {
List<MroMaterialBom> findAll(@Param("equipmentCode") String equipmentCode,
@Param("materialCode") String materialCode);
MroMaterialBom findById(@Param("id") Long id);
int insert(MroMaterialBom b);
int update(MroMaterialBom b);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,34 @@
package com.zioinfo.mro.partner;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 거래처 마스터 (mro_partner) 공급사/벤더/서비스사. PII(연락처·이메일) AES-256-GCM 암호화 저장.
*
* <p>{@code contact}/{@code email} 입출력용 transient 평문 필드. 저장 암호화하여
* {@code contactEnc}/{@code emailEnc} 넣고, 조회 응답 복호마스킹하여 평문 필드에만 세팅하고
* enc 필드는 null로 비워 반환한다(원문 enc·평문 미노출).
*/
@Data
public class MroPartner {
private Long id;
private String partnerCode;
private String partnerName;
private String partnerType; // SUPPLIER / VENDOR / SERVICE
private String bizNo;
private String managerName;
private String contactEnc; // 암호화 저장 컬럼(응답 null)
private String emailEnc; // 암호화 저장 컬럼(응답 null)
private String address;
private String paymentTerms;
private String rating;
private String status; // ACTIVE / INACTIVE
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// 입출력용 평문(transient) DB 컬럼 아님
private transient String contact;
private transient String email;
}

View File

@ -0,0 +1,51 @@
package com.zioinfo.mro.partner;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 거래처 API. 조회 Viewer+, 생성/수정/삭제 Manager+(기준정보 가드). PII는 마스킹 응답.
*/
@RestController
@RequestMapping("/api/mro/partners")
@RequiredArgsConstructor
public class PartnerController {
private final PartnerService service;
@GetMapping
public ApiResponse<List<MroPartner>> list(@RequestParam(required = false) String partnerType,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(partnerType, status, keyword));
}
@GetMapping("/{id}")
public ApiResponse<MroPartner> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroPartner> create(@RequestBody MroPartner p, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.create(p, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MroPartner> update(@PathVariable Long id, @RequestBody MroPartner p, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.update(id, p, AuthSupport.actor(auth)));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,92 @@
package com.zioinfo.mro.partner;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.common.CryptoUtil;
import com.zioinfo.mro.partner.mapper.PartnerMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
/**
* 거래처 서비스 PII(연락처·이메일) AES-256-GCM 암호화 저장, 조회 복호마스킹 enc 필드 제거.
*/
@Service
@RequiredArgsConstructor
public class PartnerService {
private static final Set<String> PARTNER_TYPE = Set.of("SUPPLIER", "VENDOR", "SERVICE");
private final PartnerMapper mapper;
private final AuditService audit;
private final CryptoUtil cryptoUtil;
public List<MroPartner> list(String partnerType, String status, String keyword) {
List<MroPartner> rows = mapper.findAll(partnerType, status, keyword);
rows.forEach(this::maskForResponse);
return rows;
}
public MroPartner get(Long id) {
MroPartner p = mapper.findById(id);
if (p == null) throw new RuntimeException("ERR-PTN-404: 거래처 없음");
maskForResponse(p);
return p;
}
public MroPartner create(MroPartner p, String actor) {
validate(p);
if (mapper.countByCode(p.getPartnerCode(), null) > 0) {
throw new RuntimeException("ERR-PTN-409: 중복 거래처코드");
}
if (p.getStatus() == null) p.setStatus("ACTIVE");
if (p.getPartnerType() == null) p.setPartnerType("SUPPLIER");
p.setContactEnc(cryptoUtil.encrypt(p.getContact()));
p.setEmailEnc(cryptoUtil.encrypt(p.getEmail()));
p.setCreatedBy(actor);
mapper.insert(p);
audit.log("PARTNER_CREATE", p.getPartnerCode(), "type=" + p.getPartnerType());
return get(p.getId());
}
public MroPartner update(Long id, MroPartner p, String actor) {
MroPartner cur = mapper.findById(id);
if (cur == null) throw new RuntimeException("ERR-PTN-404: 거래처 없음");
validate(p);
if (mapper.countByCode(p.getPartnerCode(), id) > 0) {
throw new RuntimeException("ERR-PTN-409: 중복 거래처코드");
}
p.setId(id);
p.setContactEnc(cryptoUtil.encrypt(p.getContact()));
p.setEmailEnc(cryptoUtil.encrypt(p.getEmail()));
mapper.update(p);
audit.log("PARTNER_UPDATE", cur.getPartnerCode(), "id=" + id);
return get(id);
}
public void delete(Long id) {
MroPartner cur = mapper.findById(id);
if (cur == null) throw new RuntimeException("ERR-PTN-404: 거래처 없음");
mapper.delete(id);
audit.log("PARTNER_DELETE", cur.getPartnerCode(), "id=" + id);
}
/** 응답용: enc 복호→마스킹하여 평문 필드에 세팅하고 enc 필드는 제거. */
private void maskForResponse(MroPartner p) {
if (p == null) return;
p.setContact(CryptoUtil.mask(cryptoUtil.decrypt(p.getContactEnc())));
p.setEmail(CryptoUtil.mask(cryptoUtil.decrypt(p.getEmailEnc())));
p.setContactEnc(null);
p.setEmailEnc(null);
}
private void validate(MroPartner p) {
if (p.getPartnerCode() == null || p.getPartnerCode().isBlank())
throw new IllegalArgumentException("ERR-PTN-400: partnerCode 필수");
if (p.getPartnerName() == null || p.getPartnerName().isBlank())
throw new IllegalArgumentException("ERR-PTN-400: partnerName 필수");
if (p.getPartnerType() != null && !PARTNER_TYPE.contains(p.getPartnerType().toUpperCase()))
throw new IllegalArgumentException("ERR-PTN-400: partnerType 은 SUPPLIER/VENDOR/SERVICE");
}
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.mro.partner.mapper;
import com.zioinfo.mro.partner.MroPartner;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface PartnerMapper {
List<MroPartner> findAll(@Param("partnerType") String partnerType,
@Param("status") String status,
@Param("keyword") String keyword);
MroPartner findById(@Param("id") Long id);
MroPartner findByCode(@Param("partnerCode") String partnerCode);
int countByCode(@Param("partnerCode") String partnerCode, @Param("excludeId") Long excludeId);
int insert(MroPartner p);
int update(MroPartner p);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.mro.pmplan;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 예방보전(PM) 계획 (mro_pm_plan) TBM/CBM/INSPECTION, 주기·차기 예정일·표준 작업시간.
*/
@Data
public class MroPmPlan {
private Long id;
private String planCode;
private String planName;
private String equipmentCode;
private String pmType; // TBM / CBM / INSPECTION
private Integer cycleDays;
private LocalDate lastDoneDate;
private LocalDate nextDueDate;
private String taskDesc;
private Double standardTimeMin;
private String assignee;
private String status; // ACTIVE / INACTIVE
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,51 @@
package com.zioinfo.mro.pmplan;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 예방보전(PM) 계획 API. 조회 Viewer+, 생성/수정/삭제 Manager+(기준정보 가드).
*/
@RestController
@RequestMapping("/api/mro/pm-plans")
@RequiredArgsConstructor
public class PmPlanController {
private final PmPlanService service;
@GetMapping
public ApiResponse<List<MroPmPlan>> list(@RequestParam(required = false) String equipmentCode,
@RequestParam(required = false) String pmType,
@RequestParam(required = false) String status) {
return ApiResponse.ok(service.list(equipmentCode, pmType, status));
}
@GetMapping("/{id}")
public ApiResponse<MroPmPlan> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroPmPlan> create(@RequestBody MroPmPlan p, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.create(p, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MroPmPlan> update(@PathVariable Long id, @RequestBody MroPmPlan p, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.update(id, p, AuthSupport.actor(auth)));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
service.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -0,0 +1,72 @@
package com.zioinfo.mro.pmplan;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.pmplan.mapper.PmPlanMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
/**
* 예방보전(PM) 계획 서비스 기준정보(생성/수정/삭제는 컨트롤러 MANAGER+ 가드 병행).
*/
@Service
@RequiredArgsConstructor
public class PmPlanService {
private static final Set<String> PM_TYPE = Set.of("TBM", "CBM", "INSPECTION");
private final PmPlanMapper mapper;
private final AuditService audit;
public List<MroPmPlan> list(String equipmentCode, String pmType, String status) {
return mapper.findAll(equipmentCode, pmType, status);
}
public MroPmPlan get(Long id) {
MroPmPlan p = mapper.findById(id);
if (p == null) throw new RuntimeException("ERR-PM-404: PM 계획 없음");
return p;
}
public MroPmPlan create(MroPmPlan p, String actor) {
validate(p);
if (mapper.countByCode(p.getPlanCode(), null) > 0) {
throw new RuntimeException("ERR-PM-409: 중복 PM계획코드");
}
if (p.getStatus() == null) p.setStatus("ACTIVE");
if (p.getPmType() == null) p.setPmType("TBM");
p.setCreatedBy(actor);
mapper.insert(p);
audit.log("PM_PLAN_CREATE", p.getPlanCode(), "type=" + p.getPmType());
return mapper.findById(p.getId());
}
public MroPmPlan update(Long id, MroPmPlan p, String actor) {
MroPmPlan cur = get(id);
validate(p);
if (mapper.countByCode(p.getPlanCode(), id) > 0) {
throw new RuntimeException("ERR-PM-409: 중복 PM계획코드");
}
p.setId(id);
mapper.update(p);
audit.log("PM_PLAN_UPDATE", cur.getPlanCode(), "id=" + id);
return mapper.findById(id);
}
public void delete(Long id) {
MroPmPlan cur = get(id);
mapper.delete(id);
audit.log("PM_PLAN_DELETE", cur.getPlanCode(), "id=" + id);
}
private void validate(MroPmPlan p) {
if (p.getPlanCode() == null || p.getPlanCode().isBlank())
throw new IllegalArgumentException("ERR-PM-400: planCode 필수");
if (p.getPlanName() == null || p.getPlanName().isBlank())
throw new IllegalArgumentException("ERR-PM-400: planName 필수");
if (p.getPmType() != null && !PM_TYPE.contains(p.getPmType().toUpperCase()))
throw new IllegalArgumentException("ERR-PM-400: pmType 은 TBM/CBM/INSPECTION");
}
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.mro.pmplan.mapper;
import com.zioinfo.mro.pmplan.MroPmPlan;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface PmPlanMapper {
List<MroPmPlan> findAll(@Param("equipmentCode") String equipmentCode,
@Param("pmType") String pmType,
@Param("status") String status);
MroPmPlan findById(@Param("id") Long id);
MroPmPlan findByCode(@Param("planCode") String planCode);
int countByCode(@Param("planCode") String planCode, @Param("excludeId") Long excludeId);
int insert(MroPmPlan p);
int update(MroPmPlan p);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,30 @@
package com.zioinfo.mro.purchaseorder;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 발주 (mro_purchase_order) DRAFTORDEREDPARTIAL/RECEIVEDCLOSED, +CANCELLED.
*/
@Data
public class MroPurchaseOrder {
private Long id;
private String poNo;
private String prNo;
private String partnerCode;
private String materialCode;
private Double qty;
private String unit;
private Double unitPrice;
private Double amount;
private String currency;
private LocalDate orderDate;
private LocalDate expectedDate;
private String status; // DRAFT/ORDERED/PARTIAL/RECEIVED/CLOSED/CANCELLED
private Double receivedQty;
private String buyer;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,63 @@
package com.zioinfo.mro.purchaseorder;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 발주 API 조회 Viewer+, 생성/수정/발주/입고 Worker+, 취소 Manager+.
*/
@RestController
@RequestMapping("/api/mro/purchase-orders")
@RequiredArgsConstructor
public class PurchaseOrderController {
private final PurchaseOrderService service;
@GetMapping
public ApiResponse<List<MroPurchaseOrder>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String partnerCode,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(status, partnerCode, keyword));
}
@GetMapping("/{id}")
public ApiResponse<MroPurchaseOrder> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroPurchaseOrder> create(@RequestBody MroPurchaseOrder po, Authentication auth) {
return ApiResponse.ok(service.create(po, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MroPurchaseOrder> update(@PathVariable Long id, @RequestBody MroPurchaseOrder po,
Authentication auth) {
return ApiResponse.ok(service.update(id, po, AuthSupport.actor(auth)));
}
@PatchMapping("/{id}/order")
public ApiResponse<MroPurchaseOrder> order(@PathVariable Long id, Authentication auth) {
return ApiResponse.ok(service.order(id, AuthSupport.actor(auth)));
}
@PatchMapping("/{id}/receive")
public ApiResponse<MroPurchaseOrder> receive(@PathVariable Long id, @RequestBody Map<String, Object> body,
Authentication auth) {
Object v = body == null ? null : body.get("receivedQty");
Double receivedQty = v == null ? null : Double.valueOf(v.toString());
return ApiResponse.ok(service.receive(id, receivedQty, AuthSupport.actor(auth)));
}
@PatchMapping("/{id}/cancel")
public ApiResponse<MroPurchaseOrder> cancel(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.cancel(id, AuthSupport.actor(auth)));
}
}

View File

@ -0,0 +1,125 @@
package com.zioinfo.mro.purchaseorder;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.purchaseorder.mapper.PurchaseOrderMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
/**
* 발주 서비스 발주확정(ORDERED)·입고(PARTIAL/RECEIVED)·취소.
*
* <p>수정은 DRAFT . 입고는 received_qty 누적, qty 충족 RECEIVED. 취소는 RECEIVED/CLOSED 이전만.
*/
@Service
@RequiredArgsConstructor
public class PurchaseOrderService {
private static final Set<String> RECEIVED_FINAL = Set.of("RECEIVED", "CLOSED", "CANCELLED");
private final PurchaseOrderMapper mapper;
private final AuditService audit;
public List<MroPurchaseOrder> list(String status, String partnerCode, String keyword) {
return mapper.findAll(status, partnerCode, keyword);
}
public MroPurchaseOrder get(Long id) {
MroPurchaseOrder po = mapper.findById(id);
if (po == null) throw new RuntimeException("ERR-PO-404: 발주 없음");
return po;
}
public MroPurchaseOrder create(MroPurchaseOrder po, String actor) {
if (po.getMaterialCode() == null || po.getMaterialCode().isBlank())
throw new IllegalArgumentException("ERR-PO-400: materialCode 필수");
if (po.getPoNo() == null || po.getPoNo().isBlank()) {
po.setPoNo("PO-" + System.currentTimeMillis());
}
if (mapper.countByNo(po.getPoNo()) > 0) {
throw new RuntimeException("ERR-PO-409: 중복 발주번호");
}
double qty = po.getQty() == null ? 0 : po.getQty();
double unitPrice = po.getUnitPrice() == null ? 0 : po.getUnitPrice();
po.setQty(qty);
po.setUnitPrice(unitPrice);
po.setAmount(qty * unitPrice);
if (po.getUnit() == null) po.setUnit("EA");
if (po.getCurrency() == null) po.setCurrency("KRW");
po.setStatus("DRAFT");
po.setReceivedQty(0.0);
po.setCreatedBy(actor);
mapper.insert(po);
audit.log(actor, "PO_CREATE", po.getPoNo(), "amount=" + po.getAmount());
return mapper.findById(po.getId());
}
public MroPurchaseOrder update(Long id, MroPurchaseOrder po, String actor) {
MroPurchaseOrder cur = get(id);
if (!"DRAFT".equals(cur.getStatus())) {
throw new RuntimeException("ERR-PO-409: DRAFT 상태에서만 수정 가능 (현재 " + cur.getStatus() + ")");
}
double qty = po.getQty() == null ? 0 : po.getQty();
double unitPrice = po.getUnitPrice() == null ? 0 : po.getUnitPrice();
po.setId(id);
po.setPoNo(cur.getPoNo());
po.setStatus(cur.getStatus());
po.setQty(qty);
po.setUnitPrice(unitPrice);
po.setAmount(qty * unitPrice);
po.setReceivedQty(cur.getReceivedQty());
po.setOrderDate(cur.getOrderDate());
if (po.getCurrency() == null) po.setCurrency(cur.getCurrency());
mapper.update(po);
audit.log(actor, "PO_UPDATE", cur.getPoNo(), "id=" + id);
return mapper.findById(id);
}
/** 발주확정 — DRAFT → ORDERED (order_date=now). */
public MroPurchaseOrder order(Long id, String actor) {
MroPurchaseOrder cur = get(id);
if (!"DRAFT".equals(cur.getStatus())) {
throw new RuntimeException("ERR-PO-409: DRAFT 상태에서만 발주 가능 (현재 " + cur.getStatus() + ")");
}
cur.setStatus("ORDERED");
cur.setOrderDate(LocalDate.now());
mapper.update(cur);
audit.log(actor, "PO_ORDER", cur.getPoNo(), "ordered");
return mapper.findById(id);
}
/** 입고 — received_qty 누적, qty 충족 시 RECEIVED 아니면 PARTIAL. */
public MroPurchaseOrder receive(Long id, Double receivedQty, String actor) {
MroPurchaseOrder cur = get(id);
if ("RECEIVED".equals(cur.getStatus()) || "CLOSED".equals(cur.getStatus())
|| "CANCELLED".equals(cur.getStatus())) {
throw new RuntimeException("ERR-PO-409: 입고 완료/취소된 발주는 입고 불가 (현재 " + cur.getStatus() + ")");
}
if (receivedQty == null || receivedQty <= 0) {
throw new IllegalArgumentException("ERR-PO-400: receivedQty 는 0 초과");
}
double already = cur.getReceivedQty() == null ? 0 : cur.getReceivedQty();
double total = already + receivedQty;
double ordered = cur.getQty() == null ? 0 : cur.getQty();
cur.setReceivedQty(total);
cur.setStatus(total >= ordered ? "RECEIVED" : "PARTIAL");
mapper.update(cur);
audit.log(actor, "PO_RECEIVE", cur.getPoNo(), "receivedQty=" + total + "/" + ordered);
return mapper.findById(id);
}
/** 취소 — RECEIVED/CLOSED 이전만 (Manager+). */
public MroPurchaseOrder cancel(Long id, String actor) {
MroPurchaseOrder cur = get(id);
if (RECEIVED_FINAL.contains(cur.getStatus())) {
throw new RuntimeException("ERR-PO-409: 입고완료/마감/취소된 발주는 취소 불가 (현재 " + cur.getStatus() + ")");
}
cur.setStatus("CANCELLED");
mapper.update(cur);
audit.log(actor, "PO_CANCEL", cur.getPoNo(), "cancelled");
return mapper.findById(id);
}
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.mro.purchaseorder.mapper;
import com.zioinfo.mro.purchaseorder.MroPurchaseOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface PurchaseOrderMapper {
List<MroPurchaseOrder> findAll(@Param("status") String status,
@Param("partnerCode") String partnerCode,
@Param("keyword") String keyword);
MroPurchaseOrder findById(@Param("id") Long id);
int countByNo(@Param("poNo") String poNo);
int insert(MroPurchaseOrder po);
int update(MroPurchaseOrder po);
}

View File

@ -0,0 +1,30 @@
package com.zioinfo.mro.purchaserequest;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 구매요청 (mro_purchase_request) DRAFTSUBMITTEDAPPROVED/REJECTEDORDERED.
*/
@Data
public class MroPurchaseRequest {
private Long id;
private String prNo;
private String title;
private String materialCode;
private Double qty;
private String unit;
private LocalDate requiredDate;
private String requester;
private String deptCode;
private String purpose;
private String woNo;
private Double estAmount;
private String status; // DRAFT/SUBMITTED/APPROVED/REJECTED/ORDERED
private String approver;
private LocalDateTime approvedAt;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,59 @@
package com.zioinfo.mro.purchaserequest;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 구매요청 API 조회 Viewer+, 생성/수정/상신 Worker+, 승인/반려 Manager+.
*/
@RestController
@RequestMapping("/api/mro/purchase-requests")
@RequiredArgsConstructor
public class PurchaseRequestController {
private final PurchaseRequestService service;
@GetMapping
public ApiResponse<List<MroPurchaseRequest>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(status, keyword));
}
@GetMapping("/{id}")
public ApiResponse<MroPurchaseRequest> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroPurchaseRequest> create(@RequestBody MroPurchaseRequest pr, Authentication auth) {
return ApiResponse.ok(service.create(pr, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MroPurchaseRequest> update(@PathVariable Long id, @RequestBody MroPurchaseRequest pr,
Authentication auth) {
return ApiResponse.ok(service.update(id, pr, AuthSupport.actor(auth)));
}
@PatchMapping("/{id}/submit")
public ApiResponse<MroPurchaseRequest> submit(@PathVariable Long id, Authentication auth) {
return ApiResponse.ok(service.submit(id, AuthSupport.actor(auth)));
}
@PatchMapping("/{id}/approve")
public ApiResponse<MroPurchaseRequest> approve(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.approve(id, AuthSupport.actor(auth)));
}
@PatchMapping("/{id}/reject")
public ApiResponse<MroPurchaseRequest> reject(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.reject(id, AuthSupport.actor(auth)));
}
}

View File

@ -0,0 +1,114 @@
package com.zioinfo.mro.purchaserequest;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.purchaserequest.mapper.PurchaseRequestMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
/**
* 구매요청 서비스 상태전이 DRAFTSUBMITTEDAPPROVED/REJECTED. ORDERED 발주 연동.
*
* <p>수정은 DRAFT . 완료(APPROVED/REJECTED/ORDERED) 재전이 불가.
*/
@Service
@RequiredArgsConstructor
public class PurchaseRequestService {
private static final Set<String> FINAL_STATUS = Set.of("APPROVED", "REJECTED", "ORDERED");
private final PurchaseRequestMapper mapper;
private final AuditService audit;
public List<MroPurchaseRequest> list(String status, String keyword) {
return mapper.findAll(status, keyword);
}
public MroPurchaseRequest get(Long id) {
MroPurchaseRequest pr = mapper.findById(id);
if (pr == null) throw new RuntimeException("ERR-PR-404: 구매요청 없음");
return pr;
}
public MroPurchaseRequest create(MroPurchaseRequest pr, String actor) {
if (pr.getTitle() == null || pr.getTitle().isBlank())
throw new IllegalArgumentException("ERR-PR-400: title 필수");
if (pr.getPrNo() == null || pr.getPrNo().isBlank()) {
pr.setPrNo("PR-" + System.currentTimeMillis());
}
if (mapper.countByNo(pr.getPrNo()) > 0) {
throw new RuntimeException("ERR-PR-409: 중복 구매요청번호");
}
if (pr.getUnit() == null) pr.setUnit("EA");
pr.setStatus("DRAFT");
if (pr.getRequester() == null) pr.setRequester(actor);
pr.setApprover(null);
pr.setApprovedAt(null);
pr.setCreatedBy(actor);
mapper.insert(pr);
audit.log(actor, "PR_CREATE", pr.getPrNo(), "material=" + pr.getMaterialCode());
return mapper.findById(pr.getId());
}
public MroPurchaseRequest update(Long id, MroPurchaseRequest pr, String actor) {
MroPurchaseRequest cur = get(id);
if (!"DRAFT".equals(cur.getStatus())) {
throw new RuntimeException("ERR-PR-409: DRAFT 상태에서만 수정 가능 (현재 " + cur.getStatus() + ")");
}
pr.setId(id);
pr.setPrNo(cur.getPrNo());
pr.setStatus(cur.getStatus());
pr.setApprover(cur.getApprover());
pr.setApprovedAt(cur.getApprovedAt());
mapper.update(pr);
audit.log(actor, "PR_UPDATE", cur.getPrNo(), "id=" + id);
return mapper.findById(id);
}
/** 상신 — DRAFT → SUBMITTED. */
public MroPurchaseRequest submit(Long id, String actor) {
MroPurchaseRequest cur = get(id);
requireStatus(cur, "DRAFT", "SUBMITTED");
cur.setStatus("SUBMITTED");
mapper.update(cur);
audit.log(actor, "PR_SUBMIT", cur.getPrNo(), "submitted");
return mapper.findById(id);
}
/** 승인 — SUBMITTED → APPROVED (approver=actor, approved_at=now, Manager+). */
public MroPurchaseRequest approve(Long id, String actor) {
MroPurchaseRequest cur = get(id);
requireStatus(cur, "SUBMITTED", "APPROVED");
cur.setStatus("APPROVED");
cur.setApprover(actor);
cur.setApprovedAt(LocalDateTime.now());
mapper.update(cur);
audit.log(actor, "PR_APPROVE", cur.getPrNo(), "approved");
return mapper.findById(id);
}
/** 반려 — SUBMITTED → REJECTED (Manager+). */
public MroPurchaseRequest reject(Long id, String actor) {
MroPurchaseRequest cur = get(id);
requireStatus(cur, "SUBMITTED", "REJECTED");
cur.setStatus("REJECTED");
cur.setApprover(actor);
cur.setApprovedAt(LocalDateTime.now());
mapper.update(cur);
audit.log(actor, "PR_REJECT", cur.getPrNo(), "rejected");
return mapper.findById(id);
}
private void requireStatus(MroPurchaseRequest pr, String expected, String to) {
if (FINAL_STATUS.contains(pr.getStatus())) {
throw new RuntimeException("ERR-PR-409: 완료된 구매요청은 재전이 불가 (현재 " + pr.getStatus() + ")");
}
if (!expected.equals(pr.getStatus())) {
throw new RuntimeException("ERR-PR-409: " + expected + " 상태에서만 " + to
+ " 전이 가능 (현재 " + pr.getStatus() + ")");
}
}
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.mro.purchaserequest.mapper;
import com.zioinfo.mro.purchaserequest.MroPurchaseRequest;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface PurchaseRequestMapper {
List<MroPurchaseRequest> findAll(@Param("status") String status,
@Param("keyword") String keyword);
MroPurchaseRequest findById(@Param("id") Long id);
int countByNo(@Param("prNo") String prNo);
int insert(MroPurchaseRequest pr);
int update(MroPurchaseRequest pr);
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.mro.receiving;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 입고 (mro_receiving) 순수 입고 기록. 재고 연동은 별도(inventorytxn) 담당.
*/
@Data
public class MroReceiving {
private Long id;
private String receivingNo;
private String poNo;
private String materialCode;
private String locationCode;
private Double receivedQty;
private String unit;
private String inspectResult; // PASS/FAIL/PENDING
private String receivedBy;
private LocalDateTime receivedAt;
private String remark;
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,43 @@
package com.zioinfo.mro.receiving;
import com.zioinfo.mro.common.ApiResponse;
import com.zioinfo.mro.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 입고 API 조회 Viewer+, 생성/검사 Worker+.
*/
@RestController
@RequestMapping("/api/mro/receivings")
@RequiredArgsConstructor
public class ReceivingController {
private final ReceivingService service;
@GetMapping
public ApiResponse<List<MroReceiving>> list(@RequestParam(required = false) String poNo,
@RequestParam(required = false) String inspectResult) {
return ApiResponse.ok(service.list(poNo, inspectResult));
}
@GetMapping("/{id}")
public ApiResponse<MroReceiving> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MroReceiving> create(@RequestBody MroReceiving r, Authentication auth) {
return ApiResponse.ok(service.create(r, AuthSupport.actor(auth)));
}
@PatchMapping("/{id}/inspect")
public ApiResponse<MroReceiving> inspect(@PathVariable Long id, @RequestBody Map<String, String> body,
Authentication auth) {
return ApiResponse.ok(service.inspect(id, body.get("inspectResult"), AuthSupport.actor(auth)));
}
}

View File

@ -0,0 +1,68 @@
package com.zioinfo.mro.receiving;
import com.zioinfo.mro.admin.AuditService;
import com.zioinfo.mro.receiving.mapper.ReceivingMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
/**
* 입고 서비스 순수 입고 기록 + 검사결과 등록. 재고 연동 없음.
*/
@Service
@RequiredArgsConstructor
public class ReceivingService {
private static final Set<String> INSPECT_RESULTS = Set.of("PASS", "FAIL", "PENDING");
private final ReceivingMapper mapper;
private final AuditService audit;
public List<MroReceiving> list(String poNo, String inspectResult) {
return mapper.findAll(poNo, inspectResult);
}
public MroReceiving get(Long id) {
MroReceiving r = mapper.findById(id);
if (r == null) throw new RuntimeException("ERR-RCV-404: 입고 없음");
return r;
}
public MroReceiving create(MroReceiving r, String actor) {
if (r.getMaterialCode() == null || r.getMaterialCode().isBlank())
throw new IllegalArgumentException("ERR-RCV-400: materialCode 필수");
if (r.getReceivingNo() == null || r.getReceivingNo().isBlank()) {
r.setReceivingNo("RCV-" + System.currentTimeMillis());
}
if (mapper.countByNo(r.getReceivingNo()) > 0) {
throw new RuntimeException("ERR-RCV-409: 중복 입고번호");
}
if (r.getLocationCode() == null) r.setLocationCode("WH-MRO");
if (r.getUnit() == null) r.setUnit("EA");
if (r.getInspectResult() == null) r.setInspectResult("PENDING");
else if (!INSPECT_RESULTS.contains(r.getInspectResult().toUpperCase()))
throw new IllegalArgumentException("ERR-RCV-400: inspectResult 은 PASS/FAIL/PENDING");
r.setReceivedBy(actor);
r.setReceivedAt(LocalDateTime.now());
r.setCreatedBy(actor);
mapper.insert(r);
audit.log(actor, "RECEIVING_CREATE", r.getReceivingNo(), "po=" + r.getPoNo());
return mapper.findById(r.getId());
}
/** 검사결과 등록 — PASS/FAIL. */
public MroReceiving inspect(Long id, String inspectResult, String actor) {
MroReceiving cur = get(id);
String s = inspectResult == null ? "" : inspectResult.trim().toUpperCase();
if (!"PASS".equals(s) && !"FAIL".equals(s)) {
throw new IllegalArgumentException("ERR-RCV-400: inspectResult 은 PASS/FAIL");
}
cur.setInspectResult(s);
mapper.update(cur);
audit.log(actor, "RECEIVING_INSPECT", cur.getReceivingNo(), "result=" + s);
return mapper.findById(id);
}
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.mro.receiving.mapper;
import com.zioinfo.mro.receiving.MroReceiving;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ReceivingMapper {
List<MroReceiving> findAll(@Param("poNo") String poNo,
@Param("inspectResult") String inspectResult);
MroReceiving findById(@Param("id") Long id);
int countByNo(@Param("receivingNo") String receivingNo);
int insert(MroReceiving r);
int update(MroReceiving r);
}

View File

@ -0,0 +1,34 @@
package com.zioinfo.mro.reliability;
import com.zioinfo.mro.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 신뢰성 지표(MTBF/MTTR/가동률) API 조회 전용(GET, Viewer+). 순수 매퍼 집계 + 자바 계산.
*/
@RestController
@RequestMapping("/api/mro/reliability")
@RequiredArgsConstructor
public class ReliabilityController {
private final ReliabilityService service;
/**
* 설비별 신뢰성 지표 목록/단건.
* @param equipmentCode 지정 해당 설비만
* @param fromDate 기간 시작(yyyy-MM-dd), 미지정 최근 30일 가정
* @param toDate 기간 종료(yyyy-MM-dd)
*/
@GetMapping
public ApiResponse<List<ReliabilityDto>> metrics(@RequestParam(required = false) String equipmentCode,
@RequestParam(required = false) String fromDate,
@RequestParam(required = false) String toDate) {
return ApiResponse.ok(service.metrics(equipmentCode, fromDate, toDate));
}
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.mro.reliability;
import lombok.Getter;
import lombok.Setter;
/**
* 설비 신뢰성 지표 결과. MTBF/MTTR/가동률은 서비스에서 원자료(매퍼 집계) 계산.
*/
@Getter
@Setter
public class ReliabilityDto {
private String equipmentCode;
private long failureCount;
private double mtbfHours;
private double mttrHours;
private double availability;
private double totalDowntimeMin;
}

Some files were not shown because too many files have changed in this diff Show More