feat(mes): GUARDiA MES v1.0 WMS/MES/QMS
This commit is contained in:
commit
a8e0d6ecf1
59
Jenkinsfile
vendored
Normal file
59
Jenkinsfile
vendored
Normal 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
5
backend/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
target/
|
||||||
|
*.class
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.DS_Store
|
||||||
82
backend/pom.xml
Normal file
82
backend/pom.xml
Normal 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>
|
||||||
26
backend/src/main/java/com/zioinfo/mes/MesApplication.java
Normal file
26
backend/src/main/java/com/zioinfo/mes/MesApplication.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
28
backend/src/main/java/com/zioinfo/mes/admin/dto/UserDto.java
Normal file
28
backend/src/main/java/com/zioinfo/mes/admin/dto/UserDto.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
78
backend/src/main/java/com/zioinfo/mes/ai/AiController.java
Normal file
78
backend/src/main/java/com/zioinfo/mes/ai/AiController.java
Normal 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) {}
|
||||||
|
}
|
||||||
258
backend/src/main/java/com/zioinfo/mes/ai/AiService.java
Normal file
258
backend/src/main/java/com/zioinfo/mes/ai/AiService.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
94
backend/src/main/java/com/zioinfo/mes/ai/OllamaClient.java
Normal file
94
backend/src/main/java/com/zioinfo/mes/ai/OllamaClient.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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) {}
|
||||||
|
}
|
||||||
40
backend/src/main/java/com/zioinfo/mes/auth/AuthService.java
Normal file
40
backend/src/main/java/com/zioinfo/mes/auth/AuthService.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
40
backend/src/main/java/com/zioinfo/mes/auth/JwtFilter.java
Normal file
40
backend/src/main/java/com/zioinfo/mes/auth/JwtFilter.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
backend/src/main/java/com/zioinfo/mes/auth/JwtUtil.java
Normal file
59
backend/src/main/java/com/zioinfo/mes/auth/JwtUtil.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
20
backend/src/main/java/com/zioinfo/mes/auth/MesUser.java
Normal file
20
backend/src/main/java/com/zioinfo/mes/auth/MesUser.java
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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) {}
|
||||||
|
}
|
||||||
67
backend/src/main/java/com/zioinfo/mes/bom/BomController.java
Normal file
67
backend/src/main/java/com/zioinfo/mes/bom/BomController.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
backend/src/main/java/com/zioinfo/mes/bom/BomService.java
Normal file
120
backend/src/main/java/com/zioinfo/mes/bom/BomService.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
24
backend/src/main/java/com/zioinfo/mes/bom/MesBom.java
Normal file
24
backend/src/main/java/com/zioinfo/mes/bom/MesBom.java
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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) {}
|
||||||
|
}
|
||||||
28
backend/src/main/java/com/zioinfo/mes/capa/MesCapa.java
Normal file
28
backend/src/main/java/com/zioinfo/mes/capa/MesCapa.java
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 이상만 가능합니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
93
backend/src/main/java/com/zioinfo/mes/common/CryptoUtil.java
Normal file
93
backend/src/main/java/com/zioinfo/mes/common/CryptoUtil.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
25
backend/src/main/java/com/zioinfo/mes/config/CorsConfig.java
Normal file
25
backend/src/main/java/com/zioinfo/mes/config/CorsConfig.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
@ -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) {}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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) {}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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) {}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
70
backend/src/main/java/com/zioinfo/mes/item/ItemService.java
Normal file
70
backend/src/main/java/com/zioinfo/mes/item/ItemService.java
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
27
backend/src/main/java/com/zioinfo/mes/item/MesItem.java
Normal file
27
backend/src/main/java/com/zioinfo/mes/item/MesItem.java
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
88
backend/src/main/java/com/zioinfo/mes/job/JobController.java
Normal file
88
backend/src/main/java/com/zioinfo/mes/job/JobController.java
Normal 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) {}
|
||||||
|
}
|
||||||
140
backend/src/main/java/com/zioinfo/mes/job/JobService.java
Normal file
140
backend/src/main/java/com/zioinfo/mes/job/JobService.java
Normal 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(), "");
|
||||||
|
}
|
||||||
|
}
|
||||||
36
backend/src/main/java/com/zioinfo/mes/job/MesJob.java
Normal file
36
backend/src/main/java/com/zioinfo/mes/job/MesJob.java
Normal 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;
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
@ -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) {}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
backend/src/main/java/com/zioinfo/mes/ncr/MesNcr.java
Normal file
29
backend/src/main/java/com/zioinfo/mes/ncr/MesNcr.java
Normal 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;
|
||||||
|
}
|
||||||
66
backend/src/main/java/com/zioinfo/mes/ncr/NcrController.java
Normal file
66
backend/src/main/java/com/zioinfo/mes/ncr/NcrController.java
Normal 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) {}
|
||||||
|
}
|
||||||
98
backend/src/main/java/com/zioinfo/mes/ncr/NcrService.java
Normal file
98
backend/src/main/java/com/zioinfo/mes/ncr/NcrService.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
29
backend/src/main/java/com/zioinfo/mes/oee/MesOeeRecord.java
Normal file
29
backend/src/main/java/com/zioinfo/mes/oee/MesOeeRecord.java
Normal 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;
|
||||||
|
}
|
||||||
48
backend/src/main/java/com/zioinfo/mes/oee/OeeController.java
Normal file
48
backend/src/main/java/com/zioinfo/mes/oee/OeeController.java
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
72
backend/src/main/java/com/zioinfo/mes/oee/OeeService.java
Normal file
72
backend/src/main/java/com/zioinfo/mes/oee/OeeService.java
Normal 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; }
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
21
backend/src/main/java/com/zioinfo/mes/plan/MesPlan.java
Normal file
21
backend/src/main/java/com/zioinfo/mes/plan/MesPlan.java
Normal 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;
|
||||||
|
}
|
||||||
@ -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) {}
|
||||||
|
}
|
||||||
16
backend/src/main/java/com/zioinfo/mes/plan/PlanMapper.java
Normal file
16
backend/src/main/java/com/zioinfo/mes/plan/PlanMapper.java
Normal 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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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) {}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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
Loading…
Reference in New Issue
Block a user