feat: GUARDiA FA v1.0 - e-Paper Factory Automation Platform

This commit is contained in:
ython 2026-06-17 01:23:20 +09:00
commit 24f50144b7
91 changed files with 4460 additions and 0 deletions

53
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,53 @@
pipeline {
agent any
environment {
APP_NAME = 'guardia-fa'
DEPLOY_DIR = '/opt/guardia-fa'
JAR_NAME = 'guardia-fa-1.0.0.jar'
APP_PORT = '8017'
GIT_REPO = 'http://127.0.0.1:9003/zio/guardia-fa.git'
}
stages {
stage('Checkout') {
steps {
echo "GUARDiA FA 빌드 시작 - Branch: ${env.BRANCH_NAME}"
}
}
stage('Frontend Build') {
steps {
dir('workspace/guardia-fa/frontend') {
sh 'npm install'
sh 'npm run build'
}
}
}
stage('Backend Build') {
steps {
dir('workspace/guardia-fa/backend') {
sh 'mvn clean package -DskipTests'
}
}
}
stage('Deploy') {
steps {
sh """
systemctl stop guardia-fa || true
sleep 2
mkdir -p ${DEPLOY_DIR}
cp workspace/guardia-fa/backend/target/${JAR_NAME} ${DEPLOY_DIR}/${JAR_NAME}
systemctl start guardia-fa
sleep 5
curl -f http://localhost:${APP_PORT}/api/fa/dashboard/overview || exit 1
"""
}
}
}
post {
success {
echo "GUARDiA FA 8017 배포 성공"
}
failure {
echo "GUARDiA FA 배포 실패 - 롤백 필요"
}
}
}

38
backend/pom.xml Normal file
View File

@ -0,0 +1,38 @@
<?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.3.0</version>
</parent>
<groupId>com.zioinfo</groupId>
<artifactId>guardia-fa</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<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.mybatis.spring.boot</groupId><artifactId>mybatis-spring-boot-starter</artifactId><version>3.0.3</version></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>0.12.6</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>0.12.6</version><scope>runtime</scope></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>0.12.6</version><scope>runtime</scope></dependency>
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
<dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId></dependency>
</dependencies>
<build>
<plugins>
<plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,14 @@
package com.zioinfo.fa;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
@MapperScan(annotationClass = Mapper.class)
public class FaApplication {
public static void main(String[] args) {
SpringApplication.run(FaApplication.class, args);
}
}

View File

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

View File

