feat(mes): GUARDiA MES v1.0 WMS/MES/QMS

This commit is contained in:
GUARDiA 2026-06-14 12:35:18 +09:00
commit a8e0d6ecf1
225 changed files with 17867 additions and 0 deletions

59
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,59 @@
// GUARDiA MES — CI/CD Pipeline (Jenkins, 보조)
// 주 배포는 Gitea webhook → deploy_server.py(9999). Jenkins는 검증/롤백 백업 경로.
// 저장소 구조: backend/, frontend/ 가 루트에 위치 (단일 jar — frontend가 backend static으로 번들됨)
pipeline {
agent any
environment {
MES_HOME = '/opt/guardia-mes'
JAVA_HOME = '/usr/lib/jvm/java-21-openjdk-amd64' // 서버 JDK21 (Java17 타겟 호환 빌드)
PATH = "${JAVA_HOME}/bin:${env.PATH}"
}
stages {
stage('Checkout') { steps { checkout scm } }
// frontend 먼저 빌드 → vite outDir(../backend/src/main/resources/static)에 산출 → jar에 번들
stage('Frontend Build') {
steps {
dir('frontend') {
sh 'npm ci --silent 2>/dev/null || npm install --silent'
sh 'npm run build'
}
}
}
stage('Backend Build & Test') {
steps {
dir('backend') {
sh 'mvn clean package -DskipTests -q'
}
}
}
stage('Deploy') {
steps {
sh '''
sudo systemctl stop guardia-mes 2>/dev/null || true
sudo mkdir -p ${MES_HOME}
sudo cp backend/target/guardia-mes-*.jar ${MES_HOME}/app.jar
sudo systemctl start guardia-mes
'''
}
}
stage('Health Check') {
steps {
retry(5) {
sleep 8
sh 'curl -sf http://localhost:8013/actuator/health | grep -q "UP"'
}
}
}
}
post {
success { echo 'GUARDiA MES 배포 성공 (포트 8013)' }
failure {
sh 'sudo systemctl stop guardia-mes 2>/dev/null || true'
echo 'GUARDiA MES 배포 실패 — 서비스 중지(롤백)'
}
}
}

5
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
target/
*.class
.idea/
*.iml
.DS_Store

82
backend/pom.xml Normal file
View File