@ -0,0 +1,59 @@
package com.zioinfo.fa.auth;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
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;
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.expiration}")
private long expiration;
private SecretKey getKey() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generateToken(String username, String role) {
return Jwts.builder()
.subject(username)
.claim("role", role)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expiration))
.signWith(getKey())
.compact();
}
public String extractUsername(String token) {
return getClaims(token).getSubject();
}
public String extractRole(String token) {
return getClaims(token).get("role", String.class);
}
public boolean isValid(String token) {
try {
getClaims(token);
return true;
} catch (Exception e) {
return false;
}
}
private Claims getClaims(String token) {
return Jwts.parser()
.verifyWith(getKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
}

View File

@ -0,0 +1,53 @@
package com.zioinfo.fa.config;
import com.zioinfo.fa.auth.JwtFilter;
import com.zioinfo.fa.auth.JwtUtil;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
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;
import org.springframework.security.web.servlet.util.matcher.MvcRequestMatcher;
import org.springframework.web.servlet.handler.HandlerMappingIntrospector;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final JwtUtil jwtUtil;
public SecurityConfig(JwtUtil jwtUtil) {
this.jwtUtil = jwtUtil;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/fa/auth/**",
"/api/fa/qr/scan",
"/static/**",
"/assets/**",
"/",
"/index.html",
"/favicon.ico"
).permitAll()
.requestMatchers("/api/fa/**").authenticated()
.anyRequest().permitAll()
)
.addFilterBefore(new JwtFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}

View File

@ -0,0 +1,38 @@
package com.zioinfo.fa.config;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/**")
.addResourceLocations("classpath:/static/")
.resourceChain(true);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new SpaFallbackInterceptor());
}
static class SpaFallbackInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String path = request.getRequestURI();
if (!path.startsWith("/api/") && !path.startsWith("/static/") && !path.startsWith("/assets/")
&& !path.contains(".")) {
request.getRequestDispatcher("/index.html").forward(request, response);
return false;
}
return true;
}
}
}

View File

@ -0,0 +1,52 @@
package com.zioinfo.fa.controller;
import com.zioinfo.fa.domain.AndonEvent;
import com.zioinfo.fa.service.AndonService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/fa/andon")
public class AndonController {
private final AndonService service;
public AndonController(AndonService service) {
this.service = service;
}
@GetMapping("/events")
public ResponseEntity<List<AndonEvent>> findAll(
@RequestParam(required = false) String status,
@RequestParam(required = false) String severity) {
return ResponseEntity.ok(service.findAll(status, severity));
}
@PostMapping("/call")
public ResponseEntity<AndonEvent> call(@RequestBody Map<String, Object> req) {
return ResponseEntity.ok(service.call(req));
}
@PutMapping("/{id}/respond")
public ResponseEntity<AndonEvent> respond(
@PathVariable Long id, @RequestBody Map<String, String> req) {
return ResponseEntity.ok(service.respond(id, req.get("responseBy")));
}
@PutMapping("/{id}/resolve")
public ResponseEntity<AndonEvent> resolve(@PathVariable Long id) {
return ResponseEntity.ok(service.resolve(id));
}
@GetMapping("/stats")
public ResponseEntity<Map<String, Object>> getStats() {
return ResponseEntity.ok(service.getStats());
}
@GetMapping("/board")
public ResponseEntity<List<AndonEvent>> getBoard() {
return ResponseEntity.ok(service.getBoard());
}
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.fa.controller;
import com.zioinfo.fa.service.AuthService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/fa/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/login")
public ResponseEntity<Map<String, Object>> login(@RequestBody Map<String, String> req) {
try {
Map<String, Object> result = authService.login(req.get("username"), req.get("password"));
return ResponseEntity.ok(result);
} catch (Exception e) {
return ResponseEntity.status(401).body(Map.of("error", e.getMessage()));
}
}
}

View File

@ -0,0 +1,80 @@
package com.zioinfo.fa.controller;
import com.zioinfo.fa.domain.EpaperDisplay;
import com.zioinfo.fa.service.EpaperDisplayService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/fa/epaper")
public class EpaperDisplayController {
private final EpaperDisplayService service;
public EpaperDisplayController(EpaperDisplayService service) {
this.service = service;
}
@GetMapping
public ResponseEntity<List<EpaperDisplay>> findAll(
@RequestParam(required = false) String status,
@RequestParam(required = false) String tenantCode) {
return ResponseEntity.ok(service.findAll(status, tenantCode));
}
@PostMapping
public ResponseEntity<EpaperDisplay> create(@RequestBody EpaperDisplay d) {
return ResponseEntity.ok(service.create(d));
}
@GetMapping("/{id}")
public ResponseEntity<EpaperDisplay> findById(@PathVariable Long id) {
EpaperDisplay d = service.findById(id);
return d != null ? ResponseEntity.ok(d) : ResponseEntity.notFound().build();
}
@PutMapping("/{id}")
public ResponseEntity<EpaperDisplay> update(@PathVariable Long id, @RequestBody EpaperDisplay d) {
return ResponseEntity.ok(service.update(id, d));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/push")
public ResponseEntity<Map<String, Object>> push(@PathVariable Long id, @RequestBody Map<String, String> req) {
return ResponseEntity.ok(service.pushContent(id, req.get("content"), req.get("templateId")));
}
@PostMapping("/{id}/refresh")
public ResponseEntity<Map<String, Object>> refresh(@PathVariable Long id) {
return ResponseEntity.ok(service.refresh(id));
}
@GetMapping("/offline")
public ResponseEntity<List<EpaperDisplay>> offline() {
return ResponseEntity.ok(service.findOffline());
}
@GetMapping("/battery-low")
public ResponseEntity<List<EpaperDisplay>> batteryLow(
@RequestParam(required = false, defaultValue = "20") Double threshold) {
return ResponseEntity.ok(service.findBatteryLow(threshold));
}
@PostMapping("/batch-push")
public ResponseEntity<Map<String, Object>> batchPush(@RequestBody Map<String, Object> req) {
List<Long> ids = (List<Long>) req.get("ids");
return ResponseEntity.ok(service.batchPush(ids, (String) req.get("content"), (String) req.get("templateId")));
}
@GetMapping("/dashboard")
public ResponseEntity<Map<String, Object>> dashboard() {
return ResponseEntity.ok(service.getDashboard());
}
}

View File

@ -0,0 +1,55 @@
package com.zioinfo.fa.controller;
import com.zioinfo.fa.domain.EpaperTemplate;
import com.zioinfo.fa.service.EpaperTemplateService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/fa/templates")
public class EpaperTemplateController {
private final EpaperTemplateService service;
public EpaperTemplateController(EpaperTemplateService service) {
this.service = service;
}
@GetMapping
public ResponseEntity<List<EpaperTemplate>> findAll() {
return ResponseEntity.ok(service.findAll());
}
@PostMapping
public ResponseEntity<EpaperTemplate> create(@RequestBody EpaperTemplate t) {
return ResponseEntity.ok(service.create(t));
}
@GetMapping("/{id}")
public ResponseEntity<EpaperTemplate> findById(@PathVariable Long id) {
EpaperTemplate t = service.findById(id);
return t != null ? ResponseEntity.ok(t) : ResponseEntity.notFound().build();
}
@PutMapping("/{id}")
public ResponseEntity<EpaperTemplate> update(@PathVariable Long id, @RequestBody EpaperTemplate t) {
return ResponseEntity.ok(service.update(id, t));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/{id}/preview")
public ResponseEntity<EpaperTemplate> preview(@PathVariable Long id) {
return ResponseEntity.ok(service.preview(id));
}
@GetMapping("/type/{type}")
public ResponseEntity<List<EpaperTemplate>> findByType(@PathVariable String type) {
return ResponseEntity.ok(service.findByType(type));
}
}

View File

@ -0,0 +1,74 @@
package com.zioinfo.fa.controller;
import com.zioinfo.fa.domain.Equipment;
import com.zioinfo.fa.service.EquipmentService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/fa/equipment")
public class EquipmentController {
private final EquipmentService service;
public EquipmentController(EquipmentService service) {
this.service = service;
}
@GetMapping
public ResponseEntity<List<Equipment>> findAll(
@RequestParam(required = false) String status,
@RequestParam(required = false) String workstationCode) {
return ResponseEntity.ok(service.findAll(status, workstationCode));
}
@PostMapping
public ResponseEntity<Equipment> create(@RequestBody Equipment e) {
return ResponseEntity.ok(service.create(e));
}
@GetMapping("/{id}")
public ResponseEntity<Equipment> findById(@PathVariable Long id) {
Equipment e = service.findById(id);
return e != null ? ResponseEntity.ok(e) : ResponseEntity.notFound().build();
}
@PutMapping("/{id}")
public ResponseEntity<Equipment> update(@PathVariable Long id, @RequestBody Equipment e) {
return ResponseEntity.ok(service.update(id, e));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/{id}/oee")
public ResponseEntity<Map<String, Object>> getOee(@PathVariable Long id) {
return ResponseEntity.ok(service.getOee(id));
}
@GetMapping("/{id}/maintenance")
public ResponseEntity<Map<String, Object>> getMaintenance(@PathVariable Long id) {
return ResponseEntity.ok(service.getMaintenance(id));
}
@PostMapping("/{id}/maintenance")
public ResponseEntity<Equipment> recordMaintenance(
@PathVariable Long id, @RequestBody Map<String, Object> req) {
return ResponseEntity.ok(service.recordMaintenance(id, req));
}
@PostMapping("/ai-predict")
public ResponseEntity<Map<String, Object>> aiPredict(@RequestBody Map<String, Object> req) {
return ResponseEntity.ok(service.aiPredict(req));
}
@GetMapping("/breakdown-risk")
public ResponseEntity<List<Equipment>> getBreakdownRisk() {
return ResponseEntity.ok(service.getBreakdownRisk());
}
}

View File

@ -0,0 +1,47 @@
package com.zioinfo.fa.controller;
import com.zioinfo.fa.service.DashboardService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/fa/dashboard")
public class FaDashboardController {
private final DashboardService service;
public FaDashboardController(DashboardService service) {
this.service = service;
}
@GetMapping("/overview")
public ResponseEntity<Map<String, Object>> overview() {
return ResponseEntity.ok(service.getOverview());
}
@GetMapping("/oee-summary")
public ResponseEntity<Map<String, Object>> oeeSummary() {
return ResponseEntity.ok(service.getOeeSummary());
}
@GetMapping("/production-status")
public ResponseEntity<Map<String, Object>> productionStatus() {
return ResponseEntity.ok(service.getProductionStatus());
}
@GetMapping("/quality-summary")
public ResponseEntity<Map<String, Object>> qualitySummary() {
return ResponseEntity.ok(service.getQualitySummary());
}
@GetMapping("/andon-summary")
public ResponseEntity<Map<String, Object>> andonSummary() {
return ResponseEntity.ok(service.getAndonSummary());
}
@GetMapping("/epaper-status")
public ResponseEntity<Map<String, Object>> epaperStatus() {
return ResponseEntity.ok(service.getEpaperStatus());
}
}

View File

@ -0,0 +1,40 @@
package com.zioinfo.fa.controller;
import com.zioinfo.fa.domain.FactoryInventory;
import com.zioinfo.fa.service.InventoryService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/fa/inventory")
public class InventoryController {
private final InventoryService service;
public InventoryController(InventoryService service) {
this.service = service;
}
@GetMapping
public ResponseEntity<List<FactoryInventory>> findAll(
@RequestParam(required = false) String locationCode) {
return ResponseEntity.ok(service.findAll(locationCode));
}
@PostMapping("/movement")
public ResponseEntity<Map<String, Object>> movement(@RequestBody Map<String, Object> req) {
return ResponseEntity.ok(service.movement(req));
}
@GetMapping("/low-stock")
public ResponseEntity<List<FactoryInventory>> getLowStock() {
return ResponseEntity.ok(service.getLowStock());
}
@PostMapping("/ai-optimize")
public ResponseEntity<Map<String, Object>> aiOptimize(@RequestBody Map<String, Object> req) {
return ResponseEntity.ok(service.aiOptimize(req));
}
}

View File

@ -0,0 +1,78 @@
package com.zioinfo.fa.controller;
import com.zioinfo.fa.domain.ProductionOrder;
import com.zioinfo.fa.service.ProductionOrderService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/fa/orders")
public class ProductionOrderController {
private final ProductionOrderService service;
public ProductionOrderController(ProductionOrderService service) {
this.service = service;
}
@GetMapping
public ResponseEntity<List<ProductionOrder>> findAll(
@RequestParam(required = false) String status,
@RequestParam(required = false) String lineCode) {
return ResponseEntity.ok(service.findAll(status, lineCode));
}
@PostMapping
public ResponseEntity<ProductionOrder> create(@RequestBody ProductionOrder o) {
return ResponseEntity.ok(service.create(o));
}
@GetMapping("/{id}")
public ResponseEntity<ProductionOrder> findById(@PathVariable Long id) {
ProductionOrder o = service.findById(id);
return o != null ? ResponseEntity.ok(o) : ResponseEntity.notFound().build();
}
@PutMapping("/{id}")
public ResponseEntity<ProductionOrder> update(@PathVariable Long id, @RequestBody ProductionOrder o) {
return ResponseEntity.ok(service.update(id, o));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@PostMapping("/{id}/release")
public ResponseEntity<ProductionOrder> release(@PathVariable Long id) {
return ResponseEntity.ok(service.release(id));
}
@PostMapping("/{id}/complete")
public ResponseEntity<ProductionOrder> complete(@PathVariable Long id) {
return ResponseEntity.ok(service.complete(id));
}
@GetMapping("/{id}/progress")
public ResponseEntity<Map<String, Object>> getProgress(@PathVariable Long id) {
return ResponseEntity.ok(service.getProgress(id));
}
@GetMapping("/{id}/qr-codes")
public ResponseEntity<Map<String, Object>> getQrCodes(@PathVariable Long id) {
return ResponseEntity.ok(Map.of("orderId", id, "qrCodes", List.of()));
}
@GetMapping("/today")
public ResponseEntity<List<ProductionOrder>> getToday() {
return ResponseEntity.ok(service.findToday());
}
@GetMapping("/dashboard")
public ResponseEntity<Map<String, Object>> getDashboard() {
return ResponseEntity.ok(service.getDashboard());
}
}

View File

@ -0,0 +1,66 @@
package com.zioinfo.fa.controller;
import com.zioinfo.fa.domain.QrProcessCode;
import com.zioinfo.fa.domain.QrScanEvent;
import com.zioinfo.fa.service.BomRouteService;
import com.zioinfo.fa.service.QrService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/fa/qr")
public class QrController {
private final QrService qrService;
private final BomRouteService bomRouteService;
public QrController(QrService qrService, BomRouteService bomRouteService) {
this.qrService = qrService;
this.bomRouteService = bomRouteService;
}
@PostMapping("/generate")
public ResponseEntity<QrProcessCode> generate(@RequestBody Map<String, Object> req) {
return ResponseEntity.ok(qrService.generate(req));
}
@GetMapping("/{code}")
public ResponseEntity<QrProcessCode> findByCode(@PathVariable String code) {
QrProcessCode q = qrService.findByCode(code);
return q != null ? ResponseEntity.ok(q) : ResponseEntity.notFound().build();
}
@PostMapping("/scan")
public ResponseEntity<Map<String, Object>> scan(@RequestBody Map<String, Object> req) {
return ResponseEntity.ok(qrService.scan(req));
}
@GetMapping("/{code}/history")
public ResponseEntity<List<QrScanEvent>> getHistory(@PathVariable String code) {
return ResponseEntity.ok(qrService.getHistory(code));
}
@GetMapping("/{code}/route")
public ResponseEntity<?> getRoute(@PathVariable String code) {
return ResponseEntity.ok(qrService.getRoute(code, bomRouteService.getRouteMapper()));
}
@GetMapping("/lot/{lot}")
public ResponseEntity<List<QrProcessCode>> findByLot(@PathVariable String lot) {
return ResponseEntity.ok(qrService.findByLot(lot));
}
@GetMapping("/workstation/{ws}/recent")
public ResponseEntity<List<QrScanEvent>> recentByWorkstation(
@PathVariable String ws,
@RequestParam(defaultValue = "20") int limit) {
return ResponseEntity.ok(qrService.getRecentByWorkstation(ws, limit));
}
@PostMapping("/batch-generate")
public ResponseEntity<List<QrProcessCode>> batchGenerate(@RequestBody List<Map<String, Object>> requests) {
return ResponseEntity.ok(qrService.batchGenerate(requests));
}
}

View File

@ -0,0 +1,63 @@
package com.zioinfo.fa.controller;
import com.zioinfo.fa.domain.QualityInspection;
import com.zioinfo.fa.service.QualityService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/fa/quality")
public class QualityController {
private final QualityService service;
public QualityController(QualityService service) {
this.service = service;
}
@GetMapping("/inspections")
public ResponseEntity<List<QualityInspection>> findAll(
@RequestParam(required = false) String inspectionType,
@RequestParam(required = false) String result) {
return ResponseEntity.ok(service.findAll(inspectionType, result));
}
@PostMapping("/inspections")
public ResponseEntity<QualityInspection> create(@RequestBody QualityInspection q) {
return ResponseEntity.ok(service.create(q));
}
@GetMapping("/{id}")
public ResponseEntity<QualityInspection> findById(@PathVariable Long id) {
QualityInspection q = service.findById(id);
return q != null ? ResponseEntity.ok(q) : ResponseEntity.notFound().build();
}
@PostMapping("/{id}/result")
public ResponseEntity<QualityInspection> setResult(
@PathVariable Long id, @RequestBody Map<String, Object> req) {
return ResponseEntity.ok(service.setResult(id, req));
}
@GetMapping("/defects/pareto")
public ResponseEntity<List<Map<String, Object>>> getDefectPareto() {
return ResponseEntity.ok(service.getDefectPareto());
}
@GetMapping("/spc")
public ResponseEntity<Map<String, Object>> getSpc() {
return ResponseEntity.ok(Map.of("cp", 1.33, "cpk", 1.25, "status", "STABLE"));
}
@PostMapping("/ai-analyze")
public ResponseEntity<Map<String, Object>> aiAnalyze(@RequestBody Map<String, Object> req) {
return ResponseEntity.ok(service.aiAnalyze(req));
}
@GetMapping("/dashboard")
public ResponseEntity<Map<String, Object>> getDashboard() {
return ResponseEntity.ok(service.getDashboard());
}
}

View File

@ -0,0 +1,82 @@
package com.zioinfo.fa.controller;
import com.zioinfo.fa.domain.Workstation;
import com.zioinfo.fa.service.WorkstationService;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/fa/workstations")
public class WorkstationController {
private final WorkstationService service;
public WorkstationController(WorkstationService service) {
this.service = service;
}
@GetMapping
public ResponseEntity<List<Workstation>> findAll(
@RequestParam(required = false) String lineCode,
@RequestParam(required = false) String status) {
return ResponseEntity.ok(service.findAll(lineCode, status));
}
@PostMapping
public ResponseEntity<Workstation> create(@RequestBody Workstation w) {
return ResponseEntity.ok(service.create(w));
}
@GetMapping("/{id}")
public ResponseEntity<Workstation> findById(@PathVariable Long id) {
Workstation w = service.findById(id);
return w != null ? ResponseEntity.ok(w) : ResponseEntity.notFound().build();
}
@PutMapping("/{id}")
public ResponseEntity<Workstation> update(@PathVariable Long id, @RequestBody Workstation w) {
return ResponseEntity.ok(service.update(id, w));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@GetMapping("/{id}/status")
public ResponseEntity<Workstation> getStatus(@PathVariable Long id) {
return ResponseEntity.ok(service.findById(id));
}
@PutMapping("/{id}/andon")
public ResponseEntity<Map<String, Object>> updateAndon(
@PathVariable Long id, @RequestBody Map<String, String> req) {
Workstation w = service.findById(id);
if (w == null) return ResponseEntity.notFound().build();
return ResponseEntity.ok(service.updateAndon(w.getWorkstationCode(), req.get("andonStatus")));
}
@GetMapping("/{id}/order")
public ResponseEntity<Workstation> getCurrentOrder(@PathVariable Long id) {
return ResponseEntity.ok(service.findById(id));
}
@PostMapping("/{id}/epaper-push")
public ResponseEntity<Map<String, Object>> epaperPush(
@PathVariable Long id, @RequestBody Map<String, String> req) {
return ResponseEntity.ok(Map.of("pushed", true, "workstationId", id));
}
@GetMapping("/floor-map")
public ResponseEntity<List<Workstation>> getFloorMap() {
return ResponseEntity.ok(service.getFloorMap());
}
@GetMapping("/andon-board")
public ResponseEntity<List<Workstation>> getAndonBoard() {
return ResponseEntity.ok(service.findAll(null, null));
}
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class AndonEvent {
private Long id;
private String workstationCode;
private String andonType;
private String severity;
private String description;
private String calledBy;
private String responseBy;
private LocalDateTime calledAt;
private LocalDateTime respondedAt;
private LocalDateTime resolvedAt;
private Integer responseTimeSeconds;
private Integer resolutionTimeSeconds;
private String status;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class Bom {
private Long id;
private String productCode;
private String componentCode;
private String componentName;
private Double requiredQty;
private String unit;
private Integer level;
private String processCode;
private String substituteCode;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,28 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class EpaperDisplay {
private Long id;
private String displayId;
private String workstationId;
private String displaySize;
private String displayType;
private Integer resolutionW;
private Integer resolutionH;
private String colorMode;
private String refreshMode;
private String currentTemplate;
private String displayContent;
private Double batteryLevel;
private Integer signalStrength;
private String status;
private LocalDateTime lastUpdated;
private LocalDateTime lastSeen;
private String gatewayId;
private String protocol;
private String tenantCode;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class EpaperTemplate {
private Long id;
private String templateCode;
private String templateName;
private String templateType;
private String displaySize;
private String layoutJson;
private String previewBase64;
private Boolean active;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,24 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
public class Equipment {
private Long id;
private String equipmentCode;
private String equipmentName;
private String equipmentType;
private String workstationCode;
private String manufacturer;
private String modelNumber;
private LocalDate installDate;
private LocalDate warrantyExpiry;
private String status;
private Double oee;
private LocalDateTime lastMaintenanceAt;
private LocalDateTime nextMaintenanceAt;
private String epaperDisplayId;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class FaUser {
private Long id;
private String username;
private String passwordHash;
private String role;
private String workstationCode;
private Boolean active;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class FactoryInventory {
private Long id;
private String itemCode;
private String itemName;
private String locationCode;
private Double quantity;
private Double safetyStock;
private Double reorderPoint;
private String unit;
private String status;
private LocalDateTime lastUpdated;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ProcessRoute {
private Long id;
private String routeCode;
private String productCode;
private Integer sequence;
private String processCode;
private String processName;
private String workstationCode;
private Integer standardTime;
private String checkPoints;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,25 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ProductionOrder {
private Long id;
private String orderNumber;
private String productCode;
private String productName;
private Integer plannedQty;
private Integer completedQty;
private Integer defectQty;
private String status;
private Integer priority;
private LocalDateTime plannedStart;
private LocalDateTime plannedEnd;
private LocalDateTime actualStart;
private LocalDateTime actualEnd;
private String lineCode;
private String batchNumber;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class QrProcessCode {
private Long id;
private String qrCode;
private String codeType;
private String productCode;
private String lotNumber;
private Integer quantity;
private String currentProcess;
private String currentWorkstation;
private String status;
private LocalDateTime createdAt;
private LocalDateTime lastScannedAt;
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class QrScanEvent {
private Long id;
private String qrCode;
private String workstationId;
private String operatorId;
private String scanType;
private String result;
private String defectCode;
private LocalDateTime scannedAt;
private Integer processingTime;
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class QualityInspection {
private Long id;
private String inspectionNumber;
private Long orderId;
private String lotNumber;
private String inspectionType;
private String productCode;
private Integer sampleSize;
private Integer defectCount;
private String result;
private String defectDetails;
private String inspectorId;
private LocalDateTime inspectedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.fa.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class Workstation {
private Long id;
private String workstationCode;
private String workstationName;
private String lineCode;
private String processCode;
private Integer workerCount;
private String epaperDisplayId;
private String andonStatus;
private Double oee;
private Integer targetCount;
private Integer actualCount;
private String currentOrderId;
private String status;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.AndonEvent;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface AndonEventMapper {
List<AndonEvent> findAll(@Param("status") String status, @Param("severity") String severity);
AndonEvent findById(Long id);
List<AndonEvent> findByWorkstation(String workstationCode);
int insert(AndonEvent e);
int update(AndonEvent e);
int respond(@Param("id") Long id, @Param("responseBy") String responseBy);
int resolve(@Param("id") Long id);
Map<String, Object> getStats();
List<AndonEvent> findActive();
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.Bom;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface BomMapper {
List<Bom> findByProductCode(String productCode);
List<Bom> findByProcessCode(String processCode);
Bom findById(Long id);
int insert(Bom b);
int update(Bom b);
int delete(Long id);
int deleteByProductCode(String productCode);
}

View File

@ -0,0 +1,25 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.EpaperDisplay;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface EpaperDisplayMapper {
List<EpaperDisplay> findAll(@Param("status") String status, @Param("tenantCode") String tenantCode);
EpaperDisplay findById(Long id);
EpaperDisplay findByDisplayId(String displayId);
int insert(EpaperDisplay d);
int update(EpaperDisplay d);
int delete(Long id);
List<EpaperDisplay> findByBatteryLow(@Param("threshold") Double threshold);
List<EpaperDisplay> findOffline();
int updateStatus(@Param("displayId") String displayId, @Param("status") String status, @Param("lastSeen") LocalDateTime lastSeen);
int updateContent(@Param("id") Long id, @Param("content") String content, @Param("templateId") String templateId);
long countTotal();
long countOnline();
long countBatteryLow();
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.EpaperTemplate;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface EpaperTemplateMapper {
List<EpaperTemplate> findAll();
List<EpaperTemplate> findByType(String templateType);
List<EpaperTemplate> findByDisplaySize(String displaySize);
EpaperTemplate findById(Long id);
EpaperTemplate findByCode(String templateCode);
int insert(EpaperTemplate t);
int update(EpaperTemplate t);
int delete(Long id);
int toggleActive(Long id, Boolean active);
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.Equipment;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface EquipmentMapper {
List<Equipment> findAll(@Param("status") String status, @Param("workstationCode") String workstationCode);
Equipment findById(Long id);
Equipment findByCode(String equipmentCode);
int insert(Equipment e);
int update(Equipment e);
int delete(Long id);
int updateStatus(@Param("id") Long id, @Param("status") String status);
int updateOee(@Param("id") Long id, @Param("oee") Double oee);
List<Equipment> findBreakdownRisk();
List<Equipment> findMaintenanceDue();
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.FaUser;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface FaUserMapper {
List<FaUser> findAll();
FaUser findById(Long id);
FaUser findByUsername(String username);
int insert(FaUser u);
int update(FaUser u);
int delete(Long id);
int toggleActive(Long id, Boolean active);
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.FactoryInventory;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface FactoryInventoryMapper {
List<FactoryInventory> findAll(String locationCode);
FactoryInventory findById(Long id);
FactoryInventory findByItemCode(String itemCode);
List<FactoryInventory> findLowStock();
int insert(FactoryInventory i);
int update(FactoryInventory i);
int updateQuantity(String itemCode, Double delta);
int delete(Long id);
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.ProcessRoute;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
@Mapper
public interface ProcessRouteMapper {
List<ProcessRoute> findByProductCode(String productCode);
List<ProcessRoute> findByRouteCode(String routeCode);
ProcessRoute findById(Long id);
int insert(ProcessRoute r);
int update(ProcessRoute r);
int delete(Long id);
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.ProductionOrder;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ProductionOrderMapper {
List<ProductionOrder> findAll(@Param("status") String status, @Param("lineCode") String lineCode);
List<ProductionOrder> findToday();
ProductionOrder findById(Long id);
ProductionOrder findByOrderNumber(String orderNumber);
int insert(ProductionOrder o);
int update(ProductionOrder o);
int delete(Long id);
int updateStatus(@Param("id") Long id, @Param("status") String status);
int updateProgress(@Param("id") Long id, @Param("completedQty") int completedQty, @Param("defectQty") int defectQty);
List<ProductionOrder> findDashboardSummary();
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.QrProcessCode;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface QrProcessCodeMapper {
List<QrProcessCode> findAll(@Param("status") String status, @Param("codeType") String codeType);
QrProcessCode findByQrCode(String qrCode);
QrProcessCode findById(Long id);
List<QrProcessCode> findByLotNumber(String lotNumber);
List<QrProcessCode> findByWorkstation(String workstationId);
int insert(QrProcessCode q);
int update(QrProcessCode q);
int updateStatus(@Param("qrCode") String qrCode, @Param("status") String status, @Param("workstation") String workstation, @Param("process") String process);
int delete(Long id);
List<QrProcessCode> batchInsert(List<QrProcessCode> list);
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.QrScanEvent;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface QrScanEventMapper {
List<QrScanEvent> findByQrCode(String qrCode);
List<QrScanEvent> findByWorkstation(@Param("workstationId") String workstationId, @Param("limit") int limit);
QrScanEvent findById(Long id);
int insert(QrScanEvent e);
List<QrScanEvent> findRecentByWorkstation(@Param("workstationId") String workstationId, @Param("limit") int limit);
long countByResult(@Param("result") String result, @Param("hours") int hours);
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.QualityInspection;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
@Mapper
public interface QualityInspectionMapper {
List<QualityInspection> findAll(@Param("inspectionType") String inspectionType, @Param("result") String result);
QualityInspection findById(Long id);
QualityInspection findByNumber(String inspectionNumber);
List<QualityInspection> findByOrderId(Long orderId);
int insert(QualityInspection q);
int update(QualityInspection q);
int delete(Long id);
List<Map<String, Object>> getDefectPareto();
Map<String, Object> getDashboardSummary();
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.fa.mapper;
import com.zioinfo.fa.domain.Workstation;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface WorkstationMapper {
List<Workstation> findAll(@Param("lineCode") String lineCode, @Param("status") String status);
Workstation findById(Long id);
Workstation findByCode(String workstationCode);
int insert(Workstation w);
int update(Workstation w);
int delete(Long id);
int updateAndon(@Param("code") String code, @Param("andonStatus") String andonStatus);
int updateStatus(@Param("code") String code, @Param("status") String status);
int updateCount(@Param("code") String code, @Param("actualCount") int actualCount);
List<Workstation> findFloorMap();
}

View File

@ -0,0 +1,55 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.domain.AndonEvent;
import com.zioinfo.fa.mapper.AndonEventMapper;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Service
public class AndonService {
private final AndonEventMapper mapper;
public AndonService(AndonEventMapper mapper) {
this.mapper = mapper;
}
public List<AndonEvent> findAll(String status, String severity) {
return mapper.findAll(status, severity);
}
public AndonEvent findById(Long id) { return mapper.findById(id); }
public AndonEvent call(Map<String, Object> req) {
AndonEvent e = new AndonEvent();
e.setWorkstationCode((String) req.get("workstationCode"));
e.setAndonType((String) req.get("andonType"));
e.setSeverity((String) req.getOrDefault("severity", "WARNING"));
e.setDescription((String) req.get("description"));
e.setCalledBy((String) req.get("calledBy"));
e.setCalledAt(LocalDateTime.now());
e.setStatus("OPEN");
mapper.insert(e);
return mapper.findById(e.getId());
}
public AndonEvent respond(Long id, String responseBy) {
mapper.respond(id, responseBy);
return mapper.findById(id);
}
public AndonEvent resolve(Long id) {
mapper.resolve(id);
return mapper.findById(id);
}
public Map<String, Object> getStats() {
return mapper.getStats();
}
public List<AndonEvent> getBoard() {
return mapper.findActive();
}
}

View File

@ -0,0 +1,40 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.auth.JwtUtil;
import com.zioinfo.fa.domain.FaUser;
import com.zioinfo.fa.mapper.FaUserMapper;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class AuthService {
private final FaUserMapper userMapper;
private final JwtUtil jwtUtil;
private final PasswordEncoder passwordEncoder;
public AuthService(FaUserMapper userMapper, JwtUtil jwtUtil, PasswordEncoder passwordEncoder) {
this.userMapper = userMapper;
this.jwtUtil = jwtUtil;
this.passwordEncoder = passwordEncoder;
}
public Map<String, Object> login(String username, String password) {
FaUser user = userMapper.findByUsername(username);
if (user == null || !Boolean.TRUE.equals(user.getActive())) {
throw new RuntimeException("사용자를 찾을 수 없거나 비활성 상태입니다.");
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
throw new RuntimeException("비밀번호가 일치하지 않습니다.");
}
String token = jwtUtil.generateToken(username, user.getRole());
Map<String, Object> result = new HashMap<>();
result.put("token", token);
result.put("username", username);
result.put("role", user.getRole());
result.put("workstationCode", user.getWorkstationCode());
return result;
}
}

View File

@ -0,0 +1,33 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.domain.Bom;
import com.zioinfo.fa.domain.ProcessRoute;
import com.zioinfo.fa.mapper.BomMapper;
import com.zioinfo.fa.mapper.ProcessRouteMapper;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class BomRouteService {
private final BomMapper bomMapper;
private final ProcessRouteMapper routeMapper;
public BomRouteService(BomMapper bomMapper, ProcessRouteMapper routeMapper) {
this.bomMapper = bomMapper;
this.routeMapper = routeMapper;
}
public List<Bom> getBomByProduct(String productCode) { return bomMapper.findByProductCode(productCode); }
public Bom getBomById(Long id) { return bomMapper.findById(id); }
public Bom createBom(Bom b) { bomMapper.insert(b); return bomMapper.findById(b.getId()); }
public Bom updateBom(Long id, Bom b) { b.setId(id); bomMapper.update(b); return bomMapper.findById(id); }
public void deleteBom(Long id) { bomMapper.delete(id); }
public List<ProcessRoute> getRouteByProduct(String productCode) { return routeMapper.findByProductCode(productCode); }
public ProcessRoute getRouteById(Long id) { return routeMapper.findById(id); }
public ProcessRoute createRoute(ProcessRoute r) { routeMapper.insert(r); return routeMapper.findById(r.getId()); }
public ProcessRoute updateRoute(Long id, ProcessRoute r) { r.setId(id); routeMapper.update(r); return routeMapper.findById(id); }
public void deleteRoute(Long id) { routeMapper.delete(id); }
public ProcessRouteMapper getRouteMapper() { return routeMapper; }
}

View File

@ -0,0 +1,71 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.mapper.*;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
@Service
public class DashboardService {
private final WorkstationMapper workstationMapper;
private final ProductionOrderMapper orderMapper;
private final AndonEventMapper andonMapper;
private final EquipmentMapper equipmentMapper;
private final EpaperDisplayMapper epaperMapper;
private final QualityInspectionMapper qualityMapper;
public DashboardService(WorkstationMapper workstationMapper, ProductionOrderMapper orderMapper,
AndonEventMapper andonMapper, EquipmentMapper equipmentMapper,
EpaperDisplayMapper epaperMapper, QualityInspectionMapper qualityMapper) {
this.workstationMapper = workstationMapper;
this.orderMapper = orderMapper;
this.andonMapper = andonMapper;
this.equipmentMapper = equipmentMapper;
this.epaperMapper = epaperMapper;
this.qualityMapper = qualityMapper;
}
public Map<String, Object> getOverview() {
Map<String, Object> d = new HashMap<>();
d.put("workstations", workstationMapper.findAll(null, null).size());
d.put("todayOrders", orderMapper.findToday().size());
d.put("activeAndon", andonMapper.findActive().size());
d.put("epaperOnline", epaperMapper.countOnline());
return d;
}
public Map<String, Object> getOeeSummary() {
Map<String, Object> d = new HashMap<>();
d.put("avgOee", 0.87);
d.put("availability", 0.92);
d.put("performance", 0.88);
d.put("quality", 0.97);
return d;
}
public Map<String, Object> getProductionStatus() {
Map<String, Object> d = new HashMap<>();
d.put("today", orderMapper.findToday());
d.put("workstations", workstationMapper.findAll(null, "RUNNING"));
return d;
}
public Map<String, Object> getQualitySummary() {
Map<String, Object> summary = qualityMapper.getDashboardSummary();
return summary != null ? summary : new HashMap<>();
}
public Map<String, Object> getAndonSummary() {
return andonMapper.getStats();
}
public Map<String, Object> getEpaperStatus() {
Map<String, Object> d = new HashMap<>();
d.put("total", epaperMapper.countTotal());
d.put("online", epaperMapper.countOnline());
d.put("batteryLow", epaperMapper.findByBatteryLow(20.0).size());
d.put("offline", epaperMapper.findOffline().size());
return d;
}
}

View File

@ -0,0 +1,89 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.domain.EpaperDisplay;
import com.zioinfo.fa.mapper.EpaperDisplayMapper;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class EpaperDisplayService {
private final EpaperDisplayMapper mapper;
public EpaperDisplayService(EpaperDisplayMapper mapper) {
this.mapper = mapper;
}
public List<EpaperDisplay> findAll(String status, String tenantCode) {
return mapper.findAll(status, tenantCode);
}
public EpaperDisplay findById(Long id) {
return mapper.findById(id);
}
public EpaperDisplay findByDisplayId(String displayId) {
return mapper.findByDisplayId(displayId);
}
public EpaperDisplay create(EpaperDisplay d) {
mapper.insert(d);
return mapper.findById(d.getId());
}
public EpaperDisplay update(Long id, EpaperDisplay d) {
d.setId(id);
mapper.update(d);
return mapper.findById(id);
}
public void delete(Long id) {
mapper.delete(id);
}
public Map<String, Object> pushContent(Long id, String content, String templateId) {
mapper.updateContent(id, content, templateId);
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("displayId", id);
result.put("pushedAt", LocalDateTime.now());
return result;
}
public Map<String, Object> refresh(Long id) {
EpaperDisplay d = mapper.findById(id);
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("displayId", id);
result.put("refreshedAt", LocalDateTime.now());
return result;
}
public List<EpaperDisplay> findOffline() {
return mapper.findOffline();
}
public List<EpaperDisplay> findBatteryLow(Double threshold) {
return mapper.findByBatteryLow(threshold == null ? 20.0 : threshold);
}
public Map<String, Object> batchPush(List<Long> ids, String content, String templateId) {
ids.forEach(id -> mapper.updateContent(id, content, templateId));
Map<String, Object> result = new HashMap<>();
result.put("pushedCount", ids.size());
result.put("pushedAt", LocalDateTime.now());
return result;
}
public Map<String, Object> getDashboard() {
Map<String, Object> d = new HashMap<>();
d.put("total", mapper.countTotal());
d.put("online", mapper.countOnline());
d.put("offline", mapper.findOffline().size());
d.put("batteryLow", mapper.findByBatteryLow(20.0).size());
return d;
}
}

View File

@ -0,0 +1,38 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.domain.EpaperTemplate;
import com.zioinfo.fa.mapper.EpaperTemplateMapper;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class EpaperTemplateService {
private final EpaperTemplateMapper mapper;
public EpaperTemplateService(EpaperTemplateMapper mapper) {
this.mapper = mapper;
}
public List<EpaperTemplate> findAll() { return mapper.findAll(); }
public List<EpaperTemplate> findByType(String type) { return mapper.findByType(type); }
public EpaperTemplate findById(Long id) { return mapper.findById(id); }
public EpaperTemplate create(EpaperTemplate t) {
mapper.insert(t);
return mapper.findById(t.getId());
}
public EpaperTemplate update(Long id, EpaperTemplate t) {
t.setId(id);
mapper.update(t);
return mapper.findById(id);
}
public void delete(Long id) { mapper.delete(id); }
public EpaperTemplate preview(Long id) {
// Returns template with preview data
return mapper.findById(id);
}
}

View File

@ -0,0 +1,89 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.domain.Equipment;
import com.zioinfo.fa.mapper.EquipmentMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class EquipmentService {
private final EquipmentMapper mapper;
@Value("${ollama.base-url:http://localhost:11434}")
private String ollamaUrl;
public EquipmentService(EquipmentMapper mapper) {
this.mapper = mapper;
}
public List<Equipment> findAll(String status, String workstationCode) {
return mapper.findAll(status, workstationCode);
}
public Equipment findById(Long id) { return mapper.findById(id); }
public Equipment create(Equipment e) {
mapper.insert(e);
return mapper.findById(e.getId());
}
public Equipment update(Long id, Equipment e) {
e.setId(id);
mapper.update(e);
return mapper.findById(id);
}
public void delete(Long id) { mapper.delete(id); }
public Map<String, Object> getOee(Long id) {
Equipment e = mapper.findById(id);
Map<String, Object> result = new HashMap<>();
result.put("equipment", e);
result.put("oee", e != null ? e.getOee() : 0);
result.put("availability", 0.92);
result.put("performance", 0.88);
result.put("quality", 0.97);
return result;
}
public Map<String, Object> getMaintenance(Long id) {
Equipment e = mapper.findById(id);
Map<String, Object> result = new HashMap<>();
result.put("equipment", e);
result.put("lastMaintenance", e != null ? e.getLastMaintenanceAt() : null);
result.put("nextMaintenance", e != null ? e.getNextMaintenanceAt() : null);
return result;
}
public Equipment recordMaintenance(Long id, Map<String, Object> req) {
mapper.updateStatus(id, "RUNNING");
return mapper.findById(id);
}
public Map<String, Object> aiPredict(Map<String, Object> req) {
Map<String, Object> result = new HashMap<>();
try {
RestTemplate rt = new RestTemplate();
String prompt = "Equipment: " + req.get("equipmentCode") +
". OEE trend: " + req.get("oeeTrend") +
". Predict maintenance need and breakdown risk in Korean.";
Map<String, Object> body = Map.of("model", "llama3", "prompt", prompt, "stream", false);
ResponseEntity<Map> resp = rt.postForEntity(ollamaUrl + "/api/generate", body, Map.class);
result.put("prediction", resp.getBody() != null ? resp.getBody().get("response") : "예측 불가");
} catch (Exception e) {
result.put("prediction", "AI 예측 일시 중단. 설비 이력을 수동 확인하세요.");
}
result.put("success", true);
return result;
}
public List<Equipment> getBreakdownRisk() {
return mapper.findBreakdownRisk();
}
}

View File

@ -0,0 +1,59 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.domain.FactoryInventory;
import com.zioinfo.fa.mapper.FactoryInventoryMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class InventoryService {
private final FactoryInventoryMapper mapper;
@Value("${ollama.base-url:http://localhost:11434}")
private String ollamaUrl;
public InventoryService(FactoryInventoryMapper mapper) {
this.mapper = mapper;
}
public List<FactoryInventory> findAll(String locationCode) {
return mapper.findAll(locationCode);
}
public Map<String, Object> movement(Map<String, Object> req) {
String itemCode = (String) req.get("itemCode");
Double delta = Double.valueOf(req.get("delta").toString());
mapper.updateQuantity(itemCode, delta);
Map<String, Object> result = new HashMap<>();
result.put("itemCode", itemCode);
result.put("delta", delta);
result.put("success", true);
return result;
}
public List<FactoryInventory> getLowStock() {
return mapper.findLowStock();
}
public Map<String, Object> aiOptimize(Map<String, Object> req) {
Map<String, Object> result = new HashMap<>();
try {
RestTemplate rt = new RestTemplate();
String prompt = "Optimize inventory for factory. Current low stock items: " +
req.get("lowStockItems") + ". Suggest reorder quantities in Korean.";
Map<String, Object> body = Map.of("model", "llama3", "prompt", prompt, "stream", false);
ResponseEntity<Map> resp = rt.postForEntity(ollamaUrl + "/api/generate", body, Map.class);
result.put("optimization", resp.getBody() != null ? resp.getBody().get("response") : "최적화 불가");
} catch (Exception e) {
result.put("optimization", "AI 최적화 일시 중단. 안전재고 기준으로 발주하세요.");
}
result.put("success", true);
return result;
}
}

View File

@ -0,0 +1,75 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.domain.ProductionOrder;
import com.zioinfo.fa.mapper.ProductionOrderMapper;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
public class ProductionOrderService {
private final ProductionOrderMapper mapper;
public ProductionOrderService(ProductionOrderMapper mapper) {
this.mapper = mapper;
}
public List<ProductionOrder> findAll(String status, String lineCode) {
return mapper.findAll(status, lineCode);
}
public List<ProductionOrder> findToday() { return mapper.findToday(); }
public ProductionOrder findById(Long id) { return mapper.findById(id); }
public ProductionOrder create(ProductionOrder o) {
mapper.insert(o);
return mapper.findById(o.getId());
}
public ProductionOrder update(Long id, ProductionOrder o) {
o.setId(id);
mapper.update(o);
return mapper.findById(id);
}
public void delete(Long id) { mapper.delete(id); }
public ProductionOrder release(Long id) {
mapper.updateStatus(id, "RELEASED");
return mapper.findById(id);
}
public ProductionOrder complete(Long id) {
mapper.updateStatus(id, "COMPLETED");
return mapper.findById(id);
}
public Map<String, Object> getProgress(Long id) {
ProductionOrder o = mapper.findById(id);
Map<String, Object> p = new HashMap<>();
p.put("order", o);
if (o != null && o.getPlannedQty() != null && o.getPlannedQty() > 0) {
int completed = o.getCompletedQty() != null ? o.getCompletedQty() : 0;
p.put("progressPct", (completed * 100.0) / o.getPlannedQty());
} else {
p.put("progressPct", 0);
}
return p;
}
public Map<String, Object> getDashboard() {
List<ProductionOrder> today = mapper.findToday();
int total = today.size();
long completed = today.stream().filter(o -> "COMPLETED".equals(o.getStatus())).count();
long inProgress = today.stream().filter(o -> "IN_PROGRESS".equals(o.getStatus())).count();
Map<String, Object> d = new HashMap<>();
d.put("todayTotal", total);
d.put("completed", completed);
d.put("inProgress", inProgress);
d.put("completionRate", total > 0 ? (completed * 100.0 / total) : 0);
return d;
}
}

View File

@ -0,0 +1,88 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.domain.ProcessRoute;
import com.zioinfo.fa.domain.QrProcessCode;
import com.zioinfo.fa.domain.QrScanEvent;
import com.zioinfo.fa.mapper.ProcessRouteMapper;
import com.zioinfo.fa.mapper.QrProcessCodeMapper;
import com.zioinfo.fa.mapper.QrScanEventMapper;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@Service
public class QrService {
private final QrProcessCodeMapper codeMapper;
private final QrScanEventMapper scanMapper;
public QrService(QrProcessCodeMapper codeMapper, QrScanEventMapper scanMapper) {
this.codeMapper = codeMapper;
this.scanMapper = scanMapper;
}
public QrProcessCode generate(Map<String, Object> req) {
QrProcessCode q = new QrProcessCode();
q.setQrCode(UUID.randomUUID().toString());
q.setCodeType((String) req.get("codeType"));
q.setProductCode((String) req.get("productCode"));
q.setLotNumber((String) req.get("lotNumber"));
q.setQuantity((Integer) req.getOrDefault("quantity", 1));
q.setCurrentProcess((String) req.get("currentProcess"));
q.setCurrentWorkstation((String) req.get("currentWorkstation"));
q.setStatus("IN_PROCESS");
codeMapper.insert(q);
return codeMapper.findByQrCode(q.getQrCode());
}
public QrProcessCode findByCode(String code) {
return codeMapper.findByQrCode(code);
}
public Map<String, Object> scan(Map<String, Object> req) {
String qrCode = (String) req.get("qrCode");
QrScanEvent event = new QrScanEvent();
event.setQrCode(qrCode);
event.setWorkstationId((String) req.get("workstationId"));
event.setOperatorId((String) req.get("operatorId"));
event.setScanType((String) req.getOrDefault("scanType", "SCAN_IN"));
event.setResult((String) req.getOrDefault("result", "OK"));
event.setScannedAt(LocalDateTime.now());
scanMapper.insert(event);
codeMapper.updateStatus(qrCode, "IN_PROCESS", (String) req.get("workstationId"), (String) req.get("processCode"));
QrProcessCode code = codeMapper.findByQrCode(qrCode);
Map<String, Object> result = new HashMap<>();
result.put("event", event);
result.put("processCode", code);
return result;
}
public List<QrScanEvent> getHistory(String code) {
return scanMapper.findByQrCode(code);
}
public List<ProcessRoute> getRoute(String code, com.zioinfo.fa.mapper.ProcessRouteMapper routeMapper) {
QrProcessCode q = codeMapper.findByQrCode(code);
if (q == null) return new ArrayList<>();
return routeMapper.findByProductCode(q.getProductCode());
}
public List<QrProcessCode> findByLot(String lot) {
return codeMapper.findByLotNumber(lot);
}
public List<QrScanEvent> getRecentByWorkstation(String ws, int limit) {
return scanMapper.findRecentByWorkstation(ws, limit);
}
public List<QrProcessCode> batchGenerate(List<Map<String, Object>> requests) {
List<QrProcessCode> result = new ArrayList<>();
requests.forEach(req -> result.add(generate(req)));
return result;
}
}

View File

@ -0,0 +1,69 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.domain.QualityInspection;
import com.zioinfo.fa.mapper.QualityInspectionMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.*;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.*;
@Service
public class QualityService {
private final QualityInspectionMapper mapper;
@Value("${ollama.base-url:http://localhost:11434}")
private String ollamaUrl;
public QualityService(QualityInspectionMapper mapper) {
this.mapper = mapper;
}
public List<QualityInspection> findAll(String type, String result) {
return mapper.findAll(type, result);
}
public QualityInspection findById(Long id) { return mapper.findById(id); }
public QualityInspection create(QualityInspection q) {
mapper.insert(q);
return mapper.findById(q.getId());
}
public QualityInspection setResult(Long id, Map<String, Object> req) {
QualityInspection q = mapper.findById(id);
if (q != null) {
q.setResult((String) req.get("result"));
q.setDefectCount((Integer) req.getOrDefault("defectCount", 0));
q.setDefectDetails((String) req.get("defectDetails"));
mapper.update(q);
}
return mapper.findById(id);
}
public List<Map<String, Object>> getDefectPareto() {
return mapper.getDefectPareto();
}
public Map<String, Object> getDashboard() {
return mapper.getDashboardSummary();
}
public Map<String, Object> aiAnalyze(Map<String, Object> req) {
Map<String, Object> result = new HashMap<>();
try {
RestTemplate rt = new RestTemplate();
String prompt = "Analyze quality defect: " + req.get("defectDescription") +
". Product: " + req.get("productCode") +
". Suggest root cause and corrective action in Korean.";
Map<String, Object> body = Map.of("model", "llama3", "prompt", prompt, "stream", false);
ResponseEntity<Map> resp = rt.postForEntity(ollamaUrl + "/api/generate", body, Map.class);
result.put("analysis", resp.getBody() != null ? resp.getBody().get("response") : "분석 불가");
} catch (Exception e) {
result.put("analysis", "AI 분석 일시 중단. 수동 검토가 필요합니다.");
}
result.put("success", true);
return result;
}
}

View File

@ -0,0 +1,46 @@
package com.zioinfo.fa.service;
import com.zioinfo.fa.domain.Workstation;
import com.zioinfo.fa.mapper.WorkstationMapper;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Map;
@Service
public class WorkstationService {
private final WorkstationMapper mapper;
public WorkstationService(WorkstationMapper mapper) {
this.mapper = mapper;
}
public List<Workstation> findAll(String lineCode, String status) {
return mapper.findAll(lineCode, status);
}
public Workstation findById(Long id) { return mapper.findById(id); }
public Workstation findByCode(String code) { return mapper.findByCode(code); }
public Workstation create(Workstation w) {
mapper.insert(w);
return mapper.findById(w.getId());
}
public Workstation update(Long id, Workstation w) {
w.setId(id);
mapper.update(w);
return mapper.findById(id);
}
public void delete(Long id) { mapper.delete(id); }
public Map<String, Object> updateAndon(String code, String andonStatus) {
mapper.updateAndon(code, andonStatus);
return Map.of("code", code, "andonStatus", andonStatus, "updated", true);
}
public List<Workstation> getFloorMap() {
return mapper.findFloorMap();
}
}

View File

@ -0,0 +1,26 @@
server:
port: 8017
spring:
datasource:
url: jdbc:postgresql://localhost:5432/fa_db
username: fa_user
password: fa_pass2026
hikari:
maximum-pool-size: 3
mvc:
static-path-pattern: /static/**
web:
resources:
static-locations: classpath:/static/
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
jwt:
secret: FaJwtSecret2026ZioInfo!@#$%^&*()
expiration: 86400000
ollama:
base-url: http://localhost:11434
logging:
level:
com.zioinfo.fa: DEBUG

View File

@ -0,0 +1,258 @@
-- GUARDiA FA Database Schema
-- DB: fa_db / User: fa_user / Password: fa_pass2026
CREATE TABLE IF NOT EXISTS fa_epaper_displays (
id BIGSERIAL PRIMARY KEY,
display_id VARCHAR(50) UNIQUE NOT NULL,
workstation_id VARCHAR(50),
display_size VARCHAR(20),
display_type VARCHAR(10),
resolution_w INT,
resolution_h INT,
color_mode VARCHAR(20),
refresh_mode VARCHAR(10),
current_template VARCHAR(50),
display_content JSONB,
battery_level DECIMAL(5,2),
signal_strength INT,
status VARCHAR(20) DEFAULT 'OFFLINE',
last_updated TIMESTAMP,
last_seen TIMESTAMP,
gateway_id VARCHAR(50),
protocol VARCHAR(30),
tenant_code VARCHAR(20),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fa_epaper_templates (
id BIGSERIAL PRIMARY KEY,
template_code VARCHAR(50) UNIQUE NOT NULL,
template_name VARCHAR(100) NOT NULL,
template_type VARCHAR(50),
display_size VARCHAR(20),
layout_json JSONB,
preview_base64 TEXT,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fa_qr_process_codes (
id BIGSERIAL PRIMARY KEY,
qr_code VARCHAR(100) UNIQUE NOT NULL,
code_type VARCHAR(20),
product_code VARCHAR(50),
lot_number VARCHAR(50),
quantity INT,
current_process VARCHAR(50),
current_workstation VARCHAR(50),
status VARCHAR(20) DEFAULT 'IN_PROCESS',
created_at TIMESTAMP DEFAULT NOW(),
last_scanned_at TIMESTAMP
);
CREATE TABLE IF NOT EXISTS fa_qr_scan_events (
id BIGSERIAL PRIMARY KEY,
qr_code VARCHAR(100) NOT NULL,
workstation_id VARCHAR(50),
operator_id VARCHAR(50),
scan_type VARCHAR(30),
result VARCHAR(10),
defect_code VARCHAR(30),
scanned_at TIMESTAMP DEFAULT NOW(),
processing_time INT
);
CREATE TABLE IF NOT EXISTS fa_workstations (
id BIGSERIAL PRIMARY KEY,
workstation_code VARCHAR(50) UNIQUE NOT NULL,
workstation_name VARCHAR(100),
line_code VARCHAR(30),
process_code VARCHAR(30),
worker_count INT DEFAULT 1,
epaper_display_id VARCHAR(50),
andon_status VARCHAR(10) DEFAULT 'GREEN',
oee DECIMAL(5,4) DEFAULT 0.85,
target_count INT DEFAULT 0,
actual_count INT DEFAULT 0,
current_order_id VARCHAR(50),
status VARCHAR(20) DEFAULT 'STOPPED',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fa_production_orders (
id BIGSERIAL PRIMARY KEY,
order_number VARCHAR(50) UNIQUE NOT NULL,
product_code VARCHAR(50),
product_name VARCHAR(100),
planned_qty INT,
completed_qty INT DEFAULT 0,
defect_qty INT DEFAULT 0,
status VARCHAR(20) DEFAULT 'PLANNED',
priority INT DEFAULT 5,
planned_start TIMESTAMP,
planned_end TIMESTAMP,
actual_start TIMESTAMP,
actual_end TIMESTAMP,
line_code VARCHAR(30),
batch_number VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fa_bom (
id BIGSERIAL PRIMARY KEY,
product_code VARCHAR(50) NOT NULL,
component_code VARCHAR(50),
component_name VARCHAR(100),
required_qty DECIMAL(10,4),
unit VARCHAR(20),
level INT DEFAULT 1,
sequence INT DEFAULT 1,
process_code VARCHAR(30),
substitute_code VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fa_process_routes (
id BIGSERIAL PRIMARY KEY,
route_code VARCHAR(50) NOT NULL,
product_code VARCHAR(50),
sequence INT,
process_code VARCHAR(30),
process_name VARCHAR(100),
workstation_code VARCHAR(50),
standard_time INT,
check_points JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fa_quality_inspections (
id BIGSERIAL PRIMARY KEY,
inspection_number VARCHAR(50) UNIQUE NOT NULL,
order_id BIGINT,
lot_number VARCHAR(50),
inspection_type VARCHAR(20),
product_code VARCHAR(50),
sample_size INT,
defect_count INT DEFAULT 0,
result VARCHAR(20),
defect_details JSONB,
inspector_id VARCHAR(50),
inspected_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fa_quality_defects (
id BIGSERIAL PRIMARY KEY,
inspection_id BIGINT,
defect_code VARCHAR(30),
defect_name VARCHAR(100),
defect_count INT,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fa_equipment (
id BIGSERIAL PRIMARY KEY,
equipment_code VARCHAR(50) UNIQUE NOT NULL,
equipment_name VARCHAR(100),
equipment_type VARCHAR(50),
workstation_code VARCHAR(50),
manufacturer VARCHAR(100),
model_number VARCHAR(50),
install_date DATE,
warranty_expiry DATE,
status VARCHAR(20) DEFAULT 'IDLE',
oee DECIMAL(5,4) DEFAULT 0.85,
last_maintenance_at TIMESTAMP,
next_maintenance_at TIMESTAMP,
epaper_display_id VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fa_andon_events (
id BIGSERIAL PRIMARY KEY,
workstation_code VARCHAR(50),
andon_type VARCHAR(20),
severity VARCHAR(20),
description TEXT,
called_by VARCHAR(50),
response_by VARCHAR(50),
called_at TIMESTAMP DEFAULT NOW(),
responded_at TIMESTAMP,
resolved_at TIMESTAMP,
response_time_seconds INT,
resolution_time_seconds INT,
status VARCHAR(20) DEFAULT 'OPEN',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fa_inventory (
id BIGSERIAL PRIMARY KEY,
item_code VARCHAR(50) UNIQUE NOT NULL,
item_name VARCHAR(100),
location_code VARCHAR(30),
quantity DECIMAL(10,4) DEFAULT 0,
safety_stock DECIMAL(10,4) DEFAULT 10,
reorder_point DECIMAL(10,4) DEFAULT 20,
unit VARCHAR(20),
status VARCHAR(20) DEFAULT 'NORMAL',
last_updated TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS fa_users (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(30) DEFAULT 'OPERATOR',
workstation_code VARCHAR(50),
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_epaper_status ON fa_epaper_displays(status);
CREATE INDEX IF NOT EXISTS idx_epaper_tenant ON fa_epaper_displays(tenant_code);
CREATE INDEX IF NOT EXISTS idx_qr_code ON fa_qr_process_codes(qr_code);
CREATE INDEX IF NOT EXISTS idx_qr_lot ON fa_qr_process_codes(lot_number);
CREATE INDEX IF NOT EXISTS idx_scan_qr ON fa_qr_scan_events(qr_code);
CREATE INDEX IF NOT EXISTS idx_scan_ws ON fa_qr_scan_events(workstation_id);
CREATE INDEX IF NOT EXISTS idx_order_status ON fa_production_orders(status);
CREATE INDEX IF NOT EXISTS idx_order_date ON fa_production_orders(planned_start);
CREATE INDEX IF NOT EXISTS idx_andon_ws ON fa_andon_events(workstation_code);
CREATE INDEX IF NOT EXISTS idx_andon_status ON fa_andon_events(status);
-- Seed: default admin user (password: admin123)
INSERT INTO fa_users (username, password_hash, role, active)
VALUES ('admin', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lh32', 'ADMIN', true)
ON CONFLICT (username) DO NOTHING;
-- Seed: sample workstations
INSERT INTO fa_workstations (workstation_code, workstation_name, line_code, process_code, status, andon_status)
VALUES
('WS-001', '조립 1공정', 'LINE-A', 'ASSEMBLY', 'RUNNING', 'GREEN'),
('WS-002', '조립 2공정', 'LINE-A', 'ASSEMBLY', 'RUNNING', 'GREEN'),
('WS-003', '검사 공정', 'LINE-A', 'INSPECTION', 'STOPPED', 'GREEN'),
('WS-004', '포장 공정', 'LINE-B', 'PACKAGING', 'RUNNING', 'YELLOW')
ON CONFLICT (workstation_code) DO NOTHING;
-- Seed: sample e-paper displays
INSERT INTO fa_epaper_displays (display_id, workstation_id, display_size, display_type, resolution_w, resolution_h, color_mode, refresh_mode, status, battery_level, signal_strength, gateway_id, protocol, tenant_code)
VALUES
('EP-WS-001', 'WS-001', '4.2"', 'BWY', 400, 300, 'MONO', 'FULL', 'ONLINE', 87.5, -65, 'GW-001', 'ESL-BLE', 'ZIOINFO'),
('EP-WS-002', 'WS-002', '2.9"', 'BW', 296, 128, 'MONO', 'PARTIAL', 'ONLINE', 52.3, -72, 'GW-001', 'ESL-915MHz', 'ZIOINFO'),
('EP-WS-003', 'WS-003', '7.5"', 'BWR', 800, 480, 'MONO', 'FULL', 'OFFLINE', 15.0, -90, 'GW-002', 'WiFi', 'ZIOINFO'),
('EP-WS-004', 'WS-004', '2.13"', 'BW', 250, 122, 'MONO', 'PARTIAL', 'ONLINE', 91.2, -58, 'GW-002', 'ESL-BLE', 'ZIOINFO')
ON CONFLICT (display_id) DO NOTHING;
-- Seed: e-paper templates
INSERT INTO fa_epaper_templates (template_code, template_name, template_type, display_size, layout_json)
VALUES
('TPL-WO-001', '작업 지시서', 'WORK_ORDER', '4.2"', '{"type":"work_order","fields":["order_no","product","qty","deadline"]}'),
('TPL-ANDON-001', '안돈 현황', 'ANDON', '7.5"', '{"type":"andon","fields":["status","message","time"]}'),
('TPL-KANBAN-001', '칸반 카드', 'KANBAN', '2.9"', '{"type":"kanban","fields":["item","qty","location"]}'),
('TPL-QC-001', '품질 검사표', 'QUALITY', '4.2"', '{"type":"quality","fields":["lot","result","inspector","time"]}')
ON CONFLICT (template_code) DO NOTHING;

View File

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.fa.mapper.AndonEventMapper">
<resultMap id="andonMap" type="com.zioinfo.fa.domain.AndonEvent">
<id property="id" column="id"/>
<result property="workstationCode" column="workstation_code"/>
<result property="andonType" column="andon_type"/>
<result property="calledBy" column="called_by"/>
<result property="responseBy" column="response_by"/>
<result property="calledAt" column="called_at"/>
<result property="respondedAt" column="responded_at"/>
<result property="resolvedAt" column="resolved_at"/>
<result property="responseTimeSeconds" column="response_time_seconds"/>
<result property="resolutionTimeSeconds" column="resolution_time_seconds"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="andonMap">
SELECT * FROM fa_andon_events
<where>
<if test="status != null">AND status = #{status}</if>
<if test="severity != null">AND severity = #{severity}</if>
</where>
ORDER BY called_at DESC LIMIT 100
</select>
<select id="findById" resultMap="andonMap">SELECT * FROM fa_andon_events WHERE id = #{id}</select>
<select id="findByWorkstation" resultMap="andonMap">SELECT * FROM fa_andon_events WHERE workstation_code = #{workstationCode} ORDER BY called_at DESC</select>
<select id="findActive" resultMap="andonMap">SELECT * FROM fa_andon_events WHERE resolved_at IS NULL ORDER BY called_at DESC</select>
<select id="getStats" resultType="java.util.Map">
SELECT COUNT(*) as total_today,
SUM(CASE WHEN resolved_at IS NOT NULL THEN 1 ELSE 0 END) as resolved,
SUM(CASE WHEN resolved_at IS NULL THEN 1 ELSE 0 END) as active,
COALESCE(AVG(response_time_seconds), 0) as avg_response_time
FROM fa_andon_events WHERE DATE(called_at) = CURRENT_DATE
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO fa_andon_events (workstation_code, andon_type, severity, description, called_by, called_at, status)
VALUES (#{workstationCode}, #{andonType}, #{severity}, #{description}, #{calledBy}, #{calledAt}, #{status})
</insert>
<update id="update">UPDATE fa_andon_events SET description=#{description}, severity=#{severity} WHERE id=#{id}</update>
<update id="respond">
UPDATE fa_andon_events SET response_by=#{responseBy}, responded_at=NOW(),
response_time_seconds=EXTRACT(EPOCH FROM (NOW() - called_at))::INT,
status='IN_PROGRESS' WHERE id=#{id}
</update>
<update id="resolve">
UPDATE fa_andon_events SET resolved_at=NOW(),
resolution_time_seconds=EXTRACT(EPOCH FROM (NOW() - called_at))::INT,
status='RESOLVED' WHERE id=#{id}
</update>
</mapper>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.fa.mapper.BomMapper">
<resultMap id="bomMap" type="com.zioinfo.fa.domain.Bom">
<id property="id" column="id"/>
<result property="productCode" column="product_code"/>
<result property="componentCode" column="component_code"/>
<result property="componentName" column="component_name"/>
<result property="requiredQty" column="required_qty"/>
<result property="processCode" column="process_code"/>
<result property="substituteCode" column="substitute_code"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findByProductCode" resultMap="bomMap">SELECT * FROM fa_bom WHERE product_code = #{productCode} ORDER BY level, sequence</select>
<select id="findByProcessCode" resultMap="bomMap">SELECT * FROM fa_bom WHERE process_code = #{processCode}</select>
<select id="findById" resultMap="bomMap">SELECT * FROM fa_bom WHERE id = #{id}</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO fa_bom (product_code, component_code, component_name, required_qty, unit, level, process_code, substitute_code)
VALUES (#{productCode}, #{componentCode}, #{componentName}, #{requiredQty}, #{unit}, #{level}, #{processCode}, #{substituteCode})
</insert>
<update id="update">UPDATE fa_bom SET required_qty=#{requiredQty}, unit=#{unit}, level=#{level} WHERE id=#{id}</update>
<delete id="delete">DELETE FROM fa_bom WHERE id=#{id}</delete>
<delete id="deleteByProductCode">DELETE FROM fa_bom WHERE product_code=#{productCode}</delete>
</mapper>

View File

@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.fa.mapper.EpaperDisplayMapper">
<resultMap id="displayMap" type="com.zioinfo.fa.domain.EpaperDisplay">
<id property="id" column="id"/>
<result property="displayId" column="display_id"/>
<result property="workstationId" column="workstation_id"/>
<result property="displaySize" column="display_size"/>
<result property="displayType" column="display_type"/>
<result property="resolutionW" column="resolution_w"/>
<result property="resolutionH" column="resolution_h"/>
<result property="colorMode" column="color_mode"/>
<result property="refreshMode" column="refresh_mode"/>
<result property="currentTemplate" column="current_template"/>
<result property="displayContent" column="display_content"/>
<result property="batteryLevel" column="battery_level"/>
<result property="signalStrength" column="signal_strength"/>
<result property="lastUpdated" column="last_updated"/>
<result property="lastSeen" column="last_seen"/>
<result property="gatewayId" column="gateway_id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="displayMap">
SELECT * FROM fa_epaper_displays
<where>
<if test="status != null">AND status = #{status}</if>
<if test="tenantCode != null">AND tenant_code = #{tenantCode}</if>
</where>
ORDER BY created_at DESC
</select>
<select id="findById" resultMap="displayMap">SELECT * FROM fa_epaper_displays WHERE id = #{id}</select>
<select id="findByDisplayId" resultMap="displayMap">SELECT * FROM fa_epaper_displays WHERE display_id = #{displayId}</select>
<select id="findByBatteryLow" resultMap="displayMap">SELECT * FROM fa_epaper_displays WHERE battery_level &lt; #{threshold} AND status = 'ONLINE'</select>
<select id="findOffline" resultMap="displayMap">SELECT * FROM fa_epaper_displays WHERE status = 'OFFLINE'</select>
<select id="countTotal" resultType="long">SELECT COUNT(*) FROM fa_epaper_displays</select>
<select id="countOnline" resultType="long">SELECT COUNT(*) FROM fa_epaper_displays WHERE status = 'ONLINE'</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO fa_epaper_displays (display_id, workstation_id, display_size, display_type, resolution_w, resolution_h,
color_mode, refresh_mode, status, gateway_id, protocol, tenant_code)
VALUES (#{displayId}, #{workstationId}, #{displaySize}, #{displayType}, #{resolutionW}, #{resolutionH},
#{colorMode}, #{refreshMode}, 'OFFLINE', #{gatewayId}, #{protocol}, #{tenantCode})
</insert>
<update id="update">
UPDATE fa_epaper_displays SET workstation_id=#{workstationId}, display_size=#{displaySize},
gateway_id=#{gatewayId}, protocol=#{protocol}, tenant_code=#{tenantCode} WHERE id=#{id}
</update>
<update id="updateStatus">UPDATE fa_epaper_displays SET status=#{status}, last_seen=#{lastSeen} WHERE display_id=#{displayId}</update>
<update id="updateContent">UPDATE fa_epaper_displays SET display_content=#{content}::jsonb, current_template=#{templateId}, last_updated=NOW() WHERE id=#{id}</update>
<delete id="delete">DELETE FROM fa_epaper_displays WHERE id=#{id}</delete>
</mapper>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.fa.mapper.EpaperTemplateMapper">
<resultMap id="templateMap" type="com.zioinfo.fa.domain.EpaperTemplate">
<id property="id" column="id"/>
<result property="templateCode" column="template_code"/>
<result property="templateName" column="template_name"/>
<result property="templateType" column="template_type"/>
<result property="displaySize" column="display_size"/>
<result property="layoutJson" column="layout_json"/>
<result property="previewBase64" column="preview_base64"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="findAll" resultMap="templateMap">SELECT * FROM fa_epaper_templates ORDER BY created_at DESC</select>
<select id="findByType" resultMap="templateMap">SELECT * FROM fa_epaper_templates WHERE template_type = #{templateType}</select>
<select id="findByDisplaySize" resultMap="templateMap">SELECT * FROM fa_epaper_templates WHERE display_size = #{displaySize}</select>
<select id="findById" resultMap="templateMap">SELECT * FROM fa_epaper_templates WHERE id = #{id}</select>
<select id="findByCode" resultMap="templateMap">SELECT * FROM fa_epaper_templates WHERE template_code = #{templateCode}</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO fa_epaper_templates (template_code, template_name, template_type, display_size, layout_json, active)
VALUES (#{templateCode}, #{templateName}, #{templateType}, #{displaySize}, #{layoutJson}, TRUE)
</insert>
<update id="update">
UPDATE fa_epaper_templates SET template_name=#{templateName}, template_type=#{templateType},
display_size=#{displaySize}, layout_json=#{layoutJson}, updated_at=NOW() WHERE id=#{id}
</update>
<update id="toggleActive">UPDATE fa_epaper_templates SET active=#{active} WHERE id=#{id}</update>
<delete id="delete">DELETE FROM fa_epaper_templates WHERE id=#{id}</delete>
</mapper>

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.fa.mapper.EquipmentMapper">
<resultMap id="eqMap" type="com.zioinfo.fa.domain.Equipment">
<id property="id" column="id"/>
<result property="equipmentCode" column="equipment_code"/>
<result property="equipmentName" column="equipment_name"/>
<result property="equipmentType" column="equipment_type"/>
<result property="workstationCode" column="workstation_code"/>
<result property="installDate" column="install_date"/>
<result property="warrantyExpiry" column="warranty_expiry"/>
<result property="lastMaintenanceAt" column="last_maintenance_at"/>
<result property="nextMaintenanceAt" column="next_maintenance_at"/>
<result property="epaperDisplayId" column="epaper_display_id"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="eqMap">
SELECT * FROM fa_equipment
<where>
<if test="status != null">AND status = #{status}</if>
<if test="workstationCode != null">AND workstation_code = #{workstationCode}</if>
</where>
ORDER BY equipment_code
</select>
<select id="findById" resultMap="eqMap">SELECT * FROM fa_equipment WHERE id = #{id}</select>
<select id="findByCode" resultMap="eqMap">SELECT * FROM fa_equipment WHERE equipment_code = #{equipmentCode}</select>
<select id="findBreakdownRisk" resultMap="eqMap">SELECT * FROM fa_equipment WHERE oee &lt; 0.7 AND status = 'RUNNING' ORDER BY oee ASC</select>
<select id="findMaintenanceDue" resultMap="eqMap">SELECT * FROM fa_equipment WHERE next_maintenance_at &lt;= NOW() + INTERVAL '7 days'</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO fa_equipment (equipment_code, equipment_name, equipment_type, workstation_code, manufacturer, model_number, install_date, warranty_expiry, status, oee)
VALUES (#{equipmentCode}, #{equipmentName}, #{equipmentType}, #{workstationCode}, #{manufacturer}, #{modelNumber}, #{installDate}, #{warrantyExpiry}, 'IDLE', 0.85)
</insert>
<update id="update">UPDATE fa_equipment SET equipment_name=#{equipmentName}, workstation_code=#{workstationCode}, status=#{status} WHERE id=#{id}</update>
<update id="updateStatus">UPDATE fa_equipment SET status=#{status} WHERE id=#{id}</update>
<update id="updateOee">UPDATE fa_equipment SET oee=#{oee} WHERE id=#{id}</update>
<delete id="delete">DELETE FROM fa_equipment WHERE id=#{id}</delete>
</mapper>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.fa.mapper.FaUserMapper">
<resultMap id="userMap" type="com.zioinfo.fa.domain.FaUser">
<id property="id" column="id"/>
<result property="passwordHash" column="password_hash"/>
<result property="workstationCode" column="workstation_code"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="findAll" resultMap="userMap">SELECT id, username, role, workstation_code, active, created_at FROM fa_users ORDER BY username</select>
<select id="findById" resultMap="userMap">SELECT * FROM fa_users WHERE id = #{id}</select>
<select id="findByUsername" resultMap="userMap">SELECT * FROM fa_users WHERE username = #{username}</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO fa_users (username, password_hash, role, workstation_code, active)
VALUES (#{username}, #{passwordHash}, #{role}, #{workstationCode}, #{active})
</insert>
<update id="update">UPDATE fa_users SET role=#{role}, workstation_code=#{workstationCode}, updated_at=NOW() WHERE id=#{id}</update>
<update id="toggleActive">UPDATE fa_users SET active=#{active} WHERE id=#{id}</update>
<delete id="delete">DELETE FROM fa_users WHERE id=#{id}</delete>
</mapper>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.fa.mapper.FactoryInventoryMapper">
<resultMap id="invMap" type="com.zioinfo.fa.domain.FactoryInventory">
<id property="id" column="id"/>
<result property="itemCode" column="item_code"/>
<result property="itemName" column="item_name"/>
<result property="locationCode" column="location_code"/>
<result property="safetyStock" column="safety_stock"/>
<result property="reorderPoint" column="reorder_point"/>
<result property="lastUpdated" column="last_updated"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultType="com.zioinfo.fa.domain.FactoryInventory">
SELECT * FROM fa_inventory
<where><if test="locationCode != null">AND location_code = #{locationCode}</if></where>
ORDER BY item_code
</select>
<select id="findById" resultMap="invMap">SELECT * FROM fa_inventory WHERE id = #{id}</select>
<select id="findByItemCode" resultMap="invMap">SELECT * FROM fa_inventory WHERE item_code = #{itemCode}</select>
<select id="findLowStock" resultMap="invMap">SELECT * FROM fa_inventory WHERE quantity &lt;= safety_stock</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO fa_inventory (item_code, item_name, location_code, quantity, safety_stock, reorder_point, unit, status)
VALUES (#{itemCode}, #{itemName}, #{locationCode}, #{quantity}, #{safetyStock}, #{reorderPoint}, #{unit}, 'NORMAL')
</insert>
<update id="update">UPDATE fa_inventory SET item_name=#{itemName}, quantity=#{quantity}, safety_stock=#{safetyStock}, last_updated=NOW() WHERE id=#{id}</update>
<update id="updateQuantity">UPDATE fa_inventory SET quantity=quantity+#{delta}, last_updated=NOW() WHERE item_code=#{itemCode}</update>
<delete id="delete">DELETE FROM fa_inventory WHERE id=#{id}</delete>
</mapper>

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.fa.mapper.ProcessRouteMapper">
<resultMap id="routeMap" type="com.zioinfo.fa.domain.ProcessRoute">
<id property="id" column="id"/>
<result property="routeCode" column="route_code"/>
<result property="productCode" column="product_code"/>
<result property="processCode" column="process_code"/>
<result property="processName" column="process_name"/>
<result property="workstationCode" column="workstation_code"/>
<result property="standardTime" column="standard_time"/>
<result property="checkPoints" column="check_points"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findByProductCode" resultMap="routeMap">SELECT * FROM fa_process_routes WHERE product_code = #{productCode} ORDER BY sequence</select>
<select id="findByRouteCode" resultMap="routeMap">SELECT * FROM fa_process_routes WHERE route_code = #{routeCode} ORDER BY sequence</select>
<select id="findById" resultMap="routeMap">SELECT * FROM fa_process_routes WHERE id = #{id}</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO fa_process_routes (route_code, product_code, sequence, process_code, process_name, workstation_code, standard_time, check_points)
VALUES (#{routeCode}, #{productCode}, #{sequence}, #{processCode}, #{processName}, #{workstationCode}, #{standardTime}, #{checkPoints})
</insert>
<update id="update">UPDATE fa_process_routes SET process_name=#{processName}, standard_time=#{standardTime} WHERE id=#{id}</update>
<delete id="delete">DELETE FROM fa_process_routes WHERE id=#{id}</delete>
</mapper>

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.fa.mapper.ProductionOrderMapper">
<resultMap id="orderMap" type="com.zioinfo.fa.domain.ProductionOrder">
<id property="id" column="id"/>
<result property="orderNumber" column="order_number"/>
<result property="productCode" column="product_code"/>
<result property="productName" column="product_name"/>
<result property="plannedQty" column="planned_qty"/>
<result property="completedQty" column="completed_qty"/>
<result property="defectQty" column="defect_qty"/>
<result property="plannedStart" column="planned_start"/>
<result property="plannedEnd" column="planned_end"/>
<result property="actualStart" column="actual_start"/>
<result property="actualEnd" column="actual_end"/>
<result property="lineCode" column="line_code"/>
<result property="batchNumber" column="batch_number"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="findAll" resultMap="orderMap">
SELECT * FROM fa_production_orders
<where>
<if test="status != null">AND status = #{status}</if>
<if test="lineCode != null">AND line_code = #{lineCode}</if>
</where>
ORDER BY priority ASC, created_at DESC
</select>
<select id="findToday" resultMap="orderMap">
SELECT * FROM fa_production_orders WHERE DATE(planned_start) = CURRENT_DATE ORDER BY priority ASC
</select>
<select id="findById" resultMap="orderMap">SELECT * FROM fa_production_orders WHERE id = #{id}</select>
<select id="findByOrderNumber" resultMap="orderMap">SELECT * FROM fa_production_orders WHERE order_number = #{orderNumber}</select>
<select id="findDashboardSummary" resultMap="orderMap">SELECT * FROM fa_production_orders WHERE DATE(planned_start) = CURRENT_DATE</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO fa_production_orders (order_number, product_code, product_name, planned_qty, completed_qty, defect_qty, status, priority, planned_start, planned_end, line_code, batch_number)
VALUES (#{orderNumber}, #{productCode}, #{productName}, #{plannedQty}, 0, 0, 'PLANNED', #{priority}, #{plannedStart}, #{plannedEnd}, #{lineCode}, #{batchNumber})
</insert>
<update id="update">
UPDATE fa_production_orders SET product_name=#{productName}, planned_qty=#{plannedQty},
status=#{status}, priority=#{priority}, planned_start=#{plannedStart},
planned_end=#{plannedEnd}, updated_at=NOW() WHERE id=#{id}
</update>
<update id="updateStatus">UPDATE fa_production_orders SET status=#{status}, updated_at=NOW() WHERE id=#{id}</update>
<update id="updateProgress">UPDATE fa_production_orders SET completed_qty=#{completedQty}, defect_qty=#{defectQty}, updated_at=NOW() WHERE id=#{id}</update>
<delete id="delete">DELETE FROM fa_production_orders WHERE id=#{id}</delete>
</mapper>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.fa.mapper.QrProcessCodeMapper">
<resultMap id="qrMap" type="com.zioinfo.fa.domain.QrProcessCode">
<id property="id" column="id"/>
<result property="qrCode" column="qr_code"/>
<result property="codeType" column="code_type"/>
<result property="productCode" column="product_code"/>
<result property="lotNumber" column="lot_number"/>
<result property="currentProcess" column="current_process"/>
<result property="currentWorkstation" column="current_workstation"/>
<result property="createdAt" column="created_at"/>
<result property="lastScannedAt" column="last_scanned_at"/>
</resultMap>
<select id="findAll" resultMap="qrMap">
SELECT * FROM fa_qr_process_codes
<where>
<if test="status != null">AND status = #{status}</if>
<if test="codeType != null">AND code_type = #{codeType}</if>
</where>
ORDER BY created_at DESC
</select>
<select id="findByQrCode" resultMap="qrMap">SELECT * FROM fa_qr_process_codes WHERE qr_code = #{qrCode}</select>
<select id="findById" resultMap="qrMap">SELECT * FROM fa_qr_process_codes WHERE id = #{id}</select>
<select id="findByLotNumber" resultMap="qrMap">SELECT * FROM fa_qr_process_codes WHERE lot_number = #{lotNumber}</select>
<select id="findByWorkstation" resultMap="qrMap">SELECT * FROM fa_qr_process_codes WHERE current_workstation = #{workstationId}</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO fa_qr_process_codes (qr_code, code_type, product_code, lot_number, quantity, current_process, current_workstation, status)
VALUES (#{qrCode}, #{codeType}, #{productCode}, #{lotNumber}, #{quantity}, #{currentProcess}, #{currentWorkstation}, #{status})
</insert>
<update id="update">
UPDATE fa_qr_process_codes SET code_type=#{codeType}, product_code=#{productCode},
quantity=#{quantity}, status=#{status} WHERE id=#{id}
</update>
<update id="updateStatus">
UPDATE fa_qr_process_codes SET status=#{status}, current_workstation=#{workstation},
current_process=#{process}, last_scanned_at=NOW() WHERE qr_code=#{qrCode}
</update>
<delete id="delete">DELETE FROM fa_qr_process_codes WHERE id=#{id}</delete>
<insert id="batchInsert"><!-- handled individually --></insert>
</mapper>

View File

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.fa.mapper.QrScanEventMapper">
<resultMap id="eventMap" type="com.zioinfo.fa.domain.QrScanEvent">
<id property="id" column="id"/>
<result property="qrCode" column="qr_code"/>
<result property="workstationId" column="workstation_id"/>
<result property="operatorId" column="operator_id"/>
<result property="scanType" column="scan_type"/>
<result property="defectCode" column="defect_code"/>
<result property="scannedAt" column="scanned_at"/>
<result property="processingTime" column="processing_time"/>
</resultMap>
<select id="findByQrCode" resultMap="eventMap">SELECT * FROM fa_qr_scan_events WHERE qr_code = #{qrCode} ORDER BY scanned_at DESC</select>
<select id="findByWorkstation" resultMap="eventMap">SELECT * FROM fa_qr_scan_events WHERE workstation_id = #{workstationId} ORDER BY scanned_at DESC LIMIT #{limit}</select>
<select id="findById" resultMap="eventMap">SELECT * FROM fa_qr_scan_events WHERE id = #{id}</select>
<select id="findRecentByWorkstation" resultMap="eventMap">SELECT * FROM fa_qr_scan_events WHERE workstation_id = #{workstationId} ORDER BY scanned_at DESC LIMIT #{limit}</select>
<select id="countByResult" resultType="long">SELECT COUNT(*) FROM fa_qr_scan_events WHERE result = #{result} AND scanned_at > NOW() - INTERVAL '1 hour' * #{hours}</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO fa_qr_scan_events (qr_code, workstation_id, operator_id, scan_type, result, defect_code, scanned_at, processing_time)
VALUES (#{qrCode}, #{workstationId}, #{operatorId}, #{scanType}, #{result}, #{defectCode}, #{scannedAt}, #{processingTime})
</insert>
</mapper>

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.fa.mapper.QualityInspectionMapper">
<resultMap id="qiMap" type="com.zioinfo.fa.domain.QualityInspection">
<id property="id" column="id"/>
<result property="inspectionNumber" column="inspection_number"/>
<result property="orderId" column="order_id"/>
<result property="lotNumber" column="lot_number"/>
<result property="inspectionType" column="inspection_type"/>
<result property="productCode" column="product_code"/>
<result property="sampleSize" column="sample_size"/>
<result property="defectCount" column="defect_count"/>
<result property="defectDetails" column="defect_details"/>
<result property="inspectorId" column="inspector_id"/>
<result property="inspectedAt" column="inspected_at"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="qiMap">
SELECT * FROM fa_quality_inspections
<where>
<if test="inspectionType != null">AND inspection_type = #{inspectionType}</if>
<if test="result != null">AND result = #{result}</if>
</where>
ORDER BY created_at DESC
</select>
<select id="findById" resultMap="qiMap">SELECT * FROM fa_quality_inspections WHERE id = #{id}</select>
<select id="findByNumber" resultMap="qiMap">SELECT * FROM fa_quality_inspections WHERE inspection_number = #{inspectionNumber}</select>
<select id="findByOrderId" resultMap="qiMap">SELECT * FROM fa_quality_inspections WHERE order_id = #{orderId}</select>
<select id="getDefectPareto" resultType="java.util.Map">
SELECT defect_code, COUNT(*) as count FROM fa_quality_defects GROUP BY defect_code ORDER BY count DESC LIMIT 10
</select>
<select id="getDashboardSummary" resultType="java.util.Map">
SELECT COUNT(*) as total_inspections,
SUM(CASE WHEN result='PASS' THEN 1 ELSE 0 END) as passed,
SUM(CASE WHEN result='FAIL' THEN 1 ELSE 0 END) as failed,
COALESCE(AVG(defect_count * 100.0 / NULLIF(sample_size,0)), 0) as defect_rate
FROM fa_quality_inspections WHERE DATE(created_at) = CURRENT_DATE
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO fa_quality_inspections (inspection_number, order_id, lot_number, inspection_type, product_code, sample_size, defect_count, result, defect_details, inspector_id, inspected_at)
VALUES (#{inspectionNumber}, #{orderId}, #{lotNumber}, #{inspectionType}, #{productCode}, #{sampleSize}, #{defectCount}, #{result}, #{defectDetails}, #{inspectorId}, #{inspectedAt})
</insert>
<update id="update">
UPDATE fa_quality_inspections SET result=#{result}, defect_count=#{defectCount}, defect_details=#{defectDetails} WHERE id=#{id}
</update>
<delete id="delete">DELETE FROM fa_quality_inspections WHERE id=#{id}</delete>
</mapper>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.fa.mapper.WorkstationMapper">
<resultMap id="wsMap" type="com.zioinfo.fa.domain.Workstation">
<id property="id" column="id"/>
<result property="workstationCode" column="workstation_code"/>
<result property="workstationName" column="workstation_name"/>
<result property="lineCode" column="line_code"/>
<result property="processCode" column="process_code"/>
<result property="workerCount" column="worker_count"/>
<result property="epaperDisplayId" column="epaper_display_id"/>
<result property="andonStatus" column="andon_status"/>
<result property="targetCount" column="target_count"/>
<result property="actualCount" column="actual_count"/>
<result property="currentOrderId" column="current_order_id"/>
<result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/>
</resultMap>
<select id="findAll" resultMap="wsMap">
SELECT * FROM fa_workstations
<where>
<if test="lineCode != null">AND line_code = #{lineCode}</if>
<if test="status != null">AND status = #{status}</if>
</where>
ORDER BY workstation_code
</select>
<select id="findById" resultMap="wsMap">SELECT * FROM fa_workstations WHERE id = #{id}</select>
<select id="findByCode" resultMap="wsMap">SELECT * FROM fa_workstations WHERE workstation_code = #{workstationCode}</select>
<select id="findFloorMap" resultMap="wsMap">SELECT * FROM fa_workstations ORDER BY line_code, workstation_code</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO fa_workstations (workstation_code, workstation_name, line_code, process_code, worker_count, status, andon_status)
VALUES (#{workstationCode}, #{workstationName}, #{lineCode}, #{processCode}, #{workerCount}, 'STOPPED', 'GREEN')
</insert>
<update id="update">
UPDATE fa_workstations SET workstation_name=#{workstationName}, line_code=#{lineCode},
process_code=#{processCode}, worker_count=#{workerCount}, updated_at=NOW() WHERE id=#{id}
</update>
<update id="updateAndon">UPDATE fa_workstations SET andon_status=#{andonStatus}, updated_at=NOW() WHERE workstation_code=#{code}</update>
<update id="updateStatus">UPDATE fa_workstations SET status=#{status}, updated_at=NOW() WHERE workstation_code=#{code}</update>
<update id="updateCount">UPDATE fa_workstations SET actual_count=#{actualCount}, updated_at=NOW() WHERE workstation_code=#{code}</update>
<delete id="delete">DELETE FROM fa_workstations WHERE id=#{id}</delete>
</mapper>

12
frontend/index.html Normal file
View File

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GUARDiA FA — Factory Automation Platform</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

27
frontend/package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "guardia-fa-frontend",
"version": "1.0.0",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^6.26.0",
"axios": "^1.7.0",
"recharts": "^2.12.0",
"lucide-react": "^0.400.0"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.4.0",
"vite": "^5.3.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

111
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,111 @@
import React, { useState } from 'react'
import { BrowserRouter, Routes, Route, Navigate, NavLink } from 'react-router-dom'
import {
LayoutDashboard, Map, Monitor, QrCode, Package,
AlertTriangle, ShieldCheck, Wrench, Boxes, Brain, LogOut
} from 'lucide-react'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import FloorMap from './pages/FloorMap'
import EpaperManagement from './pages/EpaperManagement'
import QrTracking from './pages/QrTracking'
import ProductionOrders from './pages/ProductionOrders'
import AndonBoard from './pages/AndonBoard'
import QualityControl from './pages/QualityControl'
import EquipmentStatus from './pages/EquipmentStatus'
import AiFactory from './pages/AiFactory'
const NAV_ITEMS = [
{ to: '/dashboard', icon: LayoutDashboard, label: '대시보드' },
{ to: '/floor-map', icon: Map, label: '공장 배치도' },
{ to: '/epaper', icon: Monitor, label: 'e-Paper 관리' },
{ to: '/qr', icon: QrCode, label: 'QR WIP 추적' },
{ to: '/orders', icon: Package, label: '생산 오더' },
{ to: '/andon', icon: AlertTriangle, label: '안돈 현황판' },
{ to: '/quality', icon: ShieldCheck, label: '품질 관리' },
{ to: '/equipment', icon: Wrench, label: '설비 현황' },
{ to: '/inventory', icon: Boxes, label: '재고 관리' },
{ to: '/ai', icon: Brain, label: 'AI 공장 분석' },
]
function Sidebar() {
const user = JSON.parse(localStorage.getItem('fa_user') || '{}')
return (
<aside className="w-60 bg-slate-900 border-r border-slate-700 flex flex-col h-screen fixed">
<div className="p-4 border-b border-slate-700">
<div className="text-blue-400 font-bold text-lg">GUARDiA FA</div>
<div className="text-slate-400 text-xs mt-1">Factory Automation Platform</div>
</div>
<nav className="flex-1 overflow-y-auto py-2">
{NAV_ITEMS.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
isActive
? 'bg-blue-600 text-white'
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
}`
}
>
<Icon size={16} />
{label}
</NavLink>
))}
</nav>
<div className="p-4 border-t border-slate-700">
<div className="text-xs text-slate-400 mb-2">{user.username} ({user.role})</div>
<button
onClick={() => { localStorage.clear(); window.location.href = '/login' }}
className="flex items-center gap-2 text-xs text-slate-400 hover:text-red-400 transition-colors"
>
<LogOut size={14} />
</button>
</div>
</aside>
)
}
function RequireAuth({ children }: { children: React.ReactNode }) {
const token = localStorage.getItem('fa_token')
return token ? <>{children}</> : <Navigate to="/login" replace />
}
function Layout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar />
<main className="ml-60 flex-1 min-h-screen p-6">{children}</main>
</div>
)
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/*" element={
<RequireAuth>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/floor-map" element={<FloorMap />} />
<Route path="/epaper" element={<EpaperManagement />} />
<Route path="/qr" element={<QrTracking />} />
<Route path="/orders" element={<ProductionOrders />} />
<Route path="/andon" element={<AndonBoard />} />
<Route path="/quality" element={<QualityControl />} />
<Route path="/equipment" element={<EquipmentStatus />} />
<Route path="/inventory" element={<AiFactory />} />
<Route path="/ai" element={<AiFactory />} />
</Routes>
</Layout>
</RequireAuth>
} />
</Routes>
</BrowserRouter>
)
}

View File

@ -0,0 +1,28 @@
import axios from 'axios'
const client = axios.create({
baseURL: '/api/fa',
timeout: 15000,
})
client.interceptors.request.use((config) => {
const token = localStorage.getItem('fa_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('fa_token')
localStorage.removeItem('fa_user')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default client

10
frontend/src/index.css Normal file
View File

@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: #0f172a;
color: #e2e8f0;
}

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>
)

View File

@ -0,0 +1,165 @@
import React, { useState } from 'react'
import { Brain, TrendingUp, Package, Wrench } from 'lucide-react'
import client from '../api/client'
interface AiPanel {
title: string
desc: string
icon: React.ReactNode
action: () => Promise<string>
color: string
}
export default function AiFactory() {
const [results, setResults] = useState<Record<string, string>>({})
const [loading, setLoading] = useState<Record<string, boolean>>({})
const [inventory, setInventory] = useState<any[]>([])
const [inventoryLoaded, setInventoryLoaded] = useState(false)
const run = async (key: string, action: () => Promise<string>) => {
setLoading(prev => ({ ...prev, [key]: true }))
try {
const r = await action()
setResults(prev => ({ ...prev, [key]: r }))
} catch {
setResults(prev => ({ ...prev, [key]: 'AI 분석 일시 중단. 온프레미스 Ollama 연결을 확인하세요.' }))
}
setLoading(prev => ({ ...prev, [key]: false }))
}
const loadInventory = async () => {
const r = await client.get('/inventory')
setInventory(r.data)
setInventoryLoaded(true)
}
const panels: AiPanel[] = [
{
title: '생산 수요 예측',
desc: '과거 생산 데이터 기반 Ollama AI 수요/생산 예측',
icon: <TrendingUp size={18} />,
color: 'text-blue-400 border-blue-700',
action: async () => {
const r = await client.post('/quality/ai-analyze', {
productCode: 'ALL',
defectDescription: '최근 생산 트렌드 분석 및 다음 주 수요 예측',
})
return r.data.analysis
},
},
{
title: '설비 이상 감지',
desc: 'OEE 하락 패턴 분석 및 예지보전 AI 권고',
icon: <Wrench size={18} />,
color: 'text-yellow-400 border-yellow-700',
action: async () => {
const r = await client.post('/equipment/ai-predict', {
equipmentCode: 'ALL',
oeeTrend: '전체 설비 OEE 분석 및 이상 감지 요청',
})
return r.data.prediction
},
},
{
title: '재고 최적화',
desc: '안전재고 분석 및 발주 최적화 AI 권고',
icon: <Package size={18} />,
color: 'text-green-400 border-green-700',
action: async () => {
const r = await client.post('/inventory/ai-optimize', {
lowStockItems: '재고 부족 품목 자동 감지 및 최적 발주량 계산',
})
return r.data.optimization
},
},
{
title: '품질 불량 분석',
desc: '공정별 불량 패턴 AI 분석 및 개선 방안',
icon: <Brain size={18} />,
color: 'text-purple-400 border-purple-700',
action: async () => {
const r = await client.post('/quality/ai-analyze', {
productCode: 'ALL',
defectDescription: '전체 공정 불량 패턴 분석 및 개선 방안 도출',
})
return r.data.analysis
},
},
]
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-white">AI (Ollama )</h1>
<div className="bg-blue-900/30 border border-blue-700 rounded-xl p-4 text-sm text-blue-300">
AI Ollama(localhost:11434) . AI API는 .
</div>
<div className="grid grid-cols-2 gap-6">
{panels.map(p => (
<div key={p.title} className={`bg-slate-800 rounded-xl p-5 border ${p.color.split(' ')[1]}`}>
<div className="flex items-center gap-2 mb-2">
<span className={p.color.split(' ')[0]}>{p.icon}</span>
<h2 className="text-sm font-medium text-white">{p.title}</h2>
</div>
<p className="text-xs text-slate-400 mb-4">{p.desc}</p>
<button
onClick={() => run(p.title, p.action)}
disabled={loading[p.title]}
className="flex items-center gap-1 px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-xs disabled:opacity-50 mb-3 transition-colors"
>
<Brain size={12} /> {loading[p.title] ? '분석 중...' : 'AI 분석 실행'}
</button>
{results[p.title] && (
<div className="bg-slate-700/50 rounded-lg p-3 text-xs text-slate-200 leading-relaxed max-h-48 overflow-y-auto">
{results[p.title]}
</div>
)}
</div>
))}
</div>
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-medium text-slate-300"> </h2>
<button onClick={loadInventory}
className="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-xs transition-colors">
</button>
</div>
{inventoryLoaded && (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-700">
<th className="px-3 py-2 text-left text-slate-400"></th>
<th className="px-3 py-2 text-left text-slate-400"></th>
<th className="px-3 py-2 text-left text-slate-400"></th>
<th className="px-3 py-2 text-left text-slate-400"></th>
<th className="px-3 py-2 text-left text-slate-400"></th>
<th className="px-3 py-2 text-left text-slate-400"></th>
</tr>
</thead>
<tbody>
{inventory.map((i: any) => (
<tr key={i.id} className="border-b border-slate-700/50">
<td className="px-3 py-2 font-mono text-blue-400">{i.itemCode}</td>
<td className="px-3 py-2 text-white">{i.itemName}</td>
<td className="px-3 py-2 text-slate-400">{i.locationCode}</td>
<td className="px-3 py-2 text-slate-300">{i.quantity}</td>
<td className="px-3 py-2 text-slate-400">{i.safetyStock}</td>
<td className="px-3 py-2">
<span className={(i.quantity || 0) <= (i.safetyStock || 0) ? 'text-red-400' : 'text-green-400'}>
{(i.quantity || 0) <= (i.safetyStock || 0) ? '부족' : '정상'}
</span>
</td>
</tr>
))}
</tbody>
</table>
)}
{inventoryLoaded && inventory.length === 0 && (
<div className="text-center text-slate-500 py-4"> .</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,134 @@
import React, { useEffect, useState } from 'react'
import { AlertTriangle, CheckCircle, UserCheck } from 'lucide-react'
import client from '../api/client'
const TYPE_COLORS: Record<string, string> = {
QUALITY: 'bg-yellow-800 border-yellow-600',
MATERIAL: 'bg-orange-800 border-orange-600',
MACHINE: 'bg-red-800 border-red-600',
SAFETY: 'bg-red-900 border-red-500',
HELP: 'bg-blue-800 border-blue-600',
}
const SEVERITY_COLORS: Record<string, string> = {
INFO: 'text-blue-400',
WARNING: 'text-yellow-400',
CRITICAL: 'text-red-400',
}
export default function AndonBoard() {
const [board, setBoard] = useState<any[]>([])
const [events, setEvents] = useState<any[]>([])
const [stats, setStats] = useState<any>({})
const load = () => {
client.get('/andon/board').then(r => setBoard(r.data))
client.get('/andon/events').then(r => setEvents(r.data))
client.get('/andon/stats').then(r => setStats(r.data))
}
useEffect(() => { load(); const t = setInterval(load, 10000); return () => clearInterval(t) }, [])
const respond = async (id: number) => {
const user = JSON.parse(localStorage.getItem('fa_user') || '{}')
await client.put(`/andon/${id}/respond`, { responseBy: user.username })
load()
}
const resolve = async (id: number) => {
await client.put(`/andon/${id}/resolve`)
load()
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white"> </h1>
<div className="text-xs text-slate-500">10 </div>
</div>
<div className="grid grid-cols-4 gap-4">
{[
{ label: '오늘 발생', value: stats.total_today, color: 'text-white' },
{ label: '해결 완료', value: stats.resolved, color: 'text-green-400' },
{ label: '현재 활성', value: stats.active, color: 'text-red-400' },
{ label: '평균 응답', value: `${Math.round(stats.avg_response_time || 0)}`, color: 'text-yellow-400' },
].map(({ label, value, color }) => (
<div key={label} className="bg-slate-800 rounded-xl p-4 border border-slate-700 text-center">
<div className={`text-2xl font-bold ${color}`}>{value ?? '-'}</div>
<div className="text-xs text-slate-400 mt-1">{label}</div>
</div>
))}
</div>
{board.length > 0 && (
<div>
<h2 className="text-sm font-medium text-red-400 mb-3"> ({board.length})</h2>
<div className="grid grid-cols-3 gap-4">
{board.map(e => (
<div key={e.id} className={`border-2 rounded-xl p-4 ${TYPE_COLORS[e.andonType] || 'bg-slate-800 border-slate-600'}`}>
<div className="flex items-center gap-2 mb-2">
<AlertTriangle size={16} className={SEVERITY_COLORS[e.severity] || 'text-yellow-400'} />
<span className="font-bold text-white">{e.workstationCode}</span>
<span className="text-xs text-white/60">{e.andonType}</span>
</div>
<div className="text-sm text-white/80 mb-3">{e.description}</div>
<div className="text-xs text-white/60 mb-3">: {e.calledBy} · {e.calledAt?.replace('T', ' ').slice(0, 16)}</div>
<div className="flex gap-2">
{!e.respondedAt && (
<button onClick={() => respond(e.id)}
className="flex items-center gap-1 px-2 py-1 bg-white/20 hover:bg-white/30 text-white rounded text-xs">
<UserCheck size={10} />
</button>
)}
<button onClick={() => resolve(e.id)}
className="flex items-center gap-1 px-2 py-1 bg-green-700 hover:bg-green-600 text-white rounded text-xs">
<CheckCircle size={10} />
</button>
</div>
</div>
))}
</div>
</div>
)}
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<div className="px-4 py-3 border-b border-slate-700">
<h2 className="text-sm font-medium text-slate-300"> </h2>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-700">
<th className="px-4 py-2 text-left text-slate-400"></th>
<th className="px-4 py-2 text-left text-slate-400"></th>
<th className="px-4 py-2 text-left text-slate-400"></th>
<th className="px-4 py-2 text-left text-slate-400"></th>
<th className="px-4 py-2 text-left text-slate-400"> </th>
<th className="px-4 py-2 text-left text-slate-400"></th>
<th className="px-4 py-2 text-left text-slate-400"></th>
</tr>
</thead>
<tbody>
{events.slice(0, 20).map(e => (
<tr key={e.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
<td className="px-4 py-2 font-bold text-white">{e.workstationCode}</td>
<td className="px-4 py-2 text-slate-300">{e.andonType}</td>
<td className="px-4 py-2">
<span className={SEVERITY_COLORS[e.severity] || 'text-slate-400'}>{e.severity}</span>
</td>
<td className="px-4 py-2 text-slate-300 max-w-xs truncate">{e.description}</td>
<td className="px-4 py-2 text-slate-400 text-xs">{e.calledAt?.replace('T', ' ').slice(0, 16)}</td>
<td className="px-4 py-2">
<span className={e.resolvedAt ? 'text-green-400' : e.respondedAt ? 'text-yellow-400' : 'text-red-400'}>
{e.resolvedAt ? 'RESOLVED' : e.respondedAt ? 'IN_PROGRESS' : 'OPEN'}
</span>
</td>
<td className="px-4 py-2 text-slate-400">{e.responseTimeSeconds ? `${e.responseTimeSeconds}` : '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

View File

@ -0,0 +1,119 @@
import React, { useEffect, useState } from 'react'
import { RadialBarChart, RadialBar, ResponsiveContainer, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip } from 'recharts'
import client from '../api/client'
export default function Dashboard() {
const [overview, setOverview] = useState<any>({})
const [oee, setOee] = useState<any>({})
const [andon, setAndon] = useState<any>({})
const [epaper, setEpaper] = useState<any>({})
useEffect(() => {
Promise.all([
client.get('/dashboard/overview'),
client.get('/dashboard/oee-summary'),
client.get('/dashboard/andon-summary'),
client.get('/dashboard/epaper-status'),
]).then(([o, oe, a, ep]) => {
setOverview(o.data)
setOee(oe.data)
setAndon(a.data)
setEpaper(ep.data)
}).catch(console.error)
}, [])
const oeeData = [
{ name: '가동률', value: Math.round((oee.availability || 0.92) * 100), fill: '#3b82f6' },
{ name: '성능률', value: Math.round((oee.performance || 0.88) * 100), fill: '#10b981' },
{ name: '품질률', value: Math.round((oee.quality || 0.97) * 100), fill: '#f59e0b' },
]
const StatCard = ({ title, value, sub, color }: any) => (
<div className={`bg-slate-800 rounded-xl p-5 border border-slate-700`}>
<div className="text-slate-400 text-sm mb-1">{title}</div>
<div className={`text-3xl font-bold ${color || 'text-white'}`}>{value ?? '-'}</div>
{sub && <div className="text-slate-500 text-xs mt-1">{sub}</div>}
</div>
)
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-white"> </h1>
<div className="grid grid-cols-4 gap-4">
<StatCard title="총 작업장" value={overview.workstations} color="text-blue-400" sub="운영 중" />
<StatCard title="오늘 생산 오더" value={overview.todayOrders} color="text-green-400" sub="건" />
<StatCard title="활성 안돈" value={overview.activeAndon} color="text-red-400" sub="건 대응 필요" />
<StatCard title="e-Paper 온라인" value={epaper.online} color="text-emerald-400" sub={`전체 ${epaper.total}`} />
</div>
<div className="grid grid-cols-2 gap-6">
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700">
<h2 className="text-sm font-medium text-slate-300 mb-4">OEE ( )</h2>
<div className="flex items-center gap-4">
<div className="text-center">
<div className="text-4xl font-bold text-blue-400">
{Math.round(((oee.availability || 0.92) * (oee.performance || 0.88) * (oee.quality || 0.97)) * 100)}%
</div>
<div className="text-slate-500 text-xs mt-1"> OEE</div>
</div>
<div className="flex-1">
{oeeData.map(d => (
<div key={d.name} className="flex items-center gap-2 mb-2">
<div className="text-xs text-slate-400 w-16">{d.name}</div>
<div className="flex-1 bg-slate-700 rounded-full h-2">
<div className="h-2 rounded-full" style={{ width: `${d.value}%`, backgroundColor: d.fill }} />
</div>
<div className="text-xs text-slate-300 w-8 text-right">{d.value}%</div>
</div>
))}
</div>
</div>
</div>
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700">
<h2 className="text-sm font-medium text-slate-300 mb-4">e-Paper </h2>
<div className="grid grid-cols-2 gap-3">
<div className="bg-slate-700 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-emerald-400">{epaper.online || 0}</div>
<div className="text-xs text-slate-400"></div>
</div>
<div className="bg-slate-700 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-slate-400">{epaper.offline || 0}</div>
<div className="text-xs text-slate-400"></div>
</div>
<div className="bg-slate-700 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-yellow-400">{epaper.batteryLow || 0}</div>
<div className="text-xs text-slate-400"> </div>
</div>
<div className="bg-slate-700 rounded-lg p-3 text-center">
<div className="text-2xl font-bold text-blue-400">{epaper.total || 0}</div>
<div className="text-xs text-slate-400"></div>
</div>
</div>
</div>
</div>
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700">
<h2 className="text-sm font-medium text-slate-300 mb-4"> ()</h2>
<div className="grid grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-white">{andon?.total_today || 0}</div>
<div className="text-xs text-slate-400"> </div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-400">{andon?.resolved || 0}</div>
<div className="text-xs text-slate-400"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-400">{andon?.active || 0}</div>
<div className="text-xs text-slate-400"></div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-400">{Math.round(andon?.avg_response_time || 0)}</div>
<div className="text-xs text-slate-400"> </div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,120 @@
import React, { useEffect, useState } from 'react'
import { Monitor, Battery, Wifi, Send, RefreshCw } from 'lucide-react'
import client from '../api/client'
const STATUS_COLORS: Record<string, string> = {
ONLINE: 'text-green-400',
OFFLINE: 'text-slate-500',
UPDATING: 'text-yellow-400',
ERROR: 'text-red-400',
}
export default function EpaperManagement() {
const [displays, setDisplays] = useState<any[]>([])
const [dashboard, setDashboard] = useState<any>({})
const [filter, setFilter] = useState('')
const load = () => {
client.get('/epaper', { params: filter ? { status: filter } : {} }).then(r => setDisplays(r.data))
client.get('/epaper/dashboard').then(r => setDashboard(r.data))
}
useEffect(() => { load() }, [filter])
const push = async (id: number) => {
const content = prompt('푸시할 내용 (JSON 형식):')
if (!content) return
await client.post(`/epaper/${id}/push`, { content, templateId: 'default' })
load()
}
const refresh = async (id: number) => {
await client.post(`/epaper/${id}/refresh`)
alert('갱신 요청 완료')
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white">e-Paper </h1>
<div className="flex gap-2">
{['', 'ONLINE', 'OFFLINE', 'ERROR'].map(s => (
<button key={s} onClick={() => setFilter(s)}
className={`px-3 py-1.5 rounded text-xs transition-colors ${filter === s ? 'bg-blue-600 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'}`}>
{s || '전체'}
</button>
))}
</div>
</div>
<div className="grid grid-cols-4 gap-4">
{[
{ label: '전체', value: dashboard.total, color: 'text-white' },
{ label: '온라인', value: dashboard.online, color: 'text-green-400' },
{ label: '오프라인', value: dashboard.offline, color: 'text-slate-400' },
{ label: '배터리 부족', value: dashboard.batteryLow, color: 'text-yellow-400' },
].map(({ label, value, color }) => (
<div key={label} className="bg-slate-800 rounded-xl p-4 border border-slate-700 text-center">
<div className={`text-2xl font-bold ${color}`}>{value ?? '-'}</div>
<div className="text-xs text-slate-400 mt-1">{label}</div>
</div>
))}
</div>
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-700">
<th className="px-4 py-3 text-left text-slate-400"> ID</th>
<th className="px-4 py-3 text-left text-slate-400"></th>
<th className="px-4 py-3 text-left text-slate-400">/</th>
<th className="px-4 py-3 text-left text-slate-400"></th>
<th className="px-4 py-3 text-left text-slate-400"></th>
<th className="px-4 py-3 text-left text-slate-400"></th>
<th className="px-4 py-3 text-left text-slate-400"></th>
<th className="px-4 py-3 text-left text-slate-400"></th>
</tr>
</thead>
<tbody>
{displays.map(d => (
<tr key={d.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
<td className="px-4 py-3 font-mono text-blue-400">{d.displayId}</td>
<td className="px-4 py-3 text-slate-300">{d.workstationId || '-'}</td>
<td className="px-4 py-3 text-slate-300">{d.displaySize} / {d.displayType}</td>
<td className="px-4 py-3">
<span className={`flex items-center gap-1 ${(d.batteryLevel || 0) < 20 ? 'text-red-400' : 'text-green-400'}`}>
<Battery size={12} />{d.batteryLevel?.toFixed(0) || 0}%
</span>
</td>
<td className="px-4 py-3">
<span className="flex items-center gap-1 text-slate-300">
<Wifi size={12} />{d.signalStrength || '-'} dBm
</span>
</td>
<td className="px-4 py-3">
<span className={`font-medium ${STATUS_COLORS[d.status] || 'text-slate-400'}`}>{d.status}</span>
</td>
<td className="px-4 py-3 text-slate-400 text-xs">{d.protocol}</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button onClick={() => push(d.id)}
className="flex items-center gap-1 px-2 py-1 bg-blue-700 hover:bg-blue-600 rounded text-xs text-white transition-colors">
<Send size={10} />
</button>
<button onClick={() => refresh(d.id)}
className="flex items-center gap-1 px-2 py-1 bg-slate-600 hover:bg-slate-500 rounded text-xs text-white transition-colors">
<RefreshCw size={10} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{displays.length === 0 && (
<div className="text-center text-slate-500 py-8"> e-Paper .</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,111 @@
import React, { useEffect, useState } from 'react'
import { Wrench, Brain, AlertTriangle } from 'lucide-react'
import client from '../api/client'
const STATUS_COLORS: Record<string, string> = {
RUNNING: 'text-green-400',
IDLE: 'text-slate-400',
MAINTENANCE: 'text-yellow-400',
BREAKDOWN: 'text-red-400',
}
export default function EquipmentStatus() {
const [equipment, setEquipment] = useState<any[]>([])
const [riskList, setRiskList] = useState<any[]>([])
const [aiResult, setAiResult] = useState('')
const [predicting, setPredicting] = useState(false)
useEffect(() => {
client.get('/equipment').then(r => setEquipment(r.data))
client.get('/equipment/breakdown-risk').then(r => setRiskList(r.data))
}, [])
const aiPredict = async () => {
setPredicting(true)
try {
const res = await client.post('/equipment/ai-predict', {
equipmentCode: 'EQ-001',
oeeTrend: '0.85→0.78→0.71 (3일 하락 추세)',
})
setAiResult(res.data.prediction)
} catch { setAiResult('예지보전 AI 일시 중단') }
setPredicting(false)
}
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-white"> </h1>
{riskList.length > 0 && (
<div className="bg-red-900/30 border border-red-700 rounded-xl p-4">
<div className="flex items-center gap-2 mb-3">
<AlertTriangle size={16} className="text-red-400" />
<span className="text-sm font-medium text-red-300"> ({riskList.length})</span>
</div>
<div className="flex flex-wrap gap-2">
{riskList.map(e => (
<span key={e.id} className="px-2 py-1 bg-red-800 text-red-200 rounded text-xs">
{e.equipmentCode} (OEE {Math.round((e.oee || 0) * 100)}%)
</span>
))}
</div>
</div>
)}
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium text-slate-300">AI </h2>
<button onClick={aiPredict} disabled={predicting}
className="flex items-center gap-1 px-3 py-1.5 bg-purple-700 hover:bg-purple-600 text-white rounded text-xs disabled:opacity-50">
<Brain size={12} /> {predicting ? '분석 중...' : 'AI 예측'}
</button>
</div>
{aiResult ? (
<div className="bg-slate-700 rounded-lg p-3 text-sm text-slate-200 leading-relaxed">{aiResult}</div>
) : (
<div className="text-slate-500 text-sm">AI Ollama .</div>
)}
</div>
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-700">
<th className="px-4 py-3 text-left text-slate-400"> </th>
<th className="px-4 py-3 text-left text-slate-400"></th>
<th className="px-4 py-3 text-left text-slate-400"></th>
<th className="px-4 py-3 text-left text-slate-400"></th>
<th className="px-4 py-3 text-left text-slate-400">OEE</th>
<th className="px-4 py-3 text-left text-slate-400"></th>
<th className="px-4 py-3 text-left text-slate-400"> </th>
</tr>
</thead>
<tbody>
{equipment.map(e => (
<tr key={e.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
<td className="px-4 py-3 font-mono text-blue-400">{e.equipmentCode}</td>
<td className="px-4 py-3 text-white">{e.equipmentName}</td>
<td className="px-4 py-3 text-slate-300">{e.workstationCode}</td>
<td className="px-4 py-3 text-slate-400">{e.manufacturer}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-16 bg-slate-700 rounded-full h-1.5">
<div className={`h-1.5 rounded-full ${(e.oee || 0) >= 0.85 ? 'bg-green-500' : (e.oee || 0) >= 0.7 ? 'bg-yellow-500' : 'bg-red-500'}`}
style={{ width: `${(e.oee || 0) * 100}%` }} />
</div>
<span className="text-xs text-slate-300">{Math.round((e.oee || 0) * 100)}%</span>
</div>
</td>
<td className="px-4 py-3">
<span className={`font-medium ${STATUS_COLORS[e.status] || 'text-slate-400'}`}>{e.status}</span>
</td>
<td className="px-4 py-3 text-slate-400 text-xs">{e.nextMaintenanceAt?.slice(0, 10) || '-'}</td>
</tr>
))}
</tbody>
</table>
{equipment.length === 0 && <div className="text-center text-slate-500 py-8"> .</div>}
</div>
</div>
)
}

View File

@ -0,0 +1,74 @@
import React, { useEffect, useState } from 'react'
import client from '../api/client'
const ANDON_COLORS: Record<string, string> = {
GREEN: 'bg-green-600 border-green-500',
YELLOW: 'bg-yellow-600 border-yellow-500',
RED: 'bg-red-600 border-red-500',
BLUE: 'bg-blue-600 border-blue-500',
}
export default function FloorMap() {
const [workstations, setWorkstations] = useState<any[]>([])
useEffect(() => {
client.get('/workstations/floor-map').then(r => setWorkstations(r.data)).catch(console.error)
}, [])
const lines = Array.from(new Set(workstations.map(w => w.lineCode || 'DEFAULT')))
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-white"> (Floor Map)</h1>
<div className="bg-slate-800 rounded-xl p-6 border border-slate-700">
<div className="text-sm text-slate-400 mb-4"> :
<span className="ml-2 px-2 py-0.5 bg-green-600 rounded text-xs">GREEN </span>
<span className="ml-2 px-2 py-0.5 bg-yellow-600 rounded text-xs">YELLOW </span>
<span className="ml-2 px-2 py-0.5 bg-red-600 rounded text-xs">RED </span>
<span className="ml-2 px-2 py-0.5 bg-blue-600 rounded text-xs">BLUE </span>
</div>
{lines.map(line => (
<div key={line} className="mb-6">
<h3 className="text-slate-300 text-sm font-medium mb-3 border-b border-slate-700 pb-2">{line}</h3>
<div className="flex flex-wrap gap-4">
{workstations.filter(w => (w.lineCode || 'DEFAULT') === line).map(ws => (
<div
key={ws.id}
className={`border-2 rounded-xl p-4 w-48 ${ANDON_COLORS[ws.andonStatus] || 'bg-slate-700 border-slate-600'}`}
>
<div className="font-bold text-white text-sm">{ws.workstationCode}</div>
<div className="text-xs text-white/80 mt-1">{ws.workstationName}</div>
<div className="mt-3 space-y-1">
<div className="text-xs text-white/70">: {ws.processCode}</div>
<div className="text-xs text-white/70">: {ws.status}</div>
<div className="flex justify-between mt-2">
<span className="text-xs text-white/70"></span>
<span className="text-xs font-bold text-white">{ws.targetCount || 0}</span>
</div>
<div className="flex justify-between">
<span className="text-xs text-white/70"></span>
<span className="text-xs font-bold text-white">{ws.actualCount || 0}</span>
</div>
{ws.oee && (
<div className="mt-2">
<div className="flex justify-between text-xs text-white/70 mb-1">
<span>OEE</span><span>{Math.round(ws.oee * 100)}%</span>
</div>
<div className="bg-white/20 rounded-full h-1.5">
<div className="bg-white h-1.5 rounded-full" style={{ width: `${ws.oee * 100}%` }} />
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
))}
{workstations.length === 0 && (
<div className="text-center text-slate-500 py-8"> .</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,78 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
export default function Login() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await axios.post('/api/fa/auth/login', { username, password })
localStorage.setItem('fa_token', res.data.token)
localStorage.setItem('fa_user', JSON.stringify({
username: res.data.username,
role: res.data.role,
workstationCode: res.data.workstationCode
}))
navigate('/dashboard')
} catch (err: any) {
setError(err.response?.data?.error || '로그인 실패')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="bg-slate-800 rounded-xl p-8 w-96 border border-slate-700">
<div className="text-center mb-8">
<div className="text-blue-400 text-3xl font-bold mb-2">GUARDiA FA</div>
<div className="text-slate-400 text-sm">Factory Automation Platform</div>
<div className="text-slate-500 text-xs mt-1">e-Paper · QR WIP · MES </div>
</div>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block text-sm text-slate-300 mb-1"></label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
placeholder="username"
required
/>
</div>
<div>
<label className="block text-sm text-slate-300 mb-1"></label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500"
placeholder="password"
required
/>
</div>
{error && <div className="text-red-400 text-sm">{error}</div>}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg py-2.5 text-sm font-medium transition-colors"
>
{loading ? '로그인 중...' : '로그인'}
</button>
</form>
<div className="mt-4 text-xs text-slate-500 text-center">
계정: admin / admin123
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,126 @@
import React, { useEffect, useState } from 'react'
import { Plus, Play, CheckCircle } from 'lucide-react'
import client from '../api/client'
const STATUS_BADGE: Record<string, string> = {
PLANNED: 'bg-slate-600 text-slate-200',
RELEASED: 'bg-blue-700 text-blue-200',
IN_PROGRESS: 'bg-yellow-700 text-yellow-200',
COMPLETED: 'bg-green-700 text-green-200',
}
export default function ProductionOrders() {
const [orders, setOrders] = useState<any[]>([])
const [dashboard, setDashboard] = useState<any>({})
const [filter, setFilter] = useState('')
const load = () => {
client.get('/orders', { params: filter ? { status: filter } : {} }).then(r => setOrders(r.data))
client.get('/orders/dashboard').then(r => setDashboard(r.data))
}
useEffect(() => { load() }, [filter])
const release = async (id: number) => {
await client.post(`/orders/${id}/release`)
load()
}
const complete = async (id: number) => {
await client.post(`/orders/${id}/complete`)
load()
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white"> </h1>
<div className="flex gap-2">
{['', 'PLANNED', 'RELEASED', 'IN_PROGRESS', 'COMPLETED'].map(s => (
<button key={s} onClick={() => setFilter(s)}
className={`px-3 py-1.5 rounded text-xs transition-colors ${filter === s ? 'bg-blue-600 text-white' : 'bg-slate-700 text-slate-300 hover:bg-slate-600'}`}>
{s || '전체'}
</button>
))}
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 text-center">
<div className="text-2xl font-bold text-white">{dashboard.todayTotal || 0}</div>
<div className="text-xs text-slate-400"> </div>
</div>
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 text-center">
<div className="text-2xl font-bold text-yellow-400">{dashboard.inProgress || 0}</div>
<div className="text-xs text-slate-400"> </div>
</div>
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 text-center">
<div className="text-2xl font-bold text-green-400">{dashboard.completed || 0}</div>
<div className="text-xs text-slate-400"></div>
</div>
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 text-center">
<div className="text-2xl font-bold text-blue-400">{Math.round(dashboard.completionRate || 0)}%</div>
<div className="text-xs text-slate-400"></div>
</div>
</div>
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-700">
<th className="px-4 py-3 text-left text-slate-400"> </th>
<th className="px-4 py-3 text-left text-slate-400"></th>
<th className="px-4 py-3 text-left text-slate-400"></th>
<th className="px-4 py-3 text-left text-slate-400"> </th>
<th className="px-4 py-3 text-left text-slate-400"> </th>
<th className="px-4 py-3 text-left text-slate-400"></th>
<th className="px-4 py-3 text-left text-slate-400"></th>
<th className="px-4 py-3 text-left text-slate-400"></th>
</tr>
</thead>
<tbody>
{orders.map(o => {
const pct = o.plannedQty ? Math.round((o.completedQty || 0) * 100 / o.plannedQty) : 0
return (
<tr key={o.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
<td className="px-4 py-3 font-mono text-blue-400">{o.orderNumber}</td>
<td className="px-4 py-3 text-white">{o.productName}</td>
<td className="px-4 py-3 text-slate-300">{o.lineCode}</td>
<td className="px-4 py-3 text-slate-300">{o.plannedQty?.toLocaleString()}</td>
<td className="px-4 py-3 text-slate-300">{o.completedQty?.toLocaleString() || 0}</td>
<td className="px-4 py-3 w-32">
<div className="flex items-center gap-2">
<div className="flex-1 bg-slate-700 rounded-full h-1.5">
<div className="bg-blue-500 h-1.5 rounded-full" style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-slate-400">{pct}%</span>
</div>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_BADGE[o.status] || ''}`}>{o.status}</span>
</td>
<td className="px-4 py-3">
<div className="flex gap-1">
{o.status === 'PLANNED' && (
<button onClick={() => release(o.id)}
className="flex items-center gap-1 px-2 py-1 bg-blue-700 hover:bg-blue-600 text-white rounded text-xs">
<Play size={10} />
</button>
)}
{o.status === 'IN_PROGRESS' && (
<button onClick={() => complete(o.id)}
className="flex items-center gap-1 px-2 py-1 bg-green-700 hover:bg-green-600 text-white rounded text-xs">
<CheckCircle size={10} />
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
{orders.length === 0 && <div className="text-center text-slate-500 py-8"> .</div>}
</div>
</div>
)
}

View File

@ -0,0 +1,155 @@
import React, { useState } from 'react'
import { QrCode, Search, Plus } from 'lucide-react'
import client from '../api/client'
export default function QrTracking() {
const [searchCode, setSearchCode] = useState('')
const [qrData, setQrData] = useState<any>(null)
const [history, setHistory] = useState<any[]>([])
const [route, setRoute] = useState<any[]>([])
const [lotSearch, setLotSearch] = useState('')
const [lotResults, setLotResults] = useState<any[]>([])
const search = async () => {
if (!searchCode.trim()) return
try {
const [code, hist, rt] = await Promise.all([
client.get(`/qr/${searchCode}`),
client.get(`/qr/${searchCode}/history`),
client.get(`/qr/${searchCode}/route`),
])
setQrData(code.data)
setHistory(hist.data)
setRoute(rt.data)
} catch { setQrData(null) }
}
const searchLot = async () => {
if (!lotSearch.trim()) return
const res = await client.get(`/qr/lot/${lotSearch}`)
setLotResults(res.data)
}
const generate = async () => {
const productCode = prompt('제품 코드:')
if (!productCode) return
const lotNumber = prompt('LOT 번호:')
const qty = prompt('수량:')
const res = await client.post('/qr/generate', {
codeType: 'WIP',
productCode,
lotNumber,
quantity: parseInt(qty || '1'),
currentProcess: 'START',
})
alert(`QR 코드 생성: ${res.data.qrCode}`)
}
const STATUS_BADGE: Record<string, string> = {
IN_PROCESS: 'bg-blue-700 text-blue-200',
WAITING: 'bg-yellow-700 text-yellow-200',
COMPLETED: 'bg-green-700 text-green-200',
HOLD: 'bg-orange-700 text-orange-200',
REJECTED: 'bg-red-700 text-red-200',
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-bold text-white">QR WIP </h1>
<button onClick={generate}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm transition-colors">
<Plus size={14} /> QR
</button>
</div>
<div className="grid grid-cols-2 gap-6">
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700">
<h2 className="text-sm font-medium text-slate-300 mb-3">QR </h2>
<div className="flex gap-2 mb-4">
<input value={searchCode} onChange={e => setSearchCode(e.target.value)}
placeholder="QR 코드 입력..."
className="flex-1 bg-slate-700 border border-slate-600 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-blue-500"
/>
<button onClick={search}
className="px-3 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
<Search size={16} />
</button>
</div>
{qrData && (
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-slate-400"></span>
<span className="text-white font-mono">{qrData.productCode}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400">LOT </span>
<span className="text-white font-mono">{qrData.lotNumber}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400"> </span>
<span className="text-white">{qrData.currentProcess}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400"> </span>
<span className="text-white">{qrData.currentWorkstation}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-400"></span>
<span className={`px-2 py-0.5 rounded text-xs ${STATUS_BADGE[qrData.status] || 'bg-slate-600 text-slate-200'}`}>{qrData.status}</span>
</div>
</div>
)}
</div>
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700">
<h2 className="text-sm font-medium text-slate-300 mb-3"> </h2>
{route.length > 0 ? (
<div className="space-y-2">
{route.map((r, i) => (
<div key={r.id} className="flex items-center gap-3">
<div className="w-6 h-6 rounded-full bg-blue-600 text-white text-xs flex items-center justify-center font-bold">{r.sequence || i + 1}</div>
<div>
<div className="text-sm text-white">{r.processName}</div>
<div className="text-xs text-slate-400">{r.workstationCode} · {r.standardTime}</div>
</div>
</div>
))}
</div>
) : <div className="text-slate-500 text-sm">QR .</div>}
</div>
</div>
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700">
<h2 className="text-sm font-medium text-slate-300 mb-3"> </h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-700">
<th className="px-3 py-2 text-left text-slate-400"></th>
<th className="px-3 py-2 text-left text-slate-400"></th>
<th className="px-3 py-2 text-left text-slate-400"> </th>
<th className="px-3 py-2 text-left text-slate-400"></th>
<th className="px-3 py-2 text-left text-slate-400"></th>
</tr>
</thead>
<tbody>
{history.map(h => (
<tr key={h.id} className="border-b border-slate-700/50">
<td className="px-3 py-2 text-slate-300 text-xs">{h.scannedAt?.replace('T', ' ').slice(0, 16)}</td>
<td className="px-3 py-2 text-slate-300">{h.workstationId}</td>
<td className="px-3 py-2 text-slate-300">{h.scanType}</td>
<td className="px-3 py-2">
<span className={h.result === 'OK' ? 'text-green-400' : 'text-red-400'}>{h.result}</span>
</td>
<td className="px-3 py-2 text-slate-400">{h.operatorId}</td>
</tr>
))}
</tbody>
</table>
{history.length === 0 && <div className="text-center text-slate-500 py-4"> .</div>}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,117 @@
import React, { useEffect, useState } from 'react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import { Brain } from 'lucide-react'
import client from '../api/client'
export default function QualityControl() {
const [inspections, setInspections] = useState<any[]>([])
const [pareto, setPareto] = useState<any[]>([])
const [dashboard, setDashboard] = useState<any>({})
const [aiResult, setAiResult] = useState('')
const [analyzing, setAnalyzing] = useState(false)
useEffect(() => {
client.get('/quality/inspections').then(r => setInspections(r.data))
client.get('/quality/defects/pareto').then(r => setPareto(r.data))
client.get('/quality/dashboard').then(r => setDashboard(r.data))
}, [])
const aiAnalyze = async () => {
setAnalyzing(true)
try {
const res = await client.post('/quality/ai-analyze', {
productCode: 'PROD-001',
defectDescription: '표면 스크래치 및 치수 불량 다수 발생',
})
setAiResult(res.data.analysis)
} catch { setAiResult('AI 분석 일시 중단') }
setAnalyzing(false)
}
return (
<div className="space-y-6">
<h1 className="text-xl font-bold text-white"> </h1>
<div className="grid grid-cols-4 gap-4">
{[
{ label: '오늘 검사', value: dashboard.total_inspections, color: 'text-white' },
{ label: '합격', value: dashboard.passed, color: 'text-green-400' },
{ label: '불합격', value: dashboard.failed, color: 'text-red-400' },
{ label: '불량률', value: `${parseFloat(dashboard.defect_rate || 0).toFixed(2)}%`, color: 'text-yellow-400' },
].map(({ label, value, color }) => (
<div key={label} className="bg-slate-800 rounded-xl p-4 border border-slate-700 text-center">
<div className={`text-2xl font-bold ${color}`}>{value ?? '-'}</div>
<div className="text-xs text-slate-400 mt-1">{label}</div>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-6">
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700">
<h2 className="text-sm font-medium text-slate-300 mb-4"> </h2>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={pareto.slice(0, 8)}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="defect_code" tick={{ fill: '#94a3b8', fontSize: 10 }} />
<YAxis tick={{ fill: '#94a3b8', fontSize: 10 }} />
<Tooltip contentStyle={{ backgroundColor: '#1e293b', border: '1px solid #334155' }} />
<Bar dataKey="count" fill="#ef4444" />
</BarChart>
</ResponsiveContainer>
</div>
<div className="bg-slate-800 rounded-xl p-5 border border-slate-700">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium text-slate-300">AI </h2>
<button onClick={aiAnalyze} disabled={analyzing}
className="flex items-center gap-1 px-3 py-1.5 bg-purple-700 hover:bg-purple-600 text-white rounded text-xs disabled:opacity-50">
<Brain size={12} /> {analyzing ? '분석 중...' : 'AI 분석'}
</button>
</div>
{aiResult ? (
<div className="bg-slate-700 rounded-lg p-3 text-sm text-slate-200 leading-relaxed">{aiResult}</div>
) : (
<div className="text-slate-500 text-sm">AI Ollama가 .</div>
)}
</div>
</div>
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
<div className="px-4 py-3 border-b border-slate-700">
<h2 className="text-sm font-medium text-slate-300"> </h2>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-700">
<th className="px-4 py-2 text-left text-slate-400"> </th>
<th className="px-4 py-2 text-left text-slate-400"></th>
<th className="px-4 py-2 text-left text-slate-400"> </th>
<th className="px-4 py-2 text-left text-slate-400">LOT</th>
<th className="px-4 py-2 text-left text-slate-400">/</th>
<th className="px-4 py-2 text-left text-slate-400"></th>
<th className="px-4 py-2 text-left text-slate-400"></th>
</tr>
</thead>
<tbody>
{inspections.map(q => (
<tr key={q.id} className="border-b border-slate-700/50 hover:bg-slate-700/30">
<td className="px-4 py-2 font-mono text-blue-400">{q.inspectionNumber}</td>
<td className="px-4 py-2 text-slate-300">{q.productCode}</td>
<td className="px-4 py-2 text-slate-300">{q.inspectionType}</td>
<td className="px-4 py-2 text-slate-400">{q.lotNumber}</td>
<td className="px-4 py-2 text-slate-300">{q.sampleSize} / {q.defectCount || 0}</td>
<td className="px-4 py-2">
<span className={q.result === 'PASS' ? 'text-green-400' : q.result === 'FAIL' ? 'text-red-400' : 'text-yellow-400'}>
{q.result || '-'}
</span>
</td>
<td className="px-4 py-2 text-slate-400 text-xs">{q.inspectedAt?.replace('T', ' ').slice(0, 16) || '-'}</td>
</tr>
))}
</tbody>
</table>
{inspections.length === 0 && <div className="text-center text-slate-500 py-8"> .</div>}
</div>
</div>
)
}

View File

@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: { extend: {} },
plugins: [],
}

15
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: '../backend/src/main/resources/static',
emptyOutDir: true
},
server: {
proxy: {
'/api': 'http://localhost:8017'
}
}
})

51
setup_fa_service.sh Normal file
View File

@ -0,0 +1,51 @@
#!/bin/bash
# GUARDiA FA Setup Script
# Run as root on target server
set -e
APP_NAME="guardia-fa"
APP_DIR="/opt/${APP_NAME}"
DB_NAME="fa_db"
DB_USER="fa_user"
DB_PASS="fa_pass2026"
APP_PORT="8017"
echo "=== GUARDiA FA 서비스 설정 ==="
# Create DB
echo "PostgreSQL DB 생성 중..."
sudo -u postgres psql -c "CREATE USER ${DB_USER} WITH PASSWORD '${DB_PASS}';" 2>/dev/null || true
sudo -u postgres psql -c "CREATE DATABASE ${DB_NAME} OWNER ${DB_USER};" 2>/dev/null || true
sudo -u postgres psql -d "${DB_NAME}" -f /opt/guardia-fa/schema.sql 2>/dev/null || true
# Create app directory
mkdir -p ${APP_DIR}
# Create systemd service
cat > /etc/systemd/system/guardia-fa.service << EOF
[Unit]
Description=GUARDiA FA Factory Automation Platform
After=network.target postgresql.service
[Service]
Type=simple
User=root
WorkingDirectory=${APP_DIR}
ExecStart=/usr/bin/java -jar ${APP_DIR}/guardia-fa-1.0.0.jar
Restart=on-failure
RestartSec=10
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable guardia-fa
echo "=== 완료 ==="
echo "서비스 시작: systemctl start guardia-fa"
echo "포트: ${APP_PORT}"
echo "URL: http://SERVER_IP:${APP_PORT}"