@ -0,0 +1,82 @@
<?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-mes</artifactId>
<version>1.0.0</version>
<name>GUARDiA MES</name>
<description>AI 기반 제조실행시스템 (WMS+MES+QMS 통합) — Ollama 온프레미스 AI + ERP/ITSM/OCR/BI 연계</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>
</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>
<!-- 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-mes-${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,26 @@
package com.zioinfo.mes;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* GUARDiA MES AI 기반 제조실행시스템 (WMS + MES + QMS 통합).
*
* <p>MES(작업지시생산실적LOT추적·OEE), WMS(입고·출고·재고·실사·LOT/시리얼),
* QMS(IQC/IPQC/OQC 검사·NCR·CAPA·SPC·성적서), 기준정보(품목·BOM·라우팅·설비·거래처·창고)
* 단일 플랫폼에서 관리한다.
*
* <p>보안 불변 규칙: 외부 AI API 절대 금지(Ollama localhost:11434만 + Java 폴백),
* 거래처/인사 PII는 AES-256-GCM 암호화·마스킹, ITSM/연계 응답은 자격증명 새니타이즈,
* 스택트레이스 미노출(에러 코드만).
*/
@SpringBootApplication
@EnableScheduling
@EnableAsync
public class MesApplication {
public static void main(String[] args) {
SpringApplication.run(MesApplication.class, args);
}
}

View File

@ -0,0 +1,96 @@
package com.zioinfo.mes.admin;
import com.zioinfo.mes.admin.dto.AuditLog;
import com.zioinfo.mes.admin.dto.MesSetting;
import com.zioinfo.mes.admin.dto.UserDto;
import com.zioinfo.mes.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* GUARDiA MES 관리자 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<MesSetting>> settings() {
return ApiResponse.ok(settingService.list());
}
@PutMapping("/settings/{key}")
public ApiResponse<MesSetting> 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.mes.admin;
import com.zioinfo.mes.admin.dto.UserDto;
import com.zioinfo.mes.admin.mapper.AdminUserMapper;
import com.zioinfo.mes.auth.MesUser;
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");
}
MesUser user = new MesUser();
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) {
MesUser 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) {
MesUser 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 필수");
}
MesUser 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) {
MesUser 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 MesUser require(Long id) {
MesUser 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.mes.admin;
import com.zioinfo.mes.admin.dto.AuditLog;
import com.zioinfo.mes.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;
/**
* 감사 로그 서비스 작업지시 전이·실적·검사 판정·관리자 작업 주요 변경을 mes_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.mes.admin;
import com.zioinfo.mes.admin.dto.MesSetting;
import com.zioinfo.mes.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<MesSetting> list() {
return mapper.findAll();
}
public MesSetting 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) {
MesSetting s = mapper.findByKey(key);
return (s == null || s.getValue() == null) ? defaultValue : s.getValue();
}
}

View File

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

View File

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

View File

@ -0,0 +1,28 @@
package com.zioinfo.mes.admin.dto;
import com.zioinfo.mes.auth.MesUser;
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 role;
private boolean active;
private LocalDateTime createdAt;
public static UserDto from(MesUser u) {
UserDto d = new UserDto();
d.id = u.getId();
d.username = u.getUsername();
d.displayName = u.getDisplayName();
d.role = u.getRole();
d.active = u.isActive();
d.createdAt = u.getCreatedAt();
return d;
}
}

View File

@ -0,0 +1,31 @@
package com.zioinfo.mes.admin.mapper;
import com.zioinfo.mes.auth.MesUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/** 관리자 사용자 관리 매퍼 (mes_user). @Mapper 필수. */
@Mapper
public interface AdminUserMapper {
List<MesUser> findAll();
MesUser findById(@Param("id") Long id);
int insert(MesUser 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.mes.admin.mapper;
import com.zioinfo.mes.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.mes.admin.mapper;
import com.zioinfo.mes.admin.dto.MesSetting;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface SettingMapper {
List<MesSetting> findAll();
MesSetting findByKey(@Param("key") String key);
int upsert(@Param("key") String key, @Param("value") String value);
}

View File

@ -0,0 +1,78 @@
package com.zioinfo.mes.ai;
import com.zioinfo.mes.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* GUARDiA MES AI 도구 API 8개 제조 AI(전부 Ollama + Java 폴백).
*
* <p>RBAC: 조회/분석 Viewer+ (GET), 분석 트리거 Worker+ (POST).
*/
@RestController
@RequestMapping("/api/mes/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("/defect-root-cause")
public ApiResponse<Map<String, Object>> defectRootCause(@RequestBody DefectRequest req) {
return ApiResponse.ok(ai.defectRootCause(req.defectCode(), req.context()));
}
@PostMapping("/forecast")
public ApiResponse<Map<String, Object>> forecast(@RequestBody ForecastRequest req) {
return ApiResponse.ok(ai.forecast(req.series(), req.horizon()));
}
@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("/spc-anomaly")
public ApiResponse<Map<String, Object>> spc(@RequestBody SpcRequest req) {
return ApiResponse.ok(ai.spcAnomaly(req.values(), req.ucl(), req.lcl(), req.cl()));
}
@PostMapping("/parse-query")
public ApiResponse<Map<String, Object>> parseQuery(@RequestBody QueryRequest req) {
return ApiResponse.ok(ai.parseQuery(req.query()));
}
@PostMapping("/inspection-judge")
public ApiResponse<Map<String, Object>> inspectionJudge(@RequestBody JudgeRequest req) {
return ApiResponse.ok(ai.inspectionJudge(req.measured(), req.lsl(), req.usl(), req.target()));
}
@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("/schedule-optimize")
public ApiResponse<List<Map<String, Object>>> scheduleOptimize(@RequestBody ScheduleRequest req) {
return ApiResponse.ok(ai.scheduleOptimize(req.orders()));
}
// request DTO
record DefectRequest(String defectCode, List<Map<String, Object>> context) {}
record ForecastRequest(List<Double> series, int horizon) {}
record PdmRequest(String equipmentCode, double availability, int downtimeCount, double mtbfHours) {}
record SpcRequest(List<Double> values, double ucl, double lcl, double cl) {}
record QueryRequest(String query) {}
record JudgeRequest(double measured, Double lsl, Double usl, Double target) {}
record SafetyStockRequest(double avgDailyDemand, double demandStdDev, double leadTimeDays, double serviceZ) {}
record ScheduleRequest(List<Map<String, Object>> orders) {}
}

View File

@ -0,0 +1,258 @@
package com.zioinfo.mes.ai;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* GUARDiA MES AI 서비스 8개 제조 AI 기능, 전부 Ollama 온프레미스 + Java 폴백.
*
* <p>보안 불변 규칙: 외부 AI API 금지. Ollama 미가용 모든 메서드가 규칙기반/통계 폴백으로 동작한다.
* <ol>
* <li>불량 원인 분석 공정/설비/자재 상관 추정 원인</li>
* <li>수요·생산량 예측 실적/계획 추세(이동평균 폴백)</li>
* <li>설비 예지보전 가동/비가동 패턴 이상 정비 권고</li>
* <li>SPC 이상 감지 관리한계 이탈·연속런·추세</li>
* <li>작업지시/재고 자연어 조회 Text필터</li>
* <li>검사 판정 보조 측정값 vs 스펙 자동 판정</li>
* <li>재고 최적화·안전재고 추천</li>
* <li>생산 일정 최적화 제안</li>
* </ol>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AiService {
private final OllamaClient ollama;
public boolean ollamaAvailable() {
return ollama.available();
}
// 1. 불량 원인 분석 (공정/설비/자재 상관)
public Map<String, Object> defectRootCause(String defectCode, List<Map<String, Object>> context) {
Map<String, Object> result = new LinkedHashMap<>();
String ctx = context == null ? "" : truncate(context.toString(), 1200);
String prompt = String.format(
"당신은 제조 품질 엔지니어입니다. 불량코드 '%s' 와 관련 공정/설비/자재 데이터: %s. "
+ "가장 가능성 높은 추정 원인 1가지와 권고 조치를 'CAUSE: ...\\nACTION: ...' 형식 한국어로 출력.",
defectCode, ctx);
String out = ollama.generate(prompt);
if (out != null && !out.isBlank()) {
result.put("cause", firstNonBlank(extractLine(out, "CAUSE:"), out));
result.put("action", extractLine(out, "ACTION:"));
result.put("source", "ollama");
return result;
}
// Java 폴백: 불량코드 분류 규칙
String code = defectCode == null ? "" : defectCode.toUpperCase();
String cause; String action;
if (code.contains("DIM") || code.contains("치수")) {
cause = "설비 공구 마모 또는 셋업 오차(치수 불량)"; action = "공구 교체 점검 + 초도품 재검사";
} else if (code.contains("SCR") || code.contains("스크래치") || code.contains("외관")) {
cause = "이송/포장 공정 표면 손상(외관 불량)"; action = "이송 지그 보호재 점검";
} else if (code.contains("MAT") || code.contains("자재")) {
cause = "수입자재 품질 편차(IQC 연계)"; action = "해당 LOT 입고검사 이력 추적 + 공급사 통보";
} else {
cause = "공정 변동(상관 데이터 부족 — 추가 수집 필요)"; action = "SPC 관리도 확인 + 4M 변경점 점검";
}
result.put("cause", cause);
result.put("action", action);
result.put("source", "fallback");
return result;
}
// 2. 수요·생산량 예측 (이동평균/선형추세 폴백)
public Map<String, Object> forecast(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;
}
// 3. 설비 예지보전 (가동/비가동 이상 정비 권고)
public Map<String, Object> predictiveMaintenance(String equipmentCode, double availability,
int downtimeCount, double mtbfHours) {
Map<String, Object> result = new LinkedHashMap<>();
double risk;
// 규칙: 가동률 낮고 비가동 빈번하며 MTBF 짧을수록 위험
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 -> "정상 — 정기 점검 유지.";
};
result.put("equipmentCode", equipmentCode);
result.put("risk", round2(risk));
result.put("level", level);
result.put("recommendation", recommend);
result.put("source", "fallback");
return result;
}
// 4. SPC 이상 감지 (관리한계 이탈·연속런·추세 Western Electric 일부 규칙)
public Map<String, Object> spcAnomaly(List<Double> values, double ucl, double lcl, double cl) {
Map<String, Object> result = new LinkedHashMap<>();
List<String> violations = new ArrayList<>();
if (values != null && !values.isEmpty()) {
// 규칙1: 관리한계 이탈
for (int i = 0; i < values.size(); i++) {
double v = values.get(i);
if (v > ucl || v < lcl) violations.add("RULE1: idx" + i + " 관리한계 이탈(" + round2(v) + ")");
}
// 규칙2: 연속 9점이 중심선 한쪽
int run = 0; boolean above = false;
for (int i = 0; i < values.size(); i++) {
boolean a = values.get(i) > cl;
if (i == 0 || a == above) { run++; } else { run = 1; }
above = a;
if (run >= 9) { violations.add("RULE2: 연속 9점 중심선 " + (above ? "" : "아래")); run = 0; }
}
// 규칙3: 연속 6점 증가/감소 추세
int inc = 1, dec = 1;
for (int i = 1; i < values.size(); i++) {
if (values.get(i) > values.get(i - 1)) { inc++; dec = 1; } else if (values.get(i) < values.get(i - 1)) { dec++; inc = 1; } else { inc = 1; dec = 1; }
if (inc >= 6) { violations.add("RULE3: 연속 6점 증가 추세"); inc = 1; }
if (dec >= 6) { violations.add("RULE3: 연속 6점 감소 추세"); dec = 1; }
}
}
result.put("anomaly", !violations.isEmpty());
result.put("violations", violations);
result.put("source", "fallback");
return result;
}
// 5. 작업지시/재고 자연어 조회 필터 추출
public Map<String, Object> parseQuery(String naturalQuery) {
Map<String, Object> filter = new LinkedHashMap<>();
if (naturalQuery == null || naturalQuery.isBlank()) return filter;
String prompt = "다음 질의에서 검색 필터를 'STATUS:..\\nITEM:..\\nDATE:..' 형식으로만 추출:\\n" + naturalQuery;
String out = ollama.generate(prompt);
if (out != null && !out.isBlank()) {
putIf(filter, "status", extractLine(out, "STATUS:"));
putIf(filter, "item", extractLine(out, "ITEM:"));
putIf(filter, "date", extractLine(out, "DATE:"));
if (!filter.isEmpty()) { filter.put("source", "ollama"); return filter; }
}
// Java 폴백: 키워드 매칭
String q = naturalQuery.toLowerCase();
if (q.contains("진행") || q.contains("inprogress")) filter.put("status", "INPROGRESS");
else if (q.contains("완료") || q.contains("done")) filter.put("status", "DONE");
else if (q.contains("대기") || q.contains("created")) filter.put("status", "CREATED");
if (q.contains("부족") || q.contains("안전재고")) filter.put("belowSafety", true);
filter.put("source", "fallback");
return filter;
}
// 6. 검사 판정 보조 (측정값 vs 스펙)
public Map<String, Object> inspectionJudge(double measured, Double lsl, Double usl, Double target) {
Map<String, Object> result = new LinkedHashMap<>();
boolean pass = true;
StringBuilder reason = new StringBuilder();
if (lsl != null && measured < lsl) { pass = false; reason.append("하한(").append(lsl).append(") 미달; "); }
if (usl != null && measured > usl) { pass = false; reason.append("상한(").append(usl).append(") 초과; "); }
double deviation = target != null ? round2(measured - target) : 0.0;
result.put("judgement", pass ? "PASS" : "FAIL");
result.put("measured", measured);
result.put("deviation", deviation);
result.put("reason", reason.length() == 0 ? "규격 내" : reason.toString().trim());
result.put("source", "fallback");
return result;
}
// 7. 재고 최적화·안전재고 추천 (수요 변동 기반)
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;
}
// 8. 생산 일정 최적화 제안 (납기·셋업 기준 단순 우선순위)
public List<Map<String, Object>> scheduleOptimize(List<Map<String, Object>> orders) {
if (orders == null || orders.isEmpty()) return List.of();
// EDD(Earliest Due Date) + 동일품목 셋업 묶기 폴백
List<Map<String, Object>> sorted = new ArrayList<>(orders);
sorted.sort((a, b) -> {
String d1 = String.valueOf(a.getOrDefault("dueDate", "9999-12-31"));
String d2 = String.valueOf(b.getOrDefault("dueDate", "9999-12-31"));
int c = d1.compareTo(d2);
if (c != 0) return c;
return String.valueOf(a.getOrDefault("itemCode", "")).compareTo(String.valueOf(b.getOrDefault("itemCode", "")));
});
int seq = 1;
for (Map<String, Object> o : sorted) {
o.put("suggestedSeq", seq++);
}
return sorted;
}
// 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;
}
@SuppressWarnings("unused")
private List<String> tokenize(String s) {
return Arrays.stream(s.split("[\\s,.]+")).filter(w -> w.length() > 1).toList();
}
}

View File

@ -0,0 +1,94 @@
package com.zioinfo.mes.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}") String textModel,
@Value("${guardia.ollama-vision-model:llava}") String visionModel) {
this.builder = builder;
this.ollamaUrl = ollamaUrl;
this.textModel = textModel;
this.visionModel = visionModel;
}
/** 프롬프트로 텍스트 생성. 실패 시 빈 문자열 반환(예외 없음). */
@SuppressWarnings("unchecked")
public String generate(String prompt) {
try {
Map<String, Object> body = Map.of("model", textModel, "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(30))
.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 "";
}
}
/** llava 비전 모델로 이미지(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(45))
.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,64 @@
package com.zioinfo.mes.analytics;
import com.zioinfo.mes.analytics.mapper.AnalyticsMapper;
import com.zioinfo.mes.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 분석/KPI API (생산·품질·재고) BI 피드 겸용. 조회 Viewer+.
*/
@RestController
@RequestMapping("/api/mes/analytics")
@RequiredArgsConstructor
public class AnalyticsController {
private final AnalyticsMapper mapper;
/** 생산 KPI. */
@GetMapping("/production-kpi")
public ApiResponse<Map<String, Object>> productionKpi(@RequestParam(defaultValue = "30") int days) {
return ApiResponse.ok(mapper.productionKpi(days));
}
/** 품질 KPI. */
@GetMapping("/quality-kpi")
public ApiResponse<Map<String, Object>> qualityKpi(@RequestParam(defaultValue = "30") int days) {
return ApiResponse.ok(mapper.qualityKpi(days));
}
/** 재고 KPI. */
@GetMapping("/inventory-kpi")
public ApiResponse<Map<String, Object>> inventoryKpi() {
return ApiResponse.ok(mapper.inventoryKpi());
}
/** 일자별 생산 추이. */
@GetMapping("/daily-production")
public ApiResponse<List<Map<String, Object>>> dailyProduction(@RequestParam(defaultValue = "14") int days) {
return ApiResponse.ok(mapper.dailyProduction(days));
}
/** 품목별 생산량 Top. */
@GetMapping("/top-items")
public ApiResponse<List<Map<String, Object>>> topItems(@RequestParam(defaultValue = "30") int days,
@RequestParam(defaultValue = "5") int limit) {
return ApiResponse.ok(mapper.topItems(days, limit));
}
/** BI 피드 — 생산·품질·재고 KPI 통합(GUARDiA BI 연동용). */
@GetMapping("/bi-feed")
public ApiResponse<Map<String, Object>> biFeed(@RequestParam(defaultValue = "30") int days) {
Map<String, Object> feed = new LinkedHashMap<>();
feed.put("production", mapper.productionKpi(days));
feed.put("quality", mapper.qualityKpi(days));
feed.put("inventory", mapper.inventoryKpi());
feed.put("dailyProduction", mapper.dailyProduction(days));
feed.put("topItems", mapper.topItems(days, 10));
return ApiResponse.ok(feed);
}
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.mes.analytics.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* 분석/KPI 매퍼 생산·품질·재고 KPI (BI 피드용, 읽기 전용).
*/
@Mapper
public interface AnalyticsMapper {
/** 생산 KPI — 기간 생산량·불량률·작업지시 완료율. */
Map<String, Object> productionKpi(@Param("days") int days);
/** 품질 KPI — 검사 합격률·NCR·CAPA. */
Map<String, Object> qualityKpi(@Param("days") int days);
/** 재고 KPI — 품목수·총수량·안전재고 미달수. */
Map<String, Object> inventoryKpi();
/** 일자별 생산 추이. */
List<Map<String, Object>> dailyProduction(@Param("days") int days);
/** 품목별 생산량 Top. */
List<Map<String, Object>> topItems(@Param("days") int days, @Param("limit") int limit);
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.mes.auth;
import com.zioinfo.mes.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/mes/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
String token = authService.login(req.username(), req.password());
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
}
@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,40 @@
package com.zioinfo.mes.auth;
import com.zioinfo.mes.auth.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
public String login(String username, String password) {
MesUser 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: 비밀번호 불일치");
}
return jwtUtil.generate(username, user.getRole());
}
public Map<String, Object> me(String token) {
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
MesUser 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.mes.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.mes.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-mes-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,20 @@
package com.zioinfo.mes.auth;
import lombok.Data;
import java.time.LocalDateTime;
/**
* MES 운영자 계정 (mes_user 테이블).
*
* <p>role: SUPERADMIN / MANAGER / WORKER / VIEWER.
*/
@Data
public class MesUser {
private Long id;
private String username;
private String passwordHash;
private String displayName;
private String role;
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,13 @@
package com.zioinfo.mes.auth.mapper;
import com.zioinfo.mes.auth.MesUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserMapper {
MesUser findByUsername(@Param("username") String username);
int insert(MesUser user);
}

View File

@ -0,0 +1,92 @@
package com.zioinfo.mes.barcode;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.common.AuthSupport;
import com.zioinfo.mes.inventory.mapper.InventoryMapper;
import com.zioinfo.mes.item.mapper.ItemMapper;
import com.zioinfo.mes.lotserial.mapper.LotSerialMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 바코드/QR API 발번·검증·스캔 해석. 모바일 현장 스캔(입출고/이동/조회)에서 사용.
*
* <p>코드 규칙: {@code TYPE:VALUE} ( ITEM:FG-1001, LOT:LOT-001, LOC:WH-MAIN/A-01-01).
* 조회 Viewer+, 발번 Worker+.
*/
@RestController
@RequestMapping("/api/mes/barcode")
@RequiredArgsConstructor
public class BarcodeController {
private final ItemMapper itemMapper;
private final LotSerialMapper lotMapper;
private final InventoryMapper inventoryMapper;
/** 바코드 발번 — TYPE:VALUE 인코딩 문자열 반환. */
@PostMapping("/generate")
public ApiResponse<Map<String, Object>> generate(@RequestBody GenerateRequest req, Authentication auth) {
AuthSupport.actor(auth);
String type = req.type() == null ? "ITEM" : req.type().toUpperCase();
if (req.value() == null || req.value().isBlank())
throw new IllegalArgumentException("ERR-BC-400: value 필수");
String code = type + ":" + req.value();
Map<String, Object> m = new LinkedHashMap<>();
m.put("type", type);
m.put("value", req.value());
m.put("code", code);
return ApiResponse.ok(m);
}
/**
* 스캔 해석 TYPE:VALUE 파싱하고 대상 엔티티를 조회한다.
* 모바일 입출고/이동/조회 진입점.
*/
@GetMapping("/resolve")
public ApiResponse<Map<String, Object>> resolve(@RequestParam String code) {
Map<String, Object> m = new LinkedHashMap<>();
if (code == null || code.isBlank())
throw new IllegalArgumentException("ERR-BC-400: code 필수");
String type, value;
int idx = code.indexOf(':');
if (idx > 0) {
type = code.substring(0, idx).toUpperCase();
value = code.substring(idx + 1);
} else {
// 접두사 없으면 품목 바코드/코드로 추정
type = "ITEM";
value = code;
}
m.put("type", type);
m.put("value", value);
switch (type) {
case "ITEM" -> {
var item = itemMapper.findByCode(value);
if (item == null) item = itemMapper.findAll(null, null, value).stream().findFirst().orElse(null);
m.put("found", item != null);
m.put("item", item);
if (item != null) m.put("stock", inventoryMapper.sumQty(item.getItemCode()));
}
case "LOT" -> {
var lot = lotMapper.findByLot(value);
m.put("found", lot != null);
m.put("lot", lot);
}
case "LOC" -> {
m.put("found", true);
m.put("location", value);
}
default -> {
m.put("found", false);
m.put("message", "알 수 없는 코드 유형");
}
}
return ApiResponse.ok(m);
}
record GenerateRequest(String type, String value) {}
}

View File

@ -0,0 +1,67 @@
package com.zioinfo.mes.bom;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.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;
/**
* BOM API 구성 CRUD + 트리 전개 + 소요량(MRP). 조회 Viewer+, 변경 Manager+.
*/
@RestController
@RequestMapping("/api/mes/bom")
@RequiredArgsConstructor
public class BomController {
private final BomService service;
@GetMapping
public ApiResponse<List<MesBom>> list(@RequestParam(required = false) String status) {
return ApiResponse.ok(service.listAll(status));
}
@GetMapping("/parent/{parentItemCode}")
public ApiResponse<List<MesBom>> byParent(@PathVariable String parentItemCode,
@RequestParam(required = false) String bomVersion) {
return ApiResponse.ok(service.listByParent(parentItemCode, bomVersion));
}
/** 다단 BOM 트리 전개. */
@GetMapping("/explode/{parentItemCode}")
public ApiResponse<Map<String, Object>> explode(@PathVariable String parentItemCode,
@RequestParam(defaultValue = "1") double qty,
@RequestParam(required = false) String bomVersion) {
return ApiResponse.ok(service.explode(parentItemCode, qty, bomVersion));
}
/** 소요량 집계(MRP) — 리프 자재별 총 소요량. */
@GetMapping("/requirement/{parentItemCode}")
public ApiResponse<Map<String, Double>> requirement(@PathVariable String parentItemCode,
@RequestParam(defaultValue = "1") double qty,
@RequestParam(required = false) String bomVersion) {
return ApiResponse.ok(service.materialRequirement(parentItemCode, qty, bomVersion));
}
@PostMapping
public ApiResponse<MesBom> create(@RequestBody MesBom bom, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.create(bom, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MesBom> update(@PathVariable Long id, @RequestBody MesBom bom, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.update(id, bom, 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,120 @@
package com.zioinfo.mes.bom;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.bom.mapper.BomMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* BOM 서비스 구성 CRUD + 트리 전개 + 소요량(MRP) 계산.
*/
@Service
@RequiredArgsConstructor
public class BomService {
private static final int MAX_DEPTH = 20; // 순환 방지
private final BomMapper mapper;
private final AuditService audit;
public List<MesBom> listByParent(String parentItemCode, String bomVersion) {
return mapper.findByParent(parentItemCode, bomVersion);
}
public List<MesBom> listAll(String status) {
return mapper.findAll(status);
}
public MesBom create(MesBom bom, String actor) {
if (bom.getParentItemCode() == null || bom.getChildItemCode() == null)
throw new IllegalArgumentException("ERR-BOM-400: parent/child 품목코드 필수");
if (bom.getParentItemCode().equals(bom.getChildItemCode()))
throw new IllegalArgumentException("ERR-BOM-400: 자기 자신 구성 불가");
if (bom.getQtyPer() == null || bom.getQtyPer() <= 0) bom.setQtyPer(1.0);
if (bom.getStatus() == null) bom.setStatus("ACTIVE");
bom.setCreatedBy(actor);
mapper.insert(bom);
audit.log("BOM_CREATE", bom.getParentItemCode(), "child=" + bom.getChildItemCode());
return mapper.findById(bom.getId());
}
public MesBom update(Long id, MesBom bom, String actor) {
MesBom cur = require(id);
bom.setId(id);
mapper.update(bom);
audit.log("BOM_UPDATE", cur.getParentItemCode(), "id=" + id);
return mapper.findById(id);
}
public void delete(Long id) {
MesBom cur = require(id);
mapper.delete(id);
audit.log("BOM_DELETE", cur.getParentItemCode(), "id=" + id);
}
/** 트리 전개 — 부모 품목에서 시작하는 다단 BOM 구조. */
public Map<String, Object> explode(String parentItemCode, double qty, String bomVersion) {
return node(parentItemCode, qty, bomVersion, 0, new ArrayList<>());
}
private Map<String, Object> node(String itemCode, double qty, String version, int depth, List<String> path) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("itemCode", itemCode);
m.put("requiredQty", round3(qty));
m.put("depth", depth);
if (depth >= MAX_DEPTH || path.contains(itemCode)) {
m.put("children", List.of());
if (path.contains(itemCode)) m.put("warning", "순환 BOM 감지");
return m;
}
List<String> newPath = new ArrayList<>(path);
newPath.add(itemCode);
List<MesBom> comps = mapper.findByParent(itemCode, version);
List<Map<String, Object>> children = new ArrayList<>();
for (MesBom c : comps) {
double loss = c.getLossRate() == null ? 0 : c.getLossRate();
double childQty = qty * c.getQtyPer() * (1 + loss);
children.add(node(c.getChildItemCode(), childQty, version, depth + 1, newPath));
}
m.put("children", children);
return m;
}
/** 소요량 집계(MRP) — 리프 자재별 총 소요량 평탄화. */
public Map<String, Double> materialRequirement(String parentItemCode, double qty, String version) {
Map<String, Double> agg = new LinkedHashMap<>();
accumulate(parentItemCode, qty, version, 0, new ArrayList<>(), agg);
return agg;
}
private void accumulate(String itemCode, double qty, String version, int depth,
List<String> path, Map<String, Double> agg) {
if (depth >= MAX_DEPTH || path.contains(itemCode)) return;
List<MesBom> comps = mapper.findByParent(itemCode, version);
if (comps.isEmpty()) {
if (depth > 0) agg.merge(itemCode, round3(qty), Double::sum); // 리프 자재
return;
}
List<String> newPath = new ArrayList<>(path);
newPath.add(itemCode);
for (MesBom c : comps) {
double loss = c.getLossRate() == null ? 0 : c.getLossRate();
double childQty = qty * c.getQtyPer() * (1 + loss);
accumulate(c.getChildItemCode(), childQty, version, depth + 1, newPath, agg);
}
}
private MesBom require(Long id) {
MesBom b = mapper.findById(id);
if (b == null) throw new RuntimeException("ERR-BOM-404: BOM 없음");
return b;
}
private double round3(double d) {
return Math.round(d * 1000.0) / 1000.0;
}
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.mes.bom;
import lombok.Data;
import java.time.LocalDateTime;
/**
* BOM 구성 (mes_bom) 부모 품목(parentItemCode) 대한 자재 구성 라인.
*
* <p>소요량(qtyPer): 부모 1단위 생산에 필요한 자식 수량. 트리는 parent/child 코드로 재귀 전개.
*/
@Data
public class MesBom {
private Long id;
private String parentItemCode;
private String childItemCode;
private Double qtyPer; // 소요량
private String unit;
private Integer seq;
private String bomVersion;
private Double lossRate; // 손실률(0~1)
private String status; // ACTIVE / INACTIVE
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.mes.bom.mapper;
import com.zioinfo.mes.bom.MesBom;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface BomMapper {
List<MesBom> findByParent(@Param("parentItemCode") String parentItemCode,
@Param("bomVersion") String bomVersion);
List<MesBom> findAll(@Param("status") String status);
MesBom findById(@Param("id") Long id);
int insert(MesBom bom);
int update(MesBom bom);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,74 @@
package com.zioinfo.mes.capa;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.capa.mapper.CapaMapper;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 시정·예방조치(CAPA) API (QMS). 조회 Viewer+, 등록/수정/상태전이 Worker+.
*/
@RestController
@RequestMapping("/api/mes/capa")
@RequiredArgsConstructor
public class CapaController {
private final CapaMapper mapper;
private final AuditService audit;
@GetMapping
public ApiResponse<List<MesCapa>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String capaType) {
return ApiResponse.ok(mapper.findAll(status, capaType));
}
@GetMapping("/{id}")
public ApiResponse<MesCapa> get(@PathVariable Long id) {
MesCapa c = mapper.findById(id);
if (c == null) throw new RuntimeException("ERR-CAPA-404: CAPA 없음");
return ApiResponse.ok(c);
}
@PostMapping
public ApiResponse<MesCapa> create(@RequestBody MesCapa c, Authentication auth) {
if (c.getCapaNo() == null || c.getCapaNo().isBlank())
c.setCapaNo("CAPA-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
+ "-" + (System.currentTimeMillis() % 100000));
if (c.getCapaType() == null) c.setCapaType("CORRECTIVE");
c.setStatus("OPEN");
c.setCreatedBy(AuthSupport.actor(auth));
mapper.insert(c);
audit.log("CAPA_CREATE", c.getCapaNo(), "ncr=" + c.getNcrNo());
return ApiResponse.ok(mapper.findById(c.getId()));
}
@PutMapping("/{id}")
public ApiResponse<MesCapa> update(@PathVariable Long id, @RequestBody MesCapa c, Authentication auth) {
MesCapa cur = mapper.findById(id);
if (cur == null) throw new RuntimeException("ERR-CAPA-404: CAPA 없음");
c.setId(id);
mapper.update(c);
audit.log("CAPA_UPDATE", cur.getCapaNo(), "id=" + id);
return ApiResponse.ok(mapper.findById(id));
}
/** 상태 전이(OPEN/INPROGRESS/VERIFY/CLOSED). CLOSE 시 effectiveness 권장. */
@PostMapping("/{id}/transition")
public ApiResponse<MesCapa> transition(@PathVariable Long id, @RequestBody TransitionRequest req,
Authentication auth) {
MesCapa cur = mapper.findById(id);
if (cur == null) throw new RuntimeException("ERR-CAPA-404: CAPA 없음");
mapper.updateStatus(id, req.status(), req.effectiveness());
audit.log("CAPA_STATUS", cur.getCapaNo(), cur.getStatus() + " -> " + req.status());
return ApiResponse.ok(mapper.findById(id));
}
record TransitionRequest(String status, String effectiveness) {}
}

View File

@ -0,0 +1,28 @@
package com.zioinfo.mes.capa;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 시정·예방조치 (mes_capa) Corrective And Preventive Action(8D).
*
* <p>status: OPEN INPROGRESS VERIFY CLOSED. capaType: CORRECTIVE/PREVENTIVE.
*/
@Data
public class MesCapa {
private Long id;
private String capaNo;
private String ncrNo;
private String capaType; // CORRECTIVE/PREVENTIVE
private String title;
private String rootCause;
private String actionPlan; // 8D/시정조치 계획
private String owner;
private LocalDate dueDate;
private String effectiveness;
private String status; // OPEN/INPROGRESS/VERIFY/CLOSED
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.mes.capa.mapper;
import com.zioinfo.mes.capa.MesCapa;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface CapaMapper {
List<MesCapa> findAll(@Param("status") String status, @Param("capaType") String capaType);
MesCapa findById(@Param("id") Long id);
int insert(MesCapa c);
int update(MesCapa c);
int updateStatus(@Param("id") Long id, @Param("status") String status,
@Param("effectiveness") String effectiveness);
}

View File

@ -0,0 +1,55 @@
package com.zioinfo.mes.certificate;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.certificate.mapper.CertificateMapper;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
/**
* 성적서/MSDS API (QMS). 조회 Viewer+, 발행 Worker+.
*/
@RestController
@RequestMapping("/api/mes/certificates")
@RequiredArgsConstructor
public class CertificateController {
private final CertificateMapper mapper;
private final AuditService audit;
@GetMapping
public ApiResponse<List<MesCertificate>> list(@RequestParam(required = false) String certType,
@RequestParam(required = false) String itemCode,
@RequestParam(required = false) String lotNo) {
return ApiResponse.ok(mapper.findAll(certType, itemCode, lotNo));
}
@GetMapping("/{id}")
public ApiResponse<MesCertificate> get(@PathVariable Long id) {
MesCertificate c = mapper.findById(id);
if (c == null) throw new RuntimeException("ERR-CERT-404: 성적서 없음");
return ApiResponse.ok(c);
}
@PostMapping
public ApiResponse<MesCertificate> create(@RequestBody MesCertificate c, Authentication auth) {
if (c.getCertType() == null) c.setCertType("COA");
if (c.getCertificateNo() == null || c.getCertificateNo().isBlank())
c.setCertificateNo(c.getCertType() + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
+ "-" + (System.currentTimeMillis() % 100000));
if (mapper.countByNo(c.getCertificateNo()) > 0)
throw new RuntimeException("ERR-CERT-409: 중복 성적서번호");
if (c.getIssuedDate() == null) c.setIssuedDate(LocalDate.now());
if (c.getStatus() == null) c.setStatus("ISSUED");
c.setCreatedBy(AuthSupport.actor(auth));
mapper.insert(c);
audit.log("CERTIFICATE_ISSUE", c.getCertificateNo(), "type=" + c.getCertType() + " item=" + c.getItemCode());
return ApiResponse.ok(mapper.findById(c.getId()));
}
}

View File

@ -0,0 +1,26 @@
package com.zioinfo.mes.certificate;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 성적서/MSDS (mes_certificate) COA(성적서)/MSDS/TEST_REPORT 발행·조회.
*/
@Data
public class MesCertificate {
private Long id;
private String certificateNo;
private String certType; // COA/MSDS/TEST_REPORT
private String itemCode;
private String lotNo;
private String partnerCode;
private String refType;
private String refNo;
private LocalDate issuedDate;
private String content; // JSONB 문자열(시험 항목/결과)
private String filePath;
private String status;
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.mes.certificate.mapper;
import com.zioinfo.mes.certificate.MesCertificate;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface CertificateMapper {
List<MesCertificate> findAll(@Param("certType") String certType,
@Param("itemCode") String itemCode,
@Param("lotNo") String lotNo);
MesCertificate findById(@Param("id") Long id);
int countByNo(@Param("certificateNo") String certificateNo);
int insert(MesCertificate c);
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.mes.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
@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.mes.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-MES-403: 기준정보/마감/승인은 MANAGER 이상만 가능합니다");
}
}
}

View File

@ -0,0 +1,93 @@
package com.zioinfo.mes.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-mes-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-MES-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,56 @@
package com.zioinfo.mes.common;
import lombok.extern.slf4j.Slf4j;
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-MES-413: 파일 크기 초과 (최대 20MB)");
}
@ExceptionHandler(AccessDeniedException.class)
@ResponseStatus(HttpStatus.FORBIDDEN)
public ApiResponse<Void> handleAccessDenied(AccessDeniedException e) {
log.warn("권한 거부: {}", e.getMessage());
return ApiResponse.fail("ERR-MES-403: 권한이 없습니다");
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleIllegalArg(IllegalArgumentException e) {
log.warn("잘못된 요청: {}", e.getMessage());
return ApiResponse.fail(e.getMessage());
}
@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,25 @@
package com.zioinfo.mes.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.mes.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 @Mapper} 인터페이스만 등록(annotationClass=Mapper.class).
* 모든 매퍼 인터페이스에 {@code @Mapper} 필수, XML namespace=FQN.
*/
@Configuration
@MapperScan(basePackages = "com.zioinfo.mes", 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,83 @@
package com.zioinfo.mes.config;
import com.zioinfo.mes.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 MES 보안 설정 JWT 무상태 인증 + RBAC.
*
* <p>RBAC 역할(상위 하위): SUPERADMIN MANAGER WORKER VIEWER.
* <ul>
* <li>auth/health/swagger/정적 permitAll</li>
* <li>조회(GET /api/mes/**) 인증 사용자 전체(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/mes/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/mes/docs/**", "/api/mes/swagger/**").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")
// 감사 로그 조회 SUPERADMIN/MANAGER
.requestMatchers("/api/admin/audit").hasAnyRole("SUPERADMIN", "MANAGER")
.requestMatchers("/api/admin/**").hasRole("SUPERADMIN")
// 변경(실적·검사·입출고·재고이동) WORKER 이상 (기준정보/마감/승인은 서비스에서 MANAGER+ 가드)
.requestMatchers(HttpMethod.POST, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")
.requestMatchers(HttpMethod.PUT, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")
.requestMatchers(HttpMethod.PATCH, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")
.requestMatchers(HttpMethod.DELETE, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER")
// 조회 인증 사용자 전체(Viewer+)
.requestMatchers(HttpMethod.GET, "/api/mes/**").hasAnyRole("SUPERADMIN", "MANAGER", "WORKER", "VIEWER")
.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.mes.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 MES UP";
}
}

View File

@ -0,0 +1,42 @@
package com.zioinfo.mes.dashboard;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.dashboard.mapper.DashboardMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* MES 대시보드 API 실시간 생산·재고·품질·설비·출하 요약(조회 Viewer+).
*/
@RestController
@RequestMapping("/api/mes/dashboard")
@RequiredArgsConstructor
public class DashboardController {
private final DashboardMapper mapper;
@GetMapping("/summary")
public ApiResponse<Map<String, Object>> summary() {
return ApiResponse.ok(mapper.summary());
}
@GetMapping("/workorder-status")
public ApiResponse<List<Map<String, Object>>> workorderStatus() {
return ApiResponse.ok(mapper.workorderByStatus());
}
@GetMapping("/shipping-status")
public ApiResponse<List<Map<String, Object>>> shippingStatus() {
return ApiResponse.ok(mapper.shippingByStatus());
}
@GetMapping("/equipment-status")
public ApiResponse<List<Map<String, Object>>> equipmentStatus() {
return ApiResponse.ok(mapper.equipmentRunStatus());
}
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.mes.dashboard.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
/**
* MES 대시보드 매퍼 실시간 생산·재고·품질·설비 요약(읽기 전용).
*/
@Mapper
public interface DashboardMapper {
Map<String, Object> summary();
List<Map<String, Object>> workorderByStatus();
List<Map<String, Object>> shippingByStatus();
List<Map<String, Object>> equipmentRunStatus();
}

View File

@ -0,0 +1,105 @@
package com.zioinfo.mes.equipment;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.ai.AiService;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.common.AuthSupport;
import com.zioinfo.mes.equipment.mapper.EquipmentMapper;
import com.zioinfo.mes.integration.ItsmClient;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 설비 마스터 API + 예지보전(AI) + 비가동ITSM SR 연계.
* 조회 Viewer+, 기준정보 변경 Manager+, 상태변경 Worker+.
*/
@RestController
@RequestMapping("/api/mes/equipment")
@RequiredArgsConstructor
public class EquipmentController {
private final EquipmentMapper mapper;
private final AuditService audit;
private final AiService ai;
private final ItsmClient itsm;
@GetMapping
public ApiResponse<List<MesEquipment>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String runStatus) {
return ApiResponse.ok(mapper.findAll(status, runStatus));
}
@GetMapping("/{id}")
public ApiResponse<MesEquipment> get(@PathVariable Long id) {
return ApiResponse.ok(require(id));
}
@PostMapping
public ApiResponse<MesEquipment> create(@RequestBody MesEquipment e, Authentication auth) {
AuthSupport.requireManager(auth);
if (e.getEquipmentCode() == null) throw new IllegalArgumentException("ERR-EQP-400: equipmentCode 필수");
if (mapper.countByCode(e.getEquipmentCode(), null) > 0)
throw new RuntimeException("ERR-EQP-409: 중복 설비코드");
if (e.getStatus() == null) e.setStatus("ACTIVE");
if (e.getRunStatus() == null) e.setRunStatus("IDLE");
e.setCreatedBy(AuthSupport.actor(auth));
mapper.insert(e);
audit.log("EQUIPMENT_CREATE", e.getEquipmentCode(), "");
return ApiResponse.ok(mapper.findById(e.getId()));
}
@PutMapping("/{id}")
public ApiResponse<MesEquipment> update(@PathVariable Long id, @RequestBody MesEquipment e, Authentication auth) {
AuthSupport.requireManager(auth);
require(id);
e.setId(id);
mapper.update(e);
audit.log("EQUIPMENT_UPDATE", e.getEquipmentCode(), "id=" + id);
return ApiResponse.ok(mapper.findById(id));
}
/** 가동상태 변경(RUN/IDLE/DOWN/MAINT) — DOWN 시 ITSM SR 자동 생성. Worker+. */
@PutMapping("/{id}/run-status")
public ApiResponse<MesEquipment> runStatus(@PathVariable Long id, @RequestBody RunStatusRequest req) {
MesEquipment e = require(id);
mapper.updateRunStatus(id, req.runStatus());
audit.log("EQUIPMENT_RUNSTATUS", e.getEquipmentCode(), "-> " + req.runStatus());
if ("DOWN".equalsIgnoreCase(req.runStatus())) {
String srId = itsm.createSr(
"[MES] 설비 비가동: " + e.getEquipmentName(),
"설비 " + e.getEquipmentCode() + " DOWN 발생. 사유: " + (req.reason() == null ? "미입력" : req.reason()),
"HIGH");
audit.log("EQUIPMENT_DOWN_SR", e.getEquipmentCode(), "srId=" + srId);
}
return ApiResponse.ok(mapper.findById(id));
}
/** 설비 예지보전 분석(AI). */
@PostMapping("/{id}/predictive-maintenance")
public ApiResponse<Map<String, Object>> pdm(@PathVariable Long id, @RequestBody PdmRequest req) {
MesEquipment e = require(id);
return ApiResponse.ok(ai.predictiveMaintenance(
e.getEquipmentCode(), req.availability(), req.downtimeCount(), req.mtbfHours()));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
mapper.delete(id);
audit.log("EQUIPMENT_DELETE", String.valueOf(id), "");
return ApiResponse.ok(null);
}
private MesEquipment require(Long id) {
MesEquipment e = mapper.findById(id);
if (e == null) throw new RuntimeException("ERR-EQP-404: 설비 없음");
return e;
}
record RunStatusRequest(String runStatus, String reason) {}
record PdmRequest(double availability, int downtimeCount, double mtbfHours) {}
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.mes.equipment;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 설비 마스터 (mes_equipment) 점검주기·파라미터(JSONB)·상태.
*/
@Data
public class MesEquipment {
private Long id;
private String equipmentCode;
private String equipmentName;
private String processCode;
private String warehouseCode; // 위치(라인/공장)
private Integer checkCycleDays; // 점검 주기()
private LocalDateTime lastCheckedAt;
private String parameters; // JSONB 가변 설비 파라미터
private String runStatus; // RUN / IDLE / DOWN / MAINT
private String status; // ACTIVE / INACTIVE
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

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

View File

@ -0,0 +1,63 @@
package com.zioinfo.mes.inspection;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.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 (QMS) IQC/IPQC/OQC. 조회 Viewer+, 등록/판정 Worker+.
*/
@RestController
@RequestMapping("/api/mes/inspections")
@RequiredArgsConstructor
public class InspectionController {
private final InspectionService service;
@GetMapping
public ApiResponse<List<MesInspection>> list(@RequestParam(required = false) String inspectionType,
@RequestParam(required = false) String result,
@RequestParam(required = false) String itemCode) {
return ApiResponse.ok(service.list(inspectionType, result, itemCode));
}
@GetMapping("/pass-rate")
public ApiResponse<List<Map<String, Object>>> passRate(@RequestParam(defaultValue = "30") int days) {
return ApiResponse.ok(service.passRate(days));
}
@GetMapping("/{id}")
public ApiResponse<MesInspection> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MesInspection> create(@RequestBody MesInspection i, Authentication auth) {
return ApiResponse.ok(service.create(i, AuthSupport.actor(auth)));
}
/** AI 판정 보조 — 측정값 vs 스펙 자동 판정(FAIL 시 NCR 자동 생성). */
@PostMapping("/{id}/judge")
public ApiResponse<Map<String, Object>> judge(@PathVariable Long id, @RequestBody JudgeRequest req,
Authentication auth) {
return ApiResponse.ok(service.judge(id, req.measured(), req.lsl(), req.usl(), req.target(),
req.defectQty(), req.defectCode(), AuthSupport.actor(auth)));
}
/** 수동 결과 입력(PASS/FAIL). FAIL 시 NCR 자동 생성. */
@PostMapping("/{id}/result")
public ApiResponse<MesInspection> setResult(@PathVariable Long id, @RequestBody ResultRequest req,
Authentication auth) {
return ApiResponse.ok(service.setResult(id, req.result(), req.defectQty(), req.defectCode(),
AuthSupport.actor(auth)));
}
record JudgeRequest(double measured, Double lsl, Double usl, Double target,
double defectQty, String defectCode) {}
record ResultRequest(String result, double defectQty, String defectCode) {}
}

View File

@ -0,0 +1,100 @@
package com.zioinfo.mes.inspection;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.ai.AiService;
import com.zioinfo.mes.inspection.mapper.InspectionMapper;
import com.zioinfo.mes.ncr.NcrService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
/**
* 검사 서비스 IQC/IPQC/OQC 검사 등록·판정·불합격 NCR 자동 생성.
*
* <p>판정(judge) {@link AiService#inspectionJudge}(Ollama+Java 폴백) 측정값 vs 스펙 자동 판정.
* FAIL {@link NcrService#createFromSource} 부적합을 자동 기록한다.
*/
@Service
@RequiredArgsConstructor
public class InspectionService {
private final InspectionMapper mapper;
private final AiService ai;
private final NcrService ncrService;
private final AuditService audit;
public List<MesInspection> list(String inspectionType, String result, String itemCode) {
return mapper.findAll(inspectionType, result, itemCode);
}
public MesInspection get(Long id) {
MesInspection i = mapper.findById(id);
if (i == null) throw new RuntimeException("ERR-INS-404: 검사 없음");
return i;
}
public List<Map<String, Object>> passRate(int days) {
return mapper.passRateByType(days);
}
@Transactional
public MesInspection create(MesInspection i, String actor) {
if (i.getItemCode() == null || i.getItemCode().isBlank())
throw new IllegalArgumentException("ERR-INS-400: itemCode 필수");
if (i.getInspectionType() == null) i.setInspectionType("IQC");
if (i.getInspectionNo() == null || i.getInspectionNo().isBlank())
i.setInspectionNo("INS-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
+ "-" + (System.currentTimeMillis() % 100000));
if (i.getResult() == null) i.setResult("PENDING");
if (i.getSampleSize() == null) i.setSampleSize(1);
i.setCreatedBy(actor);
mapper.insert(i);
audit.log("INSPECTION_CREATE", i.getInspectionNo(), "type=" + i.getInspectionType() + " item=" + i.getItemCode());
return mapper.findById(i.getId());
}
/**
* 검사 판정 측정값 vs 스펙. AI 판정 보조 호출.
* FAIL NCR 자동 생성.
*/
@Transactional
public Map<String, Object> judge(Long id, double measured, Double lsl, Double usl, Double target,
double defectQty, String defectCode, String actor) {
MesInspection i = get(id);
Map<String, Object> judgement = ai.inspectionJudge(measured, lsl, usl, target);
String result = String.valueOf(judgement.getOrDefault("judgement", "PASS"));
String measurements = "[{\"measured\":" + measured + ",\"lsl\":" + lsl + ",\"usl\":" + usl
+ ",\"judge\":\"" + result + "\"}]";
mapper.updateResult(id, result, measurements, defectQty);
audit.log("INSPECTION_JUDGE", i.getInspectionNo(), "result=" + result + " measured=" + measured);
if ("FAIL".equals(result)) {
String severity = "OQC".equals(i.getInspectionType()) ? "CRITICAL" : "MAJOR";
ncrService.createFromSource(i.getItemCode(), i.getLotNo(), "INSPECTION", i.getInspectionNo(),
defectCode == null ? "INSP-FAIL" : defectCode, defectQty, severity, actor);
judgement.put("ncrCreated", true);
}
return judgement;
}
/** 수동 판정 결과 입력(PASS/FAIL). FAIL 시 NCR 자동 생성. */
@Transactional
public MesInspection setResult(Long id, String result, double defectQty, String defectCode, String actor) {
MesInspection i = get(id);
String r = result == null ? "PASS" : result.toUpperCase();
if (!r.equals("PASS") && !r.equals("FAIL"))
throw new IllegalArgumentException("ERR-INS-400: 결과는 PASS/FAIL");
mapper.updateResult(id, r, null, defectQty);
audit.log("INSPECTION_RESULT", i.getInspectionNo(), "result=" + r);
if ("FAIL".equals(r)) {
String severity = "OQC".equals(i.getInspectionType()) ? "CRITICAL" : "MAJOR";
ncrService.createFromSource(i.getItemCode(), i.getLotNo(), "INSPECTION", i.getInspectionNo(),
defectCode == null ? "INSP-FAIL" : defectCode, defectQty, severity, actor);
}
return mapper.findById(id);
}
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.mes.inspection;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 검사 (mes_inspection) IQC(수입)/IPQC(공정)/OQC(출하) 검사 + 측정값(JSONB) + / 판정.
*
* <p>result: PENDING PASS / FAIL.
*/
@Data
public class MesInspection {
private Long id;
private String inspectionNo;
private String inspectionType; // IQC/IPQC/OQC
private String itemCode;
private String lotNo;
private String refType; // RECEIVING/WORKORDER/SHIPPING
private String refNo;
private String specCode;
private Integer sampleSize;
private String measurements; // JSONB 문자열 [{characteristic, measured, lsl, usl, judge}]
private String result; // PENDING/PASS/FAIL
private Double defectQty;
private String inspector;
private String remark;
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.mes.inspection.mapper;
import com.zioinfo.mes.inspection.MesInspection;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface InspectionMapper {
List<MesInspection> findAll(@Param("inspectionType") String inspectionType,
@Param("result") String result,
@Param("itemCode") String itemCode);
MesInspection findById(@Param("id") Long id);
int countByNo(@Param("inspectionNo") String inspectionNo);
int insert(MesInspection i);
int updateResult(@Param("id") Long id, @Param("result") String result,
@Param("measurements") String measurements, @Param("defectQty") Double defectQty);
/** 검사 합격률 요약(유형별). */
List<Map<String, Object>> passRateByType(@Param("days") int days);
}

View File

@ -0,0 +1,30 @@
package com.zioinfo.mes.integration;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* GUARDiA BI 연계 생산/품질/재고 KPI를 BI 데이터 피드로 push.
*/
@Component
@RequiredArgsConstructor
public class BiClient {
@Value("${guardia.bi-url:http://localhost:8006}")
private String biUrl;
private final GuardiaHttpClient http;
/** KPI 스냅샷을 BI 피드로 전송(연계 실패 시 무시). */
public Map<String, Object> pushKpiFeed(Map<String, Object> kpi) {
return http.callMap(biUrl, "/api/bi/feed/mes", HttpMethod.POST, kpi);
}
public boolean available() {
return !http.callMap(biUrl, "/actuator/health", HttpMethod.GET, null).isEmpty();
}
}

View File

@ -0,0 +1,38 @@
package com.zioinfo.mes.integration;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
/**
* GUARDiA ERP 연계 생산계획·BOM·MRP 자재소요·생산실적정산.
*
* <p>연계 실패 결과(폴백은 호출 ). 응답은 새니타이즈됨.
*/
@Component
@RequiredArgsConstructor
public class ErpClient {
@Value("${guardia.erp-url:http://localhost:8003}")
private String erpUrl;
private final GuardiaHttpClient http;
/** ERP 생산계획 조회(연계 실패 시 빈 리스트 → MES 자체 plan 사용). */
public List<Object> fetchProductionPlans() {
return http.callList(erpUrl, "/api/erp/production/plans", HttpMethod.GET, null);
}
/** 생산실적 → ERP 정산 피드(연계 실패 시 무시). */
public Map<String, Object> pushProductionResult(Map<String, Object> result) {
return http.callMap(erpUrl, "/api/erp/production/result", HttpMethod.POST, result);
}
public boolean available() {
return !http.callMap(erpUrl, "/actuator/health", HttpMethod.GET, null).isEmpty();
}
}

View File

@ -0,0 +1,68 @@
package com.zioinfo.mes.integration;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* GUARDiA 연계 솔루션(ERP/ITSM/OCR/BI) 공용 HTTP 클라이언트.
*
* <p>모든 응답은 {@link ItsmSecuritySanitizer#clean(Object)} 자격증명을 제거한 반환한다.
* 연계 실패 Map/List 반환(스택트레이스 미노출, 요약 로그만) 폴백을 호출 측이 수행한다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GuardiaHttpClient {
private final WebClient.Builder webClientBuilder;
@SuppressWarnings("unchecked")
public Map<String, Object> callMap(String baseUrl, String path, HttpMethod method, Object body) {
try {
WebClient.RequestBodySpec spec = webClientBuilder.baseUrl(baseUrl).build().method(method).uri(path);
WebClient.RequestHeadersSpec<?> headersSpec =
(body != null && method != HttpMethod.GET) ? spec.bodyValue(body) : spec;
Map<String, Object> result = headersSpec.retrieve()
.bodyToMono(Map.class)
.timeout(Duration.ofSeconds(10))
.map(m -> (Map<String, Object>) m)
.block();
if (result == null) {
return Collections.emptyMap();
}
return (Map<String, Object>) ItsmSecuritySanitizer.clean(result);
} catch (Exception e) {
log.warn("GUARDiA 연계 일시 실패 [{} {}{}]: {}", method, baseUrl, path, e.getMessage());
return Collections.emptyMap();
}
}
@SuppressWarnings("unchecked")
public List<Object> callList(String baseUrl, String path, HttpMethod method, Object body) {
try {
WebClient.RequestBodySpec spec = webClientBuilder.baseUrl(baseUrl).build().method(method).uri(path);
WebClient.RequestHeadersSpec<?> headersSpec =
(body != null && method != HttpMethod.GET) ? spec.bodyValue(body) : spec;
List<Object> result = headersSpec.retrieve()
.bodyToMono(List.class)
.timeout(Duration.ofSeconds(10))
.map(l -> (List<Object>) l)
.block();
if (result == null) {
return Collections.emptyList();
}
return (List<Object>) ItsmSecuritySanitizer.clean(result);
} catch (Exception e) {
log.warn("GUARDiA 연계 일시 실패 [{} {}{}]: {}", method, baseUrl, path, e.getMessage());
return Collections.emptyList();
}
}
}

View File

@ -0,0 +1,48 @@
package com.zioinfo.mes.integration;
import com.zioinfo.mes.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* GUARDiA 연계 상태/조회 API. 모든 응답은 자격증명 새니타이즈됨.
*
* <p>RBAC: 조회 Viewer+.
*/
@RestController
@RequestMapping("/api/mes/integration")
@RequiredArgsConstructor
public class IntegrationController {
private final ErpClient erp;
private final ItsmClient itsm;
private final OcrClient ocr;
private final BiClient bi;
/** 연계 솔루션 헬스 요약. */
@GetMapping("/status")
public ApiResponse<Map<String, Object>> status() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("erp", erp.available());
m.put("itsm", itsm.available());
m.put("ocr", ocr.available());
m.put("bi", bi.available());
return ApiResponse.ok(m);
}
/** ERP 생산계획 조회(연계). */
@GetMapping("/erp/plans")
public ApiResponse<List<Object>> erpPlans() {
return ApiResponse.ok(erp.fetchProductionPlans());
}
/** OCR 문서 파싱 결과 조회(연계). */
@GetMapping("/ocr/{docId}")
public ApiResponse<Map<String, Object>> ocrResult(@PathVariable String docId) {
return ApiResponse.ok(ocr.fetchParsed(docId));
}
}

View File

@ -0,0 +1,45 @@
package com.zioinfo.mes.integration;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* GUARDiA ITSM 연계 설비 장애/비가동을 ITSM SR로 자동 생성한다.
*
* <p>응답은 {@link GuardiaHttpClient} 자격증명을 새니타이즈한다.
*/
@Component
@RequiredArgsConstructor
public class ItsmClient {
@Value("${guardia.itsm-url:http://localhost:9001}")
private String itsmUrl;
private final GuardiaHttpClient http;
/**
* 설비 장애/비가동 ITSM SR 생성.
*
* @return 생성된 SR ID(연계 실패 null)
*/
public String createSr(String title, String content, String priority) {
Map<String, Object> payload = Map.of(
"title", title,
"content", content,
"priority", priority == null ? "MEDIUM" : priority,
"source", "MES"
);
Map<String, Object> result = http.callMap(itsmUrl, "/api/tasks", HttpMethod.POST, payload);
Object id = result.getOrDefault("sr_id", result.get("id"));
return id != null ? String.valueOf(id) : null;
}
public boolean available() {
Map<String, Object> r = http.callMap(itsmUrl, "/actuator/health", HttpMethod.GET, null);
return !r.isEmpty();
}
}

View File

@ -0,0 +1,62 @@
package com.zioinfo.mes.integration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* GUARDiA 연계 응답 보안 새니타이저.
*
* <p>보안 불변 규칙: 서버 자격증명(IP/SSH 계정/비밀번호 ) API 응답에 절대 노출하지 않는다.
* ITSM/ERP/OCR/BI 연계 응답을 MES로 반환하기 반드시 {@link #clean(Object)} 호출한다.
*
* <p>Map/List 구조를 재귀적으로 순회하며 민감 필드를 제거한다.
*/
public final class ItsmSecuritySanitizer {
private static final Set<String> SENSITIVE_KEYS = Set.of(
"ip_addr", "ipaddr", "ssh_user", "sshuser", "os_pw_enc", "ospwenc",
"password", "password_enc", "passwordenc", "ssh_key", "sshkey",
"secret", "token", "private_key", "credential"
);
private ItsmSecuritySanitizer() {
}
/**
* 응답 데이터에서 민감 필드를 재귀 제거한다.
*
* @param data Map(필터링), List( 원소 적용), (그대로 반환)
* @return 민감 필드가 제거된 데이터
*/
public static Object clean(Object data) {
if (data instanceof Map<?, ?> map) {
Map<String, Object> cleaned = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
String key = String.valueOf(entry.getKey());
if (isSensitive(key)) {
continue;
}
cleaned.put(key, clean(entry.getValue()));
}
return cleaned;
}
if (data instanceof List<?> list) {
List<Object> cleaned = new ArrayList<>(list.size());
for (Object item : list) {
cleaned.add(clean(item));
}
return cleaned;
}
return data;
}
private static boolean isSensitive(String key) {
if (key == null) {
return false;
}
return SENSITIVE_KEYS.contains(key.toLowerCase());
}
}

View File

@ -0,0 +1,30 @@
package com.zioinfo.mes.integration;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* GUARDiA OCR 연계 성적서·도면·납품서 파싱 결과를 품질/입고 등록 보조에 활용.
*/
@Component
@RequiredArgsConstructor
public class OcrClient {
@Value("${guardia.ocr-url:http://localhost:8005}")
private String ocrUrl;
private final GuardiaHttpClient http;
/** OCR 문서 파싱 결과 조회(docId 기준). 연계 실패 시 빈 Map. */
public Map<String, Object> fetchParsed(String docId) {
return http.callMap(ocrUrl, "/api/ocr/documents/" + docId + "/result", HttpMethod.GET, null);
}
public boolean available() {
return !http.callMap(ocrUrl, "/actuator/health", HttpMethod.GET, null).isEmpty();
}
}

View File

@ -0,0 +1,56 @@
package com.zioinfo.mes.inventory;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 재고 API 조회/이동(transfer)/조정/이력. 조회 Viewer+, 이동·조정 Worker+.
*/
@RestController
@RequestMapping("/api/mes/inventory")
@RequiredArgsConstructor
public class InventoryController {
private final InventoryService service;
@GetMapping
public ApiResponse<List<MesInventory>> list(@RequestParam(required = false) String itemCode,
@RequestParam(required = false) String warehouseCode,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(itemCode, warehouseCode, keyword));
}
@GetMapping("/available/{itemCode}")
public ApiResponse<Double> available(@PathVariable String itemCode) {
return ApiResponse.ok(service.available(itemCode));
}
@GetMapping("/below-safety")
public ApiResponse<List<MesInventory>> belowSafety() {
return ApiResponse.ok(service.belowSafety());
}
@GetMapping("/txns")
public ApiResponse<List<MesInventoryTxn>> txns(@RequestParam(required = false) String itemCode,
@RequestParam(required = false) String refType,
@RequestParam(required = false) String refNo,
@RequestParam(defaultValue = "200") int limit) {
return ApiResponse.ok(service.txns(itemCode, refType, refNo, limit));
}
/** 재고 이동(로케이션/창고 간). Worker+. */
@PostMapping("/transfer")
public ApiResponse<Void> transfer(@RequestBody TransferRequest req, Authentication auth) {
service.transfer(req.itemCode(), req.fromWarehouse(), req.fromLocation(),
req.toWarehouse(), req.toLocation(), req.lotNo(), req.qty(), AuthSupport.actor(auth));
return ApiResponse.ok(null);
}
record TransferRequest(String itemCode, String fromWarehouse, String fromLocation,
String toWarehouse, String toLocation, String lotNo, double qty) {}
}

View File

@ -0,0 +1,128 @@
package com.zioinfo.mes.inventory;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.inventory.mapper.InventoryMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 재고 서비스 수불(입고/출고/투입/산출/이동/조정) 원장 + 잔고 갱신.
*
* <p>모든 수량 변동은 {@link MesInventoryTxn} 이력 + 잔고({@link MesInventory}) 동시 갱신(트랜잭션).
* WMS(receiving/shipping/stocktake)·MES(production)·tracking 공용으로 호출한다.
*/
@Service
@RequiredArgsConstructor
public class InventoryService {
private final InventoryMapper mapper;
private final AuditService audit;
public List<MesInventory> list(String itemCode, String warehouseCode, String keyword) {
return mapper.findAll(itemCode, warehouseCode, keyword);
}
public Double available(String itemCode) {
Double q = mapper.sumQty(itemCode);
return q == null ? 0.0 : q;
}
public List<MesInventory> belowSafety() {
return mapper.findBelowSafety();
}
public List<MesInventoryTxn> txns(String itemCode, String refType, String refNo, int limit) {
return mapper.findTxns(itemCode, refType, refNo, limit <= 0 ? 200 : limit);
}
/** 입고/산출 — 잔고 증가 + 이력. */
@Transactional
public void inbound(String txnType, String itemCode, String warehouseCode, String locationCode,
String lotNo, double qty, String refType, String refNo, String actor) {
if (qty <= 0) throw new IllegalArgumentException("ERR-INV-400: 수량은 0보다 커야 합니다");
applyDelta(itemCode, warehouseCode, locationCode, lotNo, qty);
recordTxn(txnType, itemCode, null, null, warehouseCode, locationCode, lotNo, qty, refType, refNo, actor);
}
/** 출고/투입 — 잔고 감소 + 이력. 음수 재고 차단. */
@Transactional
public void outbound(String txnType, String itemCode, String warehouseCode, String locationCode,
String lotNo, double qty, String refType, String refNo, String actor) {
if (qty <= 0) throw new IllegalArgumentException("ERR-INV-400: 수량은 0보다 커야 합니다");
MesInventory inv = mapper.findOne(itemCode, warehouseCode, locationCode, lotNo);
double cur = inv == null || inv.getQty() == null ? 0 : inv.getQty();
if (cur < qty) {
throw new RuntimeException("ERR-INV-409: 재고 부족(" + itemCode + " 보유 " + cur + " < 요청 " + qty + ")");
}
applyDelta(itemCode, warehouseCode, locationCode, lotNo, -qty);
recordTxn(txnType, itemCode, warehouseCode, locationCode, null, null, lotNo, qty, refType, refNo, actor);
}
/** 재고 이동(TRANSFER) — from 감소, to 증가, 단일 이력. */
@Transactional
public void transfer(String itemCode, String fromWh, String fromLoc, String toWh, String toLoc,
String lotNo, double qty, String actor) {
if (qty <= 0) throw new IllegalArgumentException("ERR-INV-400: 수량은 0보다 커야 합니다");
MesInventory src = mapper.findOne(itemCode, fromWh, fromLoc, lotNo);
double cur = src == null || src.getQty() == null ? 0 : src.getQty();
if (cur < qty) throw new RuntimeException("ERR-INV-409: 이동 재고 부족");
applyDelta(itemCode, fromWh, fromLoc, lotNo, -qty);
applyDelta(itemCode, toWh, toLoc, lotNo, qty);
MesInventoryTxn txn = new MesInventoryTxn();
txn.setTxnType("TRANSFER");
txn.setItemCode(itemCode);
txn.setFromWarehouse(fromWh); txn.setFromLocation(fromLoc);
txn.setToWarehouse(toWh); txn.setToLocation(toLoc);
txn.setLotNo(lotNo); txn.setQty(qty);
txn.setRefType("TRANSFER"); txn.setCreatedBy(actor);
mapper.insertTxn(txn);
audit.log("INV_TRANSFER", itemCode, fromWh + "->" + toWh + " qty=" + qty);
}
/** 재고 실사 조정(ADJUST) — 목표 수량으로 보정. */
@Transactional
public void adjust(String itemCode, String warehouseCode, String locationCode, String lotNo,
double countedQty, String refNo, String actor) {
MesInventory inv = mapper.findOne(itemCode, warehouseCode, locationCode, lotNo);
double cur = inv == null || inv.getQty() == null ? 0 : inv.getQty();
double delta = countedQty - cur;
applyDelta(itemCode, warehouseCode, locationCode, lotNo, delta);
recordTxn("ADJUST", itemCode, warehouseCode, locationCode, warehouseCode, locationCode,
lotNo, delta, "STOCKTAKE", refNo, actor);
audit.log("INV_ADJUST", itemCode, "delta=" + delta);
}
/** 잔고 증감 — 행 없으면 생성. */
private void applyDelta(String itemCode, String warehouseCode, String locationCode, String lotNo, double delta) {
MesInventory inv = mapper.findOne(itemCode, warehouseCode, locationCode, lotNo);
if (inv == null) {
inv = new MesInventory();
inv.setItemCode(itemCode);
inv.setWarehouseCode(warehouseCode);
inv.setLocationCode(locationCode);
inv.setLotNo(lotNo);
inv.setQty(Math.max(0, delta));
mapper.insertStock(inv);
} else {
double next = (inv.getQty() == null ? 0 : inv.getQty()) + delta;
mapper.updateQty(inv.getId(), Math.max(0, next));
}
}
private void recordTxn(String type, String itemCode, String fromWh, String fromLoc,
String toWh, String toLoc, String lotNo, double qty,
String refType, String refNo, String actor) {
MesInventoryTxn txn = new MesInventoryTxn();
txn.setTxnType(type);
txn.setItemCode(itemCode);
txn.setFromWarehouse(fromWh); txn.setFromLocation(fromLoc);
txn.setToWarehouse(toWh); txn.setToLocation(toLoc);
txn.setLotNo(lotNo); txn.setQty(qty);
txn.setRefType(refType); txn.setRefNo(refNo);
txn.setCreatedBy(actor);
mapper.insertTxn(txn);
}
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.mes.inventory;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 재고 (mes_inventory) 품목·창고·로케이션·LOT 단위 수량.
*/
@Data
public class MesInventory {
private Long id;
private String itemCode;
private String warehouseCode;
private String locationCode;
private String lotNo;
private Double qty;
private String unit;
private LocalDateTime updatedAt;
// 조인 표시용(비영속)
private String itemName;
private Double safetyStock;
}

View File

@ -0,0 +1,26 @@
package com.zioinfo.mes.inventory;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 재고 이동 이력 (mes_inventory_txn) 입고/출고/투입/산출/이동/조정.
*
* <p>txnType: RECEIVE, ISSUE, CONSUME(투입), PRODUCE(산출), TRANSFER, ADJUST
*/
@Data
public class MesInventoryTxn {
private Long id;
private String txnType;
private String itemCode;
private String fromWarehouse;
private String fromLocation;
private String toWarehouse;
private String toLocation;
private String lotNo;
private Double qty;
private String refType; // WORKORDER / RECEIVING / SHIPPING / STOCKTAKE
private String refNo;
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,39 @@
package com.zioinfo.mes.inventory.mapper;
import com.zioinfo.mes.inventory.MesInventory;
import com.zioinfo.mes.inventory.MesInventoryTxn;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface InventoryMapper {
List<MesInventory> findAll(@Param("itemCode") String itemCode,
@Param("warehouseCode") String warehouseCode,
@Param("keyword") String keyword);
MesInventory findOne(@Param("itemCode") String itemCode,
@Param("warehouseCode") String warehouseCode,
@Param("locationCode") String locationCode,
@Param("lotNo") String lotNo);
/** 품목 총 가용재고. */
Double sumQty(@Param("itemCode") String itemCode);
/** 안전재고 미달 품목. */
List<MesInventory> findBelowSafety();
int insertStock(MesInventory inv);
int updateQty(@Param("id") Long id, @Param("qty") Double qty);
int insertTxn(MesInventoryTxn txn);
List<MesInventoryTxn> findTxns(@Param("itemCode") String itemCode,
@Param("refType") String refType,
@Param("refNo") String refNo,
@Param("limit") int limit);
/** LOT 기준 이동 이력(추적용). */
List<MesInventoryTxn> findByLot(@Param("lotNo") String lotNo);
/** 재고 KPI 요약. */
Map<String, Object> kpiSummary();
}

View File

@ -0,0 +1,51 @@
package com.zioinfo.mes.item;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.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/mes/items")
@RequiredArgsConstructor
public class ItemController {
private final ItemService service;
@GetMapping
public ApiResponse<List<MesItem>> list(@RequestParam(required = false) String itemType,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(itemType, status, keyword));
}
@GetMapping("/{id}")
public ApiResponse<MesItem> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MesItem> create(@RequestBody MesItem item, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.create(item, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MesItem> update(@PathVariable Long id, @RequestBody MesItem item, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.update(id, item, 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,70 @@
package com.zioinfo.mes.item;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.item.mapper.ItemMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
/**
* 품목 마스터 서비스 기준정보(MANAGER+ 가드는 SecurityConfig + 컨트롤러 역할 확인 병행).
*/
@Service
@RequiredArgsConstructor
public class ItemService {
private static final Set<String> TYPES = Set.of("RAW", "WIP", "FG", "SUB");
private final ItemMapper mapper;
private final AuditService audit;
public List<MesItem> list(String itemType, String status, String keyword) {
return mapper.findAll(itemType, status, keyword);
}
public MesItem get(Long id) {
MesItem i = mapper.findById(id);
if (i == null) throw new RuntimeException("ERR-ITEM-404: 품목 없음");
return i;
}
public MesItem create(MesItem item, String actor) {
validate(item);
if (mapper.countByCode(item.getItemCode(), null) > 0) {
throw new RuntimeException("ERR-ITEM-409: 중복 품목코드");
}
if (item.getStatus() == null) item.setStatus("ACTIVE");
item.setCreatedBy(actor);
mapper.insert(item);
audit.log("ITEM_CREATE", item.getItemCode(), "type=" + item.getItemType());
return mapper.findById(item.getId());
}
public MesItem update(Long id, MesItem item, String actor) {
MesItem cur = get(id);
validate(item);
if (mapper.countByCode(item.getItemCode(), id) > 0) {
throw new RuntimeException("ERR-ITEM-409: 중복 품목코드");
}
item.setId(id);
mapper.update(item);
audit.log("ITEM_UPDATE", cur.getItemCode(), "id=" + id);
return mapper.findById(id);
}
public void delete(Long id) {
MesItem cur = get(id);
mapper.delete(id);
audit.log("ITEM_DELETE", cur.getItemCode(), "id=" + id);
}
private void validate(MesItem item) {
if (item.getItemCode() == null || item.getItemCode().isBlank())
throw new IllegalArgumentException("ERR-ITEM-400: itemCode 필수");
if (item.getItemName() == null || item.getItemName().isBlank())
throw new IllegalArgumentException("ERR-ITEM-400: itemName 필수");
if (item.getItemType() != null && !TYPES.contains(item.getItemType()))
throw new IllegalArgumentException("ERR-ITEM-400: itemType 은 RAW/WIP/FG/SUB");
}
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.mes.item;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 품목 마스터 (mes_item) 원자재/반제품/제품/부자재.
*
* <p>itemType: RAW(원자재), WIP(반제품), FG(완제품), SUB(부자재)
*/
@Data
public class MesItem {
private Long id;
private String itemCode;
private String itemName;
private String itemType; // RAW / WIP / FG / SUB
private String unit; // EA, KG, M, BOX ...
private String barcode;
private String spec;
private Double safetyStock;
private String warehouseCode; // 기본 보관 창고
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.mes.item.mapper;
import com.zioinfo.mes.item.MesItem;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ItemMapper {
List<MesItem> findAll(@Param("itemType") String itemType,
@Param("status") String status,
@Param("keyword") String keyword);
MesItem findById(@Param("id") Long id);
MesItem findByCode(@Param("itemCode") String itemCode);
int countByCode(@Param("itemCode") String itemCode, @Param("excludeId") Long excludeId);
int insert(MesItem item);
int update(MesItem item);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,88 @@
package com.zioinfo.mes.job;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.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 작업/작업자/설비 배정·할당·조회·진행·이력.
*
* <p>RBAC: 조회 Viewer+, 생성/배정/진행/실적 Worker+, 삭제 Manager+.
*/
@RestController
@RequestMapping("/api/mes/jobs")
@RequiredArgsConstructor
public class JobController {
private final JobService service;
@GetMapping
public ApiResponse<List<MesJob>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String workerId,
@RequestParam(required = false) String woNo,
@RequestParam(required = false) String equipmentCode) {
return ApiResponse.ok(service.list(status, workerId, woNo, equipmentCode));
}
/** 작업 현황 보드(상태별 카운트). */
@GetMapping("/board")
public ApiResponse<List<Map<String, Object>>> board() {
return ApiResponse.ok(service.statusBoard());
}
/** 작업자별 부하(진행중 작업/잔여수량). */
@GetMapping("/workload")
public ApiResponse<List<Map<String, Object>>> workload() {
return ApiResponse.ok(service.workload());
}
@GetMapping("/{id}")
public ApiResponse<MesJob> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
/** 작업 생성(작업지시 공정 할당). */
@PostMapping
public ApiResponse<MesJob> create(@RequestBody MesJob job, Authentication auth) {
return ApiResponse.ok(service.create(job, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MesJob> update(@PathVariable Long id, @RequestBody MesJob job, Authentication auth) {
return ApiResponse.ok(service.update(id, job, AuthSupport.actor(auth)));
}
/** 작업자/설비 배정. */
@PostMapping("/{id}/assign")
public ApiResponse<MesJob> assign(@PathVariable Long id, @RequestBody AssignRequest req, Authentication auth) {
return ApiResponse.ok(service.assign(id, req.workerId(), req.equipmentCode(), AuthSupport.actor(auth)));
}
/** 진행 상태 전이(START/PAUSE/RESUME/DONE/CANCEL). */
@PostMapping("/{id}/transition")
public ApiResponse<MesJob> transition(@PathVariable Long id, @RequestBody TransitionRequest req, Authentication auth) {
return ApiResponse.ok(service.transition(id, req.action(), AuthSupport.actor(auth)));
}
/** 작업 완료 수량 보고. */
@PostMapping("/{id}/report")
public ApiResponse<MesJob> report(@PathVariable Long id, @RequestBody QtyRequest req, Authentication auth) {
return ApiResponse.ok(service.reportDone(id, req.qty(), AuthSupport.actor(auth)));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
service.delete(id);
return ApiResponse.ok(null);
}
record AssignRequest(String workerId, String equipmentCode) {}
record TransitionRequest(String action) {}
record QtyRequest(double qty) {}
}

View File

@ -0,0 +1,140 @@
package com.zioinfo.mes.job;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.job.mapper.JobMapper;
import com.zioinfo.mes.workorder.MesWorkOrder;
import com.zioinfo.mes.workorder.mapper.WorkOrderMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 작업관리 서비스 작업 생성·작업자/설비 배정·할당·진행 전이·실적 누적·이력 조회.
*
* <p>상태 전이: ASSIGNED STARTED (PAUSED STARTED) DONE (CANCELLED 가능).
* 작업지시(workorder) 참조해 itemCode/woNo 보정한다.
*/
@Service
@RequiredArgsConstructor
public class JobService {
private static final Map<String, Set<String>> TRANSITIONS = Map.of(
"ASSIGNED", Set.of("STARTED", "CANCELLED"),
"STARTED", Set.of("PAUSED", "DONE"),
"PAUSED", Set.of("STARTED", "CANCELLED"),
"DONE", Set.of(),
"CANCELLED", Set.of()
);
private final JobMapper mapper;
private final WorkOrderMapper workOrderMapper;
private final AuditService audit;
public List<MesJob> list(String status, String workerId, String woNo, String equipmentCode) {
return mapper.findAll(status, workerId, woNo, equipmentCode);
}
public MesJob get(Long id) {
MesJob j = mapper.findById(id);
if (j == null) throw new RuntimeException("ERR-JOB-404: 작업 없음");
return j;
}
public List<Map<String, Object>> statusBoard() {
return mapper.countByStatus();
}
public List<Map<String, Object>> workload() {
return mapper.workloadByWorker();
}
/** 작업 생성(작업지시 공정 할당). workorderId 로 woNo/itemCode 보정. */
@Transactional
public MesJob create(MesJob job, String actor) {
if (job.getWorkorderId() == null)
throw new IllegalArgumentException("ERR-JOB-400: workorderId 필수");
MesWorkOrder wo = workOrderMapper.findById(job.getWorkorderId());
if (wo == null) throw new RuntimeException("ERR-JOB-404: 작업지시 없음");
job.setWoNo(wo.getWoNo());
if (job.getItemCode() == null) job.setItemCode(wo.getItemCode());
if (job.getAssignedQty() == null || job.getAssignedQty() <= 0)
job.setAssignedQty(wo.getPlanQty());
if (job.getJobNo() == null || job.getJobNo().isBlank())
job.setJobNo("JOB-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
+ "-" + (System.currentTimeMillis() % 100000));
if (mapper.countByNo(job.getJobNo()) > 0)
throw new RuntimeException("ERR-JOB-409: 중복 작업번호");
job.setStatus("ASSIGNED");
job.setCreatedBy(actor);
mapper.insert(job);
audit.log("JOB_CREATE", job.getJobNo(),
"wo=" + job.getWoNo() + " worker=" + job.getWorkerId() + " eqp=" + job.getEquipmentCode());
return mapper.findById(job.getId());
}
/** 작업자/설비 재배정. */
@Transactional
public MesJob assign(Long id, String workerId, String equipmentCode, String actor) {
MesJob j = get(id);
if (Set.of("DONE", "CANCELLED").contains(j.getStatus()))
throw new RuntimeException("ERR-JOB-409: 종료된 작업은 재배정 불가");
mapper.assign(id, workerId, equipmentCode);
audit.log("JOB_ASSIGN", j.getJobNo(), "worker=" + workerId + " eqp=" + equipmentCode);
return mapper.findById(id);
}
/** 진행 상태 전이 — action: START/PAUSE/RESUME/DONE/CANCEL. */
@Transactional
public MesJob transition(Long id, String action, String actor) {
MesJob j = get(id);
String from = j.getStatus();
String to = switch (action == null ? "" : action.toUpperCase()) {
case "START", "RESUME" -> "STARTED";
case "PAUSE" -> "PAUSED";
case "DONE" -> "DONE";
case "CANCEL" -> "CANCELLED";
default -> throw new IllegalArgumentException("ERR-JOB-400: 알 수 없는 action(START/PAUSE/RESUME/DONE/CANCEL)");
};
if (!TRANSITIONS.getOrDefault(from, Set.of()).contains(to))
throw new RuntimeException("ERR-JOB-409: " + from + "" + to + " 전이 불가");
mapper.updateStatus(id, to);
audit.log("JOB_" + action.toUpperCase(), j.getJobNo(), from + " -> " + to);
return mapper.findById(id);
}
/** 작업 실적 누적(완료 수량). */
@Transactional
public MesJob reportDone(Long id, double qty, String actor) {
MesJob j = get(id);
if (qty <= 0) throw new IllegalArgumentException("ERR-JOB-400: 수량은 0보다 커야 합니다");
mapper.addDone(id, qty);
audit.log("JOB_DONE_QTY", j.getJobNo(), "+" + qty);
return mapper.findById(id);
}
@Transactional
public MesJob update(Long id, MesJob job, String actor) {
MesJob cur = get(id);
if (Set.of("DONE", "CANCELLED").contains(cur.getStatus()))
throw new RuntimeException("ERR-JOB-409: 종료된 작업은 수정 불가");
job.setId(id);
if (job.getAssignedQty() == null) job.setAssignedQty(cur.getAssignedQty());
mapper.update(job);
audit.log("JOB_UPDATE", cur.getJobNo(), "id=" + id);
return mapper.findById(id);
}
public void delete(Long id) {
MesJob cur = get(id);
if (!Set.of("ASSIGNED", "CANCELLED").contains(cur.getStatus()))
throw new RuntimeException("ERR-JOB-409: 진행/완료 작업은 삭제 불가");
mapper.delete(id);
audit.log("JOB_DELETE", cur.getJobNo(), "");
}
}

View File

@ -0,0 +1,36 @@
package com.zioinfo.mes.job;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 작업관리 (mes_job) 작업지시 하위 작업/작업자/설비 배정·할당·진행·이력.
*
* <p>상태: ASSIGNED STARTED (PAUSED) DONE (CANCELLED 가능).
* 작업지시(workorder) 공정(op) 작업자/설비에 할당하고 실작업 진행을 추적한다.
*/
@Data
public class MesJob {
private Long id;
private String jobNo;
private Long workorderId;
private String woNo;
private String itemCode;
private String itemName; // 조인 표시(비영속)
private Integer opSeq;
private String processCode;
private String processName;
private String equipmentCode;
private String workerId; // 배정 작업자
private Double assignedQty;
private Double doneQty;
private String status; // ASSIGNED/STARTED/PAUSED/DONE/CANCELLED
private LocalDateTime plannedStart;
private LocalDateTime plannedEnd;
private LocalDateTime startedAt;
private LocalDateTime endedAt;
private String remark;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,30 @@
package com.zioinfo.mes.job.mapper;
import com.zioinfo.mes.job.MesJob;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface JobMapper {
List<MesJob> findAll(@Param("status") String status,
@Param("workerId") String workerId,
@Param("woNo") String woNo,
@Param("equipmentCode") String equipmentCode);
MesJob findById(@Param("id") Long id);
int countByNo(@Param("jobNo") String jobNo);
int insert(MesJob job);
int update(MesJob job);
int assign(@Param("id") Long id, @Param("workerId") String workerId,
@Param("equipmentCode") String equipmentCode);
int updateStatus(@Param("id") Long id, @Param("status") String status);
int addDone(@Param("id") Long id, @Param("qty") Double qty);
int delete(@Param("id") Long id);
/** 상태별 카운트(작업현황 보드). */
List<Map<String, Object>> countByStatus();
/** 작업자별 진행중 작업 수(부하 조회). */
List<Map<String, Object>> workloadByWorker();
}

View File

@ -0,0 +1,66 @@
package com.zioinfo.mes.lotserial;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.common.AuthSupport;
import com.zioinfo.mes.lotserial.mapper.LotSerialMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* LOT/시리얼 관리 API (WMS). 조회 Viewer+, 등록/상태변경 Worker+.
*/
@RestController
@RequestMapping("/api/mes/lotserial")
@RequiredArgsConstructor
public class LotSerialController {
private final LotSerialMapper mapper;
private final AuditService audit;
@GetMapping
public ApiResponse<List<MesLotSerial>> list(@RequestParam(required = false) String itemCode,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(mapper.findAll(itemCode, status, keyword));
}
@GetMapping("/{id}")
public ApiResponse<MesLotSerial> get(@PathVariable Long id) {
MesLotSerial l = mapper.findById(id);
if (l == null) throw new RuntimeException("ERR-LOT-404: LOT 없음");
return ApiResponse.ok(l);
}
/** 유효기간 임박 LOT(days 이내, 기본 30일). */
@GetMapping("/expiring")
public ApiResponse<List<MesLotSerial>> expiring(@RequestParam(defaultValue = "30") int days) {
return ApiResponse.ok(mapper.findExpiringSoon(days));
}
@PostMapping
public ApiResponse<MesLotSerial> create(@RequestBody MesLotSerial l, Authentication auth) {
if (l.getLotNo() == null || l.getLotNo().isBlank())
throw new IllegalArgumentException("ERR-LOT-400: lotNo 필수");
if (l.getStatus() == null) l.setStatus("ACTIVE");
l.setCreatedBy(AuthSupport.actor(auth));
mapper.insert(l);
audit.log("LOTSERIAL_CREATE", l.getLotNo(), "item=" + l.getItemCode());
return ApiResponse.ok(mapper.findById(l.getId()));
}
@PutMapping("/{id}/status")
public ApiResponse<MesLotSerial> updateStatus(@PathVariable Long id, @RequestBody StatusRequest req,
Authentication auth) {
MesLotSerial l = mapper.findById(id);
if (l == null) throw new RuntimeException("ERR-LOT-404: LOT 없음");
mapper.updateStatus(id, req.status());
audit.log("LOTSERIAL_STATUS", l.getLotNo(), l.getStatus() + " -> " + req.status());
return ApiResponse.ok(mapper.findById(id));
}
record StatusRequest(String status) {}
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.mes.lotserial;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* LOT/시리얼 (mes_lotserial) 생산/입고로 발생한 LOT·시리얼 추적·유효기간.
*
* <p>status: ACTIVE/CONSUMED/SHIPPED/EXPIRED.
*/
@Data
public class MesLotSerial {
private Long id;
private String lotNo;
private String serialNo;
private String itemCode;
private String itemName; // 조인 표시(비영속)
private Double qty;
private String sourceType; // PRODUCTION/RECEIVING
private String sourceRef;
private LocalDate mfgDate;
private LocalDate expiryDate;
private String status;
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.mes.lotserial.mapper;
import com.zioinfo.mes.lotserial.MesLotSerial;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface LotSerialMapper {
List<MesLotSerial> findAll(@Param("itemCode") String itemCode,
@Param("status") String status,
@Param("keyword") String keyword);
MesLotSerial findById(@Param("id") Long id);
MesLotSerial findByLot(@Param("lotNo") String lotNo);
int insert(MesLotSerial l);
int updateStatus(@Param("id") Long id, @Param("status") String status);
/** 유효기간 임박(days 이내) LOT. */
List<MesLotSerial> findExpiringSoon(@Param("days") int days);
}

View File

@ -0,0 +1,48 @@
package com.zioinfo.mes.member;
import com.zioinfo.mes.admin.dto.UserDto;
import com.zioinfo.mes.admin.mapper.AdminUserMapper;
import com.zioinfo.mes.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;
/**
* 구성원(작업자) 조회 API 작업 배정·실적 입력 작업자 선택용.
*
* <p>관리자 CRUD /api/admin/users(SuperAdmin) 담당하고, API 조회 전용(Viewer+).
* 보안: {@link UserDto} 로만 반환하여 password_hash 노출 차단.
*/
@RestController
@RequestMapping("/api/mes/members")
@RequiredArgsConstructor
public class MemberController {
private final AdminUserMapper mapper;
/** 활성 구성원 목록(작업자 배정 드롭다운용). role 필터 가능. */
@GetMapping
public ApiResponse<List<UserDto>> list(@RequestParam(required = false) String role) {
List<UserDto> all = mapper.findAll().stream()
.filter(u -> u.isActive())
.filter(u -> role == null || role.isBlank() || role.equalsIgnoreCase(u.getRole()))
.map(UserDto::from)
.toList();
return ApiResponse.ok(all);
}
/** 현장 작업자(WORKER) 목록 — 작업 배정 전용. */
@GetMapping("/workers")
public ApiResponse<List<UserDto>> workers() {
List<UserDto> workers = mapper.findAll().stream()
.filter(u -> u.isActive())
.filter(u -> "WORKER".equalsIgnoreCase(u.getRole()) || "MANAGER".equalsIgnoreCase(u.getRole()))
.map(UserDto::from)
.toList();
return ApiResponse.ok(workers);
}
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.mes.ncr;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 부적합 보고 (mes_ncr) Non-Conformance Report. 검사 불합격·불량 발생 기록.
*
* <p>status: OPEN REVIEW DISPOSITIONED CLOSED. disposition: USE_AS_IS/REWORK/SCRAP/RETURN.
*/
@Data
public class MesNcr {
private Long id;
private String ncrNo;
private String itemCode;
private String lotNo;
private String refType; // INSPECTION/WORKORDER/RECEIVING/SHIPPING
private String refNo;
private String defectCode;
private Double defectQty;
private String description;
private String disposition; // USE_AS_IS/REWORK/SCRAP/RETURN
private String severity; // MINOR/MAJOR/CRITICAL
private String status; // OPEN/REVIEW/DISPOSITIONED/CLOSED
private Long capaId;
private String createdBy;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,66 @@
package com.zioinfo.mes.ncr;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.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;
/**
* 부적합(NCR) API (QMS). 조회 Viewer+, 등록/처리 Worker+, CAPA 연계 Worker+.
*/
@RestController
@RequestMapping("/api/mes/ncr")
@RequiredArgsConstructor
public class NcrController {
private final NcrService service;
@GetMapping
public ApiResponse<List<MesNcr>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String severity,
@RequestParam(required = false) String itemCode) {
return ApiResponse.ok(service.list(status, severity, itemCode));
}
@GetMapping("/board")
public ApiResponse<List<Map<String, Object>>> board() {
return ApiResponse.ok(service.statusBoard());
}
@GetMapping("/{id}")
public ApiResponse<MesNcr> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MesNcr> create(@RequestBody MesNcr n, Authentication auth) {
return ApiResponse.ok(service.create(n, AuthSupport.actor(auth)));
}
@PostMapping("/{id}/disposition")
public ApiResponse<MesNcr> disposition(@PathVariable Long id, @RequestBody DispositionRequest req,
Authentication auth) {
return ApiResponse.ok(service.disposition(id, req.disposition(), AuthSupport.actor(auth)));
}
@PostMapping("/{id}/transition")
public ApiResponse<MesNcr> transition(@PathVariable Long id, @RequestBody StatusRequest req,
Authentication auth) {
return ApiResponse.ok(service.transition(id, req.status(), AuthSupport.actor(auth)));
}
@PostMapping("/{id}/link-capa")
public ApiResponse<Void> linkCapa(@PathVariable Long id, @RequestBody CapaLinkRequest req,
Authentication auth) {
service.linkCapa(id, req.capaId(), AuthSupport.actor(auth));
return ApiResponse.ok(null);
}
record DispositionRequest(String disposition) {}
record StatusRequest(String status) {}
record CapaLinkRequest(Long capaId) {}
}

View File

@ -0,0 +1,98 @@
package com.zioinfo.mes.ncr;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.ncr.mapper.NcrMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
/**
* 부적합(NCR) 서비스 부적합 보고 등록·처리방안(disposition)·CAPA 연계.
*
* <p>검사 불합격 inspection 모듈이 {@link #createFromSource} 자동 생성한다.
*/
@Service
@RequiredArgsConstructor
public class NcrService {
private final NcrMapper mapper;
private final AuditService audit;
public List<MesNcr> list(String status, String severity, String itemCode) {
return mapper.findAll(status, severity, itemCode);
}
public MesNcr get(Long id) {
MesNcr n = mapper.findById(id);
if (n == null) throw new RuntimeException("ERR-NCR-404: NCR 없음");
return n;
}
public List<Map<String, Object>> statusBoard() {
return mapper.countByStatus();
}
@Transactional
public MesNcr create(MesNcr n, String actor) {
if (n.getItemCode() == null || n.getItemCode().isBlank())
throw new IllegalArgumentException("ERR-NCR-400: itemCode 필수");
if (n.getNcrNo() == null || n.getNcrNo().isBlank())
n.setNcrNo(genNo());
if (n.getSeverity() == null) n.setSeverity("MINOR");
n.setStatus("OPEN");
n.setCreatedBy(actor);
mapper.insert(n);
audit.log("NCR_CREATE", n.getNcrNo(), "item=" + n.getItemCode() + " defect=" + n.getDefectCode());
return mapper.findById(n.getId());
}
/** 검사 불합격 → NCR 자동 생성(inspection 모듈 호출용). */
@Transactional
public MesNcr createFromSource(String itemCode, String lotNo, String refType, String refNo,
String defectCode, double defectQty, String severity, String actor) {
MesNcr n = new MesNcr();
n.setItemCode(itemCode);
n.setLotNo(lotNo);
n.setRefType(refType);
n.setRefNo(refNo);
n.setDefectCode(defectCode);
n.setDefectQty(defectQty);
n.setSeverity(severity == null ? "MAJOR" : severity);
n.setDescription("검사 불합격 자동 부적합 (" + refType + " " + refNo + ")");
return create(n, actor);
}
/** 처리방안 확정 → DISPOSITIONED. */
@Transactional
public MesNcr disposition(Long id, String disposition, String actor) {
MesNcr n = get(id);
mapper.updateDisposition(id, disposition, "DISPOSITIONED");
audit.log("NCR_DISPOSITION", n.getNcrNo(), disposition);
return mapper.findById(id);
}
@Transactional
public MesNcr transition(Long id, String status, String actor) {
MesNcr n = get(id);
mapper.updateStatus(id, status);
audit.log("NCR_STATUS", n.getNcrNo(), n.getStatus() + " -> " + status);
return mapper.findById(id);
}
@Transactional
public void linkCapa(Long id, Long capaId, String actor) {
MesNcr n = get(id);
mapper.linkCapa(id, capaId);
audit.log("NCR_LINK_CAPA", n.getNcrNo(), "capaId=" + capaId);
}
private String genNo() {
return "NCR-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
+ "-" + (System.currentTimeMillis() % 100000);
}
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.mes.ncr.mapper;
import com.zioinfo.mes.ncr.MesNcr;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface NcrMapper {
List<MesNcr> findAll(@Param("status") String status,
@Param("severity") String severity,
@Param("itemCode") String itemCode);
MesNcr findById(@Param("id") Long id);
int insert(MesNcr n);
int updateDisposition(@Param("id") Long id, @Param("disposition") String disposition,
@Param("status") String status);
int updateStatus(@Param("id") Long id, @Param("status") String status);
int linkCapa(@Param("id") Long id, @Param("capaId") Long capaId);
/** 상태별 카운트(품질 대시보드). */
List<Map<String, Object>> countByStatus();
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.mes.oee;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* OEE 일일 기록 (mes_oee) 설비/일자별 가동·성능·품질 입력값.
*
* <p>OEE = Availability × Performance × Quality.
* Availability = runTime / plannedTime
* Performance = (idealCycleMin × totalCount) / runTime
* Quality = goodCount / totalCount
*/
@Data
public class MesOeeRecord {
private Long id;
private String equipmentCode;
private LocalDate recordDate;
private Double plannedTimeMin; // 계획 가동시간
private Double runTimeMin; // 실제 가동시간
private Double downtimeMin; // 비가동시간
private String downtimeCode; // 비가동 코드
private Double idealCycleMin; // 이상 사이클타임(/)
private Double totalCount; // 생산수
private Double goodCount; // 양품수
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,48 @@
package com.zioinfo.mes.oee;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.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;
/**
* OEE API 기록 입력 + 산식 + 비가동 파레토. 조회 Viewer+, 입력 Worker+.
*/
@RestController
@RequestMapping("/api/mes/oee")
@RequiredArgsConstructor
public class OeeController {
private final OeeService service;
@GetMapping
public ApiResponse<List<MesOeeRecord>> list(@RequestParam(required = false) String equipmentCode,
@RequestParam(defaultValue = "100") int limit) {
return ApiResponse.ok(service.list(equipmentCode, limit));
}
@PostMapping
public ApiResponse<MesOeeRecord> create(@RequestBody MesOeeRecord r, Authentication auth) {
return ApiResponse.ok(service.create(r, AuthSupport.actor(auth)));
}
/** 입력값으로 즉시 OEE 계산(저장 없음). */
@PostMapping("/compute")
public ApiResponse<Map<String, Object>> compute(@RequestBody MesOeeRecord r) {
return ApiResponse.ok(service.compute(r));
}
@GetMapping("/{id}/oee")
public ApiResponse<Map<String, Object>> computeById(@PathVariable Long id) {
return ApiResponse.ok(service.computeById(id));
}
@GetMapping("/downtime-pareto")
public ApiResponse<List<Map<String, Object>>> downtimePareto(@RequestParam(defaultValue = "30") int days) {
return ApiResponse.ok(service.downtimePareto(days));
}
}

View File

@ -0,0 +1,72 @@
package com.zioinfo.mes.oee;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.oee.mapper.OeeMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* OEE 서비스 가동률·성능·품질 산식 + 비가동/불량 집계.
*/
@Service
@RequiredArgsConstructor
public class OeeService {
private final OeeMapper mapper;
private final AuditService audit;
public List<MesOeeRecord> list(String equipmentCode, int limit) {
return mapper.findAll(equipmentCode, limit <= 0 ? 100 : limit);
}
public MesOeeRecord create(MesOeeRecord r, String actor) {
if (r.getEquipmentCode() == null) throw new IllegalArgumentException("ERR-OEE-400: equipmentCode 필수");
r.setCreatedBy(actor);
mapper.insert(r);
audit.log("OEE_RECORD", r.getEquipmentCode(), "date=" + r.getRecordDate());
return mapper.findById(r.getId());
}
public List<Map<String, Object>> downtimePareto(int days) {
return mapper.downtimePareto(days <= 0 ? 30 : days);
}
/** OEE 계산 — 한 기록의 가동률/성능/품질/OEE. */
public Map<String, Object> compute(MesOeeRecord r) {
double planned = nz(r.getPlannedTimeMin());
double run = nz(r.getRunTimeMin());
double ideal = nz(r.getIdealCycleMin());
double total = nz(r.getTotalCount());
double good = nz(r.getGoodCount());
double availability = planned > 0 ? clamp(run / planned) : 0;
double performance = run > 0 ? clamp((ideal * total) / run) : 0;
double quality = total > 0 ? clamp(good / total) : 0;
double oee = availability * performance * quality;
Map<String, Object> m = new LinkedHashMap<>();
m.put("equipmentCode", r.getEquipmentCode());
m.put("availability", round4(availability));
m.put("performance", round4(performance));
m.put("quality", round4(quality));
m.put("oee", round4(oee));
m.put("oeePercent", round2(oee * 100));
return m;
}
/** id 기준 OEE 계산. */
public Map<String, Object> computeById(Long id) {
MesOeeRecord r = mapper.findById(id);
if (r == null) throw new RuntimeException("ERR-OEE-404: OEE 기록 없음");
return compute(r);
}
private double nz(Double d) { return d == null ? 0 : d; }
private double clamp(double d) { return Math.max(0, Math.min(1, d)); }
private double round4(double d) { return Math.round(d * 10000.0) / 10000.0; }
private double round2(double d) { return Math.round(d * 100.0) / 100.0; }
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.mes.oee.mapper;
import com.zioinfo.mes.oee.MesOeeRecord;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface OeeMapper {
List<MesOeeRecord> findAll(@Param("equipmentCode") String equipmentCode, @Param("limit") int limit);
MesOeeRecord findById(@Param("id") Long id);
int insert(MesOeeRecord r);
/** 비가동 코드별 집계. */
List<Map<String, Object>> downtimePareto(@Param("days") int days);
}

View File

@ -0,0 +1,28 @@
package com.zioinfo.mes.partner;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 거래처 마스터 (mes_partner) 공급사/고객.
*
* <p>보안: 담당자 연락처/이메일은 *_enc 컬럼에 AES 암호화 저장, 응답 마스킹.
* 평문 contactPhone/contactEmail 입력 전용(요청 바인딩), 응답에는 마스킹 값만.
*/
@Data
public class MesPartner {
private Long id;
private String partnerCode;
private String partnerName;
private String partnerType; // SUPPLIER / CUSTOMER / BOTH
private String bizNo; // 사업자번호
private String contactName;
private String contactPhoneEnc; // AES 암호화 저장
private String contactEmailEnc; // AES 암호화 저장
private String contactPhone; // 응답 마스킹용(비영속 서비스에서 set)
private String contactEmail; // 응답 마스킹용(비영속)
private String address;
private String status;
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,51 @@
package com.zioinfo.mes.partner;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 거래처 마스터 API. 조회 Viewer+(PII 마스킹), 변경 Manager+.
*/
@RestController
@RequestMapping("/api/mes/partners")
@RequiredArgsConstructor
public class PartnerController {
private final PartnerService service;
@GetMapping
public ApiResponse<List<MesPartner>> 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<MesPartner> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<MesPartner> create(@RequestBody MesPartner p, Authentication auth) {
AuthSupport.requireManager(auth);
return ApiResponse.ok(service.create(p, AuthSupport.actor(auth)));
}
@PutMapping("/{id}")
public ApiResponse<MesPartner> update(@PathVariable Long id, @RequestBody MesPartner 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,93 @@
package com.zioinfo.mes.partner;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.common.CryptoUtil;
import com.zioinfo.mes.partner.mapper.PartnerMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 거래처 서비스 담당자 연락처 PII AES 암호화 저장, 응답은 마스킹.
*
* <p>보안 불변 규칙: contactPhone/contactEmail 평문은 절대 저장/응답하지 않는다.
*/
@Service
@RequiredArgsConstructor
public class PartnerService {
private final PartnerMapper mapper;
private final CryptoUtil crypto;
private final AuditService audit;
public List<MesPartner> list(String partnerType, String status, String keyword) {
List<MesPartner> list = mapper.findAll(partnerType, status, keyword);
list.forEach(this::maskForResponse);
return list;
}
public MesPartner get(Long id) {
MesPartner p = require(id);
maskForResponse(p);
return p;
}
public MesPartner create(MesPartner p, String actor) {
if (p.getPartnerCode() == null || p.getPartnerCode().isBlank())
throw new IllegalArgumentException("ERR-PARTNER-400: partnerCode 필수");
if (mapper.countByCode(p.getPartnerCode(), null) > 0)
throw new RuntimeException("ERR-PARTNER-409: 중복 거래처코드");
encryptPii(p);
if (p.getStatus() == null) p.setStatus("ACTIVE");
p.setCreatedBy(actor);
mapper.insert(p);
audit.log("PARTNER_CREATE", p.getPartnerCode(), "type=" + p.getPartnerType());
return get(p.getId());
}
public MesPartner update(Long id, MesPartner p, String actor) {
require(id);
encryptPii(p);
p.setId(id);
mapper.update(p);
audit.log("PARTNER_UPDATE", p.getPartnerCode(), "id=" + id);
return get(id);
}
public void delete(Long id) {
MesPartner cur = require(id);
mapper.delete(id);
audit.log("PARTNER_DELETE", cur.getPartnerCode(), "id=" + id);
}
/** 입력 평문 → *_enc 암호화. 평문 필드는 제거. */
private void encryptPii(MesPartner p) {
if (p.getContactPhone() != null && !p.getContactPhone().isBlank()) {
p.setContactPhoneEnc(crypto.encrypt(p.getContactPhone()));
}
if (p.getContactEmail() != null && !p.getContactEmail().isBlank()) {
p.setContactEmailEnc(crypto.encrypt(p.getContactEmail()));
}
p.setContactPhone(null);
p.setContactEmail(null);
}
/** 응답용 — 복호화 후 마스킹. *_enc 는 노출하지 않음. */
private void maskForResponse(MesPartner p) {
if (p.getContactPhoneEnc() != null && !p.getContactPhoneEnc().isBlank()) {
p.setContactPhone(CryptoUtil.mask(crypto.decrypt(p.getContactPhoneEnc())));
}
if (p.getContactEmailEnc() != null && !p.getContactEmailEnc().isBlank()) {
p.setContactEmail(CryptoUtil.mask(crypto.decrypt(p.getContactEmailEnc())));
}
p.setContactPhoneEnc(null);
p.setContactEmailEnc(null);
}
private MesPartner require(Long id) {
MesPartner p = mapper.findById(id);
if (p == null) throw new RuntimeException("ERR-PARTNER-404: 거래처 없음");
return p;
}
}

View File

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

View File

@ -0,0 +1,21 @@
package com.zioinfo.mes.plan;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 생산계획 (mes_plan) — 일정/간트 기준 데이터. */
@Data
public class MesPlan {
private Long id;
private String planNo;
private String itemCode;
private String itemName; // 조인 표시(비영속)
private Double planQty;
private LocalDate startDate;
private LocalDate endDate;
private String status; // PLANNED / CONFIRMED / RELEASED / DONE
private String remark;
private String createdBy;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,92 @@
package com.zioinfo.mes.plan;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.ai.AiService;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
/**
* 생산계획 API + AI 일정 최적화. 조회 Viewer+, 변경 Manager+.
*/
@RestController
@RequestMapping("/api/mes/plans")
@RequiredArgsConstructor
public class PlanController {
private final PlanMapper mapper;
private final AuditService audit;
private final AiService ai;
@GetMapping
public ApiResponse<List<MesPlan>> list(@RequestParam(required = false) String status,
@RequestParam(required = false) String itemCode) {
return ApiResponse.ok(mapper.findAll(status, itemCode));
}
@GetMapping("/{id}")
public ApiResponse<MesPlan> get(@PathVariable Long id) {
return ApiResponse.ok(require(id));
}
@PostMapping
public ApiResponse<MesPlan> create(@RequestBody MesPlan p, Authentication auth) {
AuthSupport.requireManager(auth);
if (p.getItemCode() == null) throw new IllegalArgumentException("ERR-PLAN-400: itemCode 필수");
if (p.getPlanNo() == null || p.getPlanNo().isBlank())
p.setPlanNo("PLAN-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + (System.currentTimeMillis() % 100000));
if (p.getStatus() == null) p.setStatus("PLANNED");
p.setCreatedBy(AuthSupport.actor(auth));
mapper.insert(p);
audit.log("PLAN_CREATE", p.getPlanNo(), "item=" + p.getItemCode());
return ApiResponse.ok(mapper.findById(p.getId()));
}
@PutMapping("/{id}")
public ApiResponse<MesPlan> update(@PathVariable Long id, @RequestBody MesPlan p, Authentication auth) {
AuthSupport.requireManager(auth);
require(id);
p.setId(id);
mapper.update(p);
audit.log("PLAN_UPDATE", p.getPlanNo(), "id=" + id);
return ApiResponse.ok(mapper.findById(id));
}
@PutMapping("/{id}/status")
public ApiResponse<MesPlan> status(@PathVariable Long id, @RequestBody StatusRequest req, Authentication auth) {
AuthSupport.requireManager(auth);
MesPlan cur = require(id);
mapper.updateStatus(id, req.status());
audit.log("PLAN_STATUS", cur.getPlanNo(), cur.getStatus() + " -> " + req.status());
return ApiResponse.ok(mapper.findById(id));
}
/** AI 일정 최적화 — 납기/셋업 기준 순서 제안. */
@PostMapping("/optimize")
public ApiResponse<List<Map<String, Object>>> optimize(@RequestBody List<Map<String, Object>> orders) {
return ApiResponse.ok(ai.scheduleOptimize(orders));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id, Authentication auth) {
AuthSupport.requireManager(auth);
mapper.delete(id);
audit.log("PLAN_DELETE", String.valueOf(id), "");
return ApiResponse.ok(null);
}
private MesPlan require(Long id) {
MesPlan p = mapper.findById(id);
if (p == null) throw new RuntimeException("ERR-PLAN-404: 생산계획 없음");
return p;
}
record StatusRequest(String status) {}
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.mes.plan;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface PlanMapper {
List<MesPlan> findAll(@Param("status") String status, @Param("itemCode") String itemCode);
MesPlan findById(@Param("id") Long id);
int insert(MesPlan p);
int update(MesPlan p);
int updateStatus(@Param("id") Long id, @Param("status") String status);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,25 @@
package com.zioinfo.mes.process;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 공정 진행 (mes_process_progress) 작업지시의 공정별 진행/작업자 배정.
*/
@Data
public class MesProcessProgress {
private Long id;
private Long workorderId;
private String woNo;
private Integer opSeq;
private String processCode;
private String processName;
private String equipmentCode;
private String workerId;
private String status; // WAITING / RUNNING / DONE
private Double inputQty;
private Double outputQty;
private LocalDateTime startedAt;
private LocalDateTime endedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,61 @@
package com.zioinfo.mes.process;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.common.AuthSupport;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 공정 진행/작업자 배정 API (ProcessBoard 칸반용). 조회 Viewer+, 진행 입력 Worker+.
*/
@RestController
@RequestMapping("/api/mes/process")
@RequiredArgsConstructor
public class ProcessController {
private final ProcessMapper mapper;
private final AuditService audit;
@GetMapping("/workorder/{workorderId}")
public ApiResponse<List<MesProcessProgress>> byWorkorder(@PathVariable Long workorderId) {
return ApiResponse.ok(mapper.findByWorkorder(workorderId));
}
/** 실시간 진행 중 공정(칸반 RUNNING 칼럼). */
@GetMapping("/running")
public ApiResponse<List<MesProcessProgress>> running() {
return ApiResponse.ok(mapper.findRunning());
}
@PostMapping
public ApiResponse<MesProcessProgress> create(@RequestBody MesProcessProgress p, Authentication auth) {
if (p.getWorkorderId() == null) throw new IllegalArgumentException("ERR-PROC-400: workorderId 필수");
if (p.getStatus() == null) p.setStatus("WAITING");
mapper.insert(p);
audit.log("PROCESS_CREATE", p.getWoNo(), "op=" + p.getOpSeq());
return ApiResponse.ok(mapper.findById(p.getId()));
}
/** 공정 상태 전이 — WAITING→RUNNING→DONE. Worker+. */
@PutMapping("/{id}/status")
public ApiResponse<MesProcessProgress> status(@PathVariable Long id, @RequestBody StatusRequest req) {
MesProcessProgress cur = mapper.findById(id);
if (cur == null) throw new RuntimeException("ERR-PROC-404: 공정 진행 없음");
mapper.updateStatus(id, req.status());
audit.log("PROCESS_STATUS", cur.getWoNo(), cur.getStatus() + " -> " + req.status());
return ApiResponse.ok(mapper.findById(id));
}
@PutMapping("/{id}")
public ApiResponse<MesProcessProgress> update(@PathVariable Long id, @RequestBody MesProcessProgress p, Authentication auth) {
p.setId(id);
mapper.update(p);
return ApiResponse.ok(mapper.findById(id));
}
record StatusRequest(String status) {}
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.mes.process;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ProcessMapper {
List<MesProcessProgress> findByWorkorder(@Param("workorderId") Long workorderId);
List<MesProcessProgress> findRunning();
MesProcessProgress findById(@Param("id") Long id);
int insert(MesProcessProgress p);
int update(MesProcessProgress p);
int updateStatus(@Param("id") Long id, @Param("status") String status);
}

View File

@ -0,0 +1,25 @@
package com.zioinfo.mes.production;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 생산실적 (mes_production) 작업지시에 대한 산출/불량 보고 + LOT 생성.
*/
@Data
public class MesProduction {
private Long id;
private Long workorderId;
private String woNo;
private String itemCode;
private String lotNo; // 생성된 산출 LOT
private Double goodQty; // 양품 산출
private Double defectQty; // 불량
private String defectCode; // 대표 불량코드
private String processCode;
private String equipmentCode;
private String workerId;
private String warehouseCode; // 산출 입고 창고
private LocalDateTime reportedAt;
private String createdBy;
}

View File

@ -0,0 +1,49 @@
package com.zioinfo.mes.production;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.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/mes/production")
@RequiredArgsConstructor
public class ProductionController {
private final ProductionService service;
@GetMapping
public ApiResponse<List<MesProduction>> list(@RequestParam(required = false) String woNo,
@RequestParam(required = false) String itemCode,
@RequestParam(defaultValue = "200") int limit) {
return ApiResponse.ok(service.list(woNo, itemCode, limit));
}
@GetMapping("/{id}")
public ApiResponse<MesProduction> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@GetMapping("/daily-output")
public ApiResponse<List<Map<String, Object>>> dailyOutput(@RequestParam(defaultValue = "14") int days) {
return ApiResponse.ok(service.dailyOutput(days));
}
@GetMapping("/defect-pareto")
public ApiResponse<List<Map<String, Object>>> defectPareto(@RequestParam(defaultValue = "30") int days) {
return ApiResponse.ok(service.defectPareto(days));
}
/** 생산실적 보고 — LOT 생성·자재 투입·산출 입고·작업지시 누적. Worker+. */
@PostMapping("/report")
public ApiResponse<MesProduction> report(@RequestBody MesProduction p, Authentication auth) {
return ApiResponse.ok(service.report(p, AuthSupport.actor(auth)));
}
}

View File

@ -0,0 +1,112 @@
package com.zioinfo.mes.production;
import com.zioinfo.mes.admin.AuditService;
import com.zioinfo.mes.bom.BomService;
import com.zioinfo.mes.inventory.InventoryService;
import com.zioinfo.mes.production.mapper.ProductionMapper;
import com.zioinfo.mes.workorder.MesWorkOrder;
import com.zioinfo.mes.workorder.mapper.WorkOrderMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
/**
* 생산실적 서비스 작업지시 산출 보고.
*
* <p>실적 보고 : 1) 산출 LOT 생성 2) BOM 소요 자재 투입(재고 차감 CONSUME)
* 3) 산출 양품 입고(재고 증가 PRODUCE) 4) 작업지시 누적 산출/불량 갱신.
* 작업지시는 RELEASED/INPROGRESS 상태에서만 실적 보고 가능(보고 INPROGRESS 자동 전이).
*/
@Service
@RequiredArgsConstructor
public class ProductionService {
private final ProductionMapper mapper;
private final WorkOrderMapper woMapper;
private final BomService bomService;
private final InventoryService inventory;
private final AuditService audit;
public List<MesProduction> list(String woNo, String itemCode, int limit) {
return mapper.findAll(woNo, itemCode, limit <= 0 ? 200 : limit);
}
public MesProduction get(Long id) {
MesProduction p = mapper.findById(id);
if (p == null) throw new RuntimeException("ERR-PROD-404: 생산실적 없음");
return p;
}
public List<Map<String, Object>> dailyOutput(int days) {
return mapper.dailyOutput(days <= 0 ? 14 : days);
}
public List<Map<String, Object>> defectPareto(int days) {
return mapper.defectPareto(days <= 0 ? 30 : days);
}
/**
* 생산실적 보고 LOT 생성 + 자재 투입 + 산출 입고 + 작업지시 누적.
*/
@Transactional
public MesProduction report(MesProduction p, String actor) {
if (p.getWorkorderId() == null)
throw new IllegalArgumentException("ERR-PROD-400: workorderId 필수");
double good = p.getGoodQty() == null ? 0 : p.getGoodQty();
double defect = p.getDefectQty() == null ? 0 : p.getDefectQty();
if (good <= 0 && defect <= 0)
throw new IllegalArgumentException("ERR-PROD-400: 양품 또는 불량 수량이 필요합니다");
MesWorkOrder wo = woMapper.findById(p.getWorkorderId());
if (wo == null) throw new RuntimeException("ERR-PROD-404: 작업지시 없음");
if (!java.util.Set.of("RELEASED", "INPROGRESS").contains(wo.getStatus())) {
throw new RuntimeException("ERR-PROD-409: RELEASED/INPROGRESS 작업지시만 실적 보고 가능(현재 " + wo.getStatus() + ")");
}
if ("RELEASED".equals(wo.getStatus())) {
woMapper.updateStatus(wo.getId(), "INPROGRESS");
}
p.setWoNo(wo.getWoNo());
p.setItemCode(wo.getItemCode());
String wh = p.getWarehouseCode() != null ? p.getWarehouseCode() : wo.getWarehouseCode();
p.setWarehouseCode(wh);
if (p.getLotNo() == null || p.getLotNo().isBlank()) {
p.setLotNo(generateLot(wo.getItemCode()));
}
p.setCreatedBy(actor);
mapper.insert(p);
double totalInput = good + defect;
// 1) BOM 자재 투입(재고 차감) 소요량 = 산출수량 기준. 재고 부족 트랜잭션 롤백.
if (totalInput > 0) {
Map<String, Double> req = bomService.materialRequirement(wo.getItemCode(), totalInput, null);
for (Map.Entry<String, Double> e : req.entrySet()) {
inventory.outbound("CONSUME", e.getKey(), wh, null, null, e.getValue(),
"WORKORDER", wo.getWoNo(), actor);
}
}
// 2) 산출 양품 입고(재고 증가, 산출 LOT)
if (good > 0) {
inventory.inbound("PRODUCE", wo.getItemCode(), wh, null, p.getLotNo(), good,
"WORKORDER", wo.getWoNo(), actor);
}
// 3) 작업지시 누적 산출/불량
woMapper.addProduced(wo.getId(), good, defect);
audit.log("PRODUCTION_REPORT", wo.getWoNo(),
"good=" + good + " defect=" + defect + " lot=" + p.getLotNo());
return mapper.findById(p.getId());
}
private String generateLot(String itemCode) {
String prefix = itemCode == null ? "LOT" : itemCode.replaceAll("[^A-Za-z0-9]", "");
if (prefix.length() > 8) prefix = prefix.substring(0, 8);
return prefix + "-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE)
+ "-" + (System.currentTimeMillis() % 100000);
}
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.mes.production.mapper;
import com.zioinfo.mes.production.MesProduction;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface ProductionMapper {
List<MesProduction> findAll(@Param("woNo") String woNo,
@Param("itemCode") String itemCode,
@Param("limit") int limit);
MesProduction findById(@Param("id") Long id);
List<MesProduction> findByWorkorder(@Param("workorderId") Long workorderId);
int insert(MesProduction p);
/** 일자별 생산/불량 집계(대시보드). */
List<Map<String, Object>> dailyOutput(@Param("days") int days);
/** 불량코드별 집계(파레토). */
List<Map<String, Object>> defectPareto(@Param("days") int days);
}

View File

@ -0,0 +1,66 @@
package com.zioinfo.mes.progress;
import com.zioinfo.mes.common.ApiResponse;
import com.zioinfo.mes.progress.mapper.ProgressMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 진행관리 API 작업지시/공정 진척률·계획대비실적·실시간 생산현황·지연 알림.
*
* <p>읽기 전용(조회 Viewer+). 기존 작업지시/공정/작업/계획 데이터를 집계한다.
*/
@RestController
@RequestMapping("/api/mes/progress")
@RequiredArgsConstructor
public class ProgressController {
private final ProgressMapper mapper;
/** 전체 진척 요약 KPI(활성/진행/지연 작업지시·가동 공정·평균 진척률). */
@GetMapping("/summary")
public ApiResponse<Map<String, Object>> summary() {
return ApiResponse.ok(mapper.progressSummary());
}
/** 작업지시별 진척률 목록(status 미지정 시 RELEASED/INPROGRESS/DONE). */
@GetMapping("/workorders")
public ApiResponse<List<Map<String, Object>>> workorders(@RequestParam(required = false) String status) {
return ApiResponse.ok(mapper.workorderProgress(status));
}
/** 단일 작업지시 진척 상세 + 공정 진행 목록. */
@GetMapping("/workorders/{id}")
public ApiResponse<Map<String, Object>> workorderDetail(@PathVariable Long id) {
Map<String, Object> wo = mapper.workorderProgressOne(id);
if (wo == null) throw new RuntimeException("ERR-PROG-404: 작업지시 없음");
Map<String, Object> out = new LinkedHashMap<>(wo);
out.put("processes", mapper.processProgressByWo(id));
return ApiResponse.ok(out);
}
/** 실시간 생산현황(가동중 RUNNING 공정). */
@GetMapping("/live")
public ApiResponse<List<Map<String, Object>>> live() {
return ApiResponse.ok(mapper.liveProcess());
}
/** 계획 대비 실적(품목별). */
@GetMapping("/plan-vs-actual")
public ApiResponse<List<Map<String, Object>>> planVsActual(@RequestParam(defaultValue = "30") int days) {
return ApiResponse.ok(mapper.planVsActual(days));
}
/** 지연 알림(작업지시 + 작업). */
@GetMapping("/delays")
public ApiResponse<Map<String, Object>> delays() {
Map<String, Object> m = new LinkedHashMap<>();
m.put("workorders", mapper.delayedWorkorders());
m.put("jobs", mapper.delayedJobs());
return ApiResponse.ok(m);
}
}

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