update(@AuthenticationPrincipal KintexPrincipal principal,
+ @RequestBody AiConfigUpdateRequest req) {
+ guard.requireAdmin(principal);
+ return ApiResponse.ok(service.update(req, principal.userId()));
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigMapper.java
new file mode 100644
index 0000000..060a8e9
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigMapper.java
@@ -0,0 +1,50 @@
+package com.zioinfo.kintex.ai;
+
+import com.zioinfo.kintex.tenant.TenantContext;
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * F100 AI 설정({@code ai_config} key/value) 매퍼 — 테넌트 스코프(★표준: PK = (tenant_id, config_key)).
+ * ★ 보안 불변: 이 테이블에는 API 키를 저장하지 않는다 (키는 env 전용). provider/모델/활성화 플래그만 보관.
+ */
+@Mapper
+public interface AiConfigMapper {
+
+ /** 전체 설정 행(config_key, config_val) — 현재 테넌트. */
+ @Select("SELECT config_key AS \"key\", config_val AS \"val\" FROM ai_config WHERE tenant_id = #{tenantId}")
+ List> findAllByTenant(@Param("tenantId") String tenantId);
+
+ default List> findAll() {
+ return findAllByTenant(TenantContext.currentTenantId());
+ }
+
+ /** 단일 값(미설정 시 null) — 현재 테넌트. */
+ @Select("SELECT config_val FROM ai_config WHERE tenant_id = #{tenantId} AND config_key = #{key}")
+ String findValByTenant(@Param("tenantId") String tenantId, @Param("key") String key);
+
+ default String findVal(String key) {
+ return findValByTenant(TenantContext.currentTenantId(), key);
+ }
+
+ /** 멱등 upsert — (tenant_id, config_key) 충돌 시 값·수정자·수정시각 갱신. */
+ @Insert("""
+ INSERT INTO ai_config (tenant_id, config_key, config_val, updated_by, updated_at)
+ VALUES (#{tenantId}, #{key}, #{val}, #{actor}, now())
+ ON CONFLICT (tenant_id, config_key) DO UPDATE
+ SET config_val = EXCLUDED.config_val,
+ updated_by = EXCLUDED.updated_by,
+ updated_at = now()
+ """)
+ int upsertByTenant(@Param("tenantId") String tenantId, @Param("key") String key,
+ @Param("val") String val, @Param("actor") String actor);
+
+ default int upsert(String key, String val, String actor) {
+ return upsertByTenant(TenantContext.currentTenantId(), key, val, actor);
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigService.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigService.java
new file mode 100644
index 0000000..5c7ad9f
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiConfigService.java
@@ -0,0 +1,158 @@
+package com.zioinfo.kintex.ai;
+
+import com.zioinfo.kintex.ai.dto.AiConfigDto;
+import com.zioinfo.kintex.ai.dto.AiConfigUpdateRequest;
+import com.zioinfo.kintex.common.error.ApiException;
+import com.zioinfo.kintex.common.error.ErrorCode;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * F100 AI provider 런타임 설정({@code ai_config}) 단일 출처. DB 우선 → 미설정 시 기본값 폴백.
+ *
+ * 보안 : 이 서비스는 Claude API 키를 다루지 않는다. {@link #claudeKeySet()} 은
+ * {@link ClaudeTextClient#isConfigured()}(env 존재 여부)만 반환 — 값/길이/마스킹 일절 미포함.
+ * 설정 테이블에도 키는 저장하지 않는다(provider/모델/활성화 플래그만).
+ */
+@Service
+public class AiConfigService {
+
+ public static final String K_PROVIDER = "ai.provider"; // ENUM: claude|ollama
+ public static final String K_CLAUDE_MODEL = "ai.claude.model"; // ENUM: 화이트리스트
+ public static final String K_OLLAMA_MODEL = "ai.ollama.model"; // STRING
+ public static final String K_ENABLED = "ai.enabled"; // BOOL
+
+ public static final String PROVIDER_CLAUDE = "claude";
+ public static final String PROVIDER_OLLAMA = "ollama";
+ public static final String DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6";
+ public static final String DEFAULT_OLLAMA_MODEL = "qwen3:1.7b";
+
+ public static final Set ALLOWED_PROVIDERS = Set.of(PROVIDER_CLAUDE, PROVIDER_OLLAMA);
+ /** 허용 Claude 모델 ID(임의 문자열 거부). */
+ public static final Set ALLOWED_CLAUDE_MODELS =
+ Set.of("claude-sonnet-4-6", "claude-haiku-4-5", "claude-opus-4-8");
+
+ private final AiConfigMapper mapper;
+ private final ClaudeTextClient claudeClient;
+ private final OllamaTextClient ollamaClient;
+
+ public AiConfigService(AiConfigMapper mapper, ClaudeTextClient claudeClient, OllamaTextClient ollamaClient) {
+ this.mapper = mapper;
+ this.claudeClient = claudeClient;
+ this.ollamaClient = ollamaClient;
+ }
+
+ // ── 효과값(DB 우선 → 기본) ──
+
+ /** 효과 provider("claude"|"ollama"). 기본 claude(키 미설정이면 라우터가 Ollama 폴백). */
+ public String provider() {
+ String v = dbVal(K_PROVIDER);
+ if (v != null) {
+ String p = v.trim().toLowerCase();
+ if (ALLOWED_PROVIDERS.contains(p)) {
+ return p;
+ }
+ }
+ return PROVIDER_CLAUDE;
+ }
+
+ /** 효과 Claude 모델(DB 우선, 미설정/비허용 시 기본). */
+ public String claudeModel() {
+ String v = dbVal(K_CLAUDE_MODEL);
+ if (v != null && ALLOWED_CLAUDE_MODELS.contains(v.trim())) {
+ return v.trim();
+ }
+ return DEFAULT_CLAUDE_MODEL;
+ }
+
+ /** 효과 Ollama 모델 태그(DB 우선, 미설정 시 기본 소형). */
+ public String ollamaModel() {
+ String v = dbVal(K_OLLAMA_MODEL);
+ return (v != null && !v.isBlank()) ? v.trim() : DEFAULT_OLLAMA_MODEL;
+ }
+
+ /** 전역 AI 토글(기본 true). false 면 모든 AI 결과는 degraded → 호출자 폴백. */
+ public boolean aiEnabled() {
+ String v = dbVal(K_ENABLED);
+ return v == null || !"false".equalsIgnoreCase(v.trim());
+ }
+
+ /** Claude API 키가 env 로 설정되어 있는지(여부만). */
+ public boolean claudeKeySet() {
+ return claudeClient.isConfigured();
+ }
+
+ /** Ollama 폴백 URL 이 env 로 설정되어 있는지(여부만). */
+ public boolean ollamaConfigured() {
+ return ollamaClient.isConfigured();
+ }
+
+ /** 실제 호출이 Claude 경로로 가야 하는지: provider=claude · 키 설정 · AI 활성. */
+ public boolean isClaudeActive() {
+ return PROVIDER_CLAUDE.equals(provider()) && claudeKeySet() && aiEnabled();
+ }
+
+ // ── ADMIN 조회/갱신 ──
+
+ public AiConfigDto getConfig() {
+ return new AiConfigDto(provider(), claudeModel(), ollamaModel(),
+ aiEnabled(), claudeKeySet(), ollamaConfigured());
+ }
+
+ /**
+ * 설정 갱신(upsert). 화이트리스트 검증: provider∈{claude,ollama}, claudeModel∈허용셋.
+ * 각 필드 선택(null/공백 = 기존 유지). 재기동 없이 다음 호출부터 반영.
+ */
+ public AiConfigDto update(AiConfigUpdateRequest req, String actor) {
+ String act = (actor == null || actor.isBlank()) ? "SYSTEM" : actor;
+ if (req.provider() != null && !req.provider().isBlank()) {
+ String p = req.provider().trim().toLowerCase();
+ if (!ALLOWED_PROVIDERS.contains(p)) {
+ throw new ApiException(ErrorCode.VALIDATION, "provider 는 claude 또는 ollama 여야 합니다.");
+ }
+ mapper.upsert(K_PROVIDER, p, act);
+ }
+ if (req.claudeModel() != null && !req.claudeModel().isBlank()) {
+ String m = req.claudeModel().trim();
+ if (!ALLOWED_CLAUDE_MODELS.contains(m)) {
+ throw new ApiException(ErrorCode.VALIDATION, "허용되지 않은 Claude 모델 ID 입니다.");
+ }
+ mapper.upsert(K_CLAUDE_MODEL, m, act);
+ }
+ if (req.ollamaModel() != null && !req.ollamaModel().isBlank()) {
+ mapper.upsert(K_OLLAMA_MODEL, req.ollamaModel().trim(), act);
+ }
+ if (req.aiEnabled() != null) {
+ mapper.upsert(K_ENABLED, req.aiEnabled() ? "true" : "false", act);
+ }
+ return getConfig();
+ }
+
+ // ── helpers ──
+
+ /** DB 값 로드(전체 조회 후 필터 — 행 수 소량). 공백/null 은 미설정으로 취급. */
+ private String dbVal(String key) {
+ Map all = allValues();
+ String v = all.get(key);
+ return (v != null && !v.isBlank()) ? v : null;
+ }
+
+ private Map allValues() {
+ Map m = new HashMap<>();
+ List> rows = mapper.findAll();
+ if (rows != null) {
+ for (Map r : rows) {
+ Object k = r.get("key");
+ Object v = r.get("val");
+ if (k != null) {
+ m.put(String.valueOf(k), v == null ? null : String.valueOf(v));
+ }
+ }
+ }
+ return m;
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/AiTextRouter.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiTextRouter.java
new file mode 100644
index 0000000..faa6fa7
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/AiTextRouter.java
@@ -0,0 +1,84 @@
+package com.zioinfo.kintex.ai;
+
+import com.zioinfo.kintex.ai.TextAiClient.GenResult;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+/**
+ * F100 AI provider 선택·폴백 라우터. 모든 AI 텍스트 생성의 단일 진입점.
+ *
+ * 선택/폴백 정책 :
+ *
+ * AI 비활성({@link AiConfigService#aiEnabled()}=false) → 즉시 degraded(provider="none"). 호출자 폴백.
+ * provider=claude · 키 설정 · 활성 → Claude 시도. 실패(degraded)면 Ollama 자동 폴백 .
+ * provider=ollama (또는 claude 인데 키 미설정) → Ollama 시도.
+ * 양 경로 실패 → degraded(text=null). 호출자가 규칙기반/오류 처리(무회귀).
+ *
+ *
+ * 환각 방지 : 반환 {@link AiResult} 는 실제 응답 provider 와 degraded 를 정직하게 담는다 —
+ * 호출자는 이를 근거로 AI 산출물 라벨링(AiLabel)·폴백을 결정한다.
+ */
+@Service
+public class AiTextRouter {
+
+ private static final Logger log = LoggerFactory.getLogger(AiTextRouter.class);
+
+ private final AiConfigService config;
+ private final ClaudeTextClient claudeClient;
+ private final OllamaTextClient ollamaClient;
+
+ public AiTextRouter(AiConfigService config, ClaudeTextClient claudeClient, OllamaTextClient ollamaClient) {
+ this.config = config;
+ this.claudeClient = claudeClient;
+ this.ollamaClient = ollamaClient;
+ }
+
+ /**
+ * AI 텍스트 생성 결과.
+ *
+ * @param text 생성 텍스트(degraded 시 null)
+ * @param provider 실제 응답 provider("claude"|"ollama"|"none")
+ * @param degraded 폴백/실패 여부(true 면 호출자가 규칙기반/오류로 처리)
+ */
+ public record AiResult(String text, String provider, boolean degraded) {
+
+ public boolean usable() {
+ return !degraded && text != null && !text.isBlank();
+ }
+
+ static AiResult none() {
+ return new AiResult(null, "none", true);
+ }
+ }
+
+ /** 기본 max_tokens 로 생성. */
+ public AiResult generate(String prompt) {
+ return generate(prompt, 0);
+ }
+
+ /** max_tokens 상한을 지정해 생성(장문 1회 호출). Claude 경로에만 상한 반영, Ollama 는 기본 옵션. */
+ public AiResult generate(String prompt, int maxTokens) {
+ if (prompt == null || prompt.isBlank() || !config.aiEnabled()) {
+ return AiResult.none();
+ }
+ if (config.isClaudeActive()) {
+ GenResult r = maxTokens > 0
+ ? claudeClient.generate(prompt, config.claudeModel(), maxTokens)
+ : claudeClient.generate(prompt, config.claudeModel());
+ if (r.usable()) {
+ return new AiResult(r.text(), AiConfigService.PROVIDER_CLAUDE, false);
+ }
+ log.warn("Claude path degraded -> Ollama fallback");
+ GenResult f = ollamaClient.generate(prompt, config.ollamaModel());
+ return f.usable()
+ ? new AiResult(f.text(), AiConfigService.PROVIDER_OLLAMA, false)
+ : AiResult.none();
+ }
+ // provider=ollama, 또는 claude 인데 키 미설정 → Ollama 경로.
+ GenResult o = ollamaClient.generate(prompt, config.ollamaModel());
+ return o.usable()
+ ? new AiResult(o.text(), AiConfigService.PROVIDER_OLLAMA, false)
+ : AiResult.none();
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/ClaudeTextClient.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/ClaudeTextClient.java
new file mode 100644
index 0000000..3f31fed
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/ClaudeTextClient.java
@@ -0,0 +1,151 @@
+package com.zioinfo.kintex.ai;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+
+/**
+ * 외부 Claude(Anthropic) Messages API 텍스트 생성 클라이언트 (F100).
+ *
+ *
외부 호출 예외 허용 : 소유자 승인(2026-07-03)에 따라 {@code api.anthropic.com} 은 허용된 유일한
+ * 외부 경로다. 그 외 외부 API 는 여전히 금지.
+ *
+ *
API 키 보안(최우선) : 키는 환경변수 {@code ANTHROPIC_API_KEY} 에서만 로드하며
+ * ({@code @Value("${ANTHROPIC_API_KEY:}")}), DB·코드·커밋·로그·응답·예외 메시지 어디에도 기록하지 않는다.
+ * 키는 오직 HTTP 헤더 {@code x-api-key} 로만 전달된다(로그는 상태코드/예외 클래스명만).
+ *
+ *
실패 처리 : 키 미설정/타임아웃/비200/예외 시 {@code GenResult.fail()} 반환(예외 미투척 →
+ * 호출자 Ollama 폴백). 신규 라이브러리 0 — JDK {@link HttpClient} 사용.
+ */
+@Service
+public class ClaudeTextClient implements TextAiClient {
+
+ private static final Logger log = LoggerFactory.getLogger(ClaudeTextClient.class);
+
+ /** Anthropic Messages API 엔드포인트(외부 호출 예외 허용 단일 경로). */
+ private static final String API_URL = "https://api.anthropic.com/v1/messages";
+ private static final String ANTHROPIC_VERSION = "2023-06-01";
+ static final String DEFAULT_MODEL = "claude-sonnet-4-6";
+ private static final int DEFAULT_MAX_TOKENS = 1024;
+ private static final int TIMEOUT_SEC = 120;
+ private static final int CONNECT_TIMEOUT_SEC = 15;
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ /** 환경변수 전용 주입. 미설정 시 빈 문자열(=미구성). 값은 절대 외부 노출/로그 금지. */
+ @Value("${ANTHROPIC_API_KEY:}")
+ private String apiKey;
+
+ /** API 키가 환경변수로 설정되어 있는지(여부만 — 값/길이/마스킹 일절 미노출). */
+ public boolean isConfigured() {
+ return apiKey != null && !apiKey.isBlank();
+ }
+
+ @Override
+ public GenResult generate(String prompt) {
+ return generate(prompt, DEFAULT_MODEL, DEFAULT_MAX_TOKENS);
+ }
+
+ public GenResult generate(String prompt, String model) {
+ return generate(prompt, model, DEFAULT_MAX_TOKENS);
+ }
+
+ /** 지정 모델로 생성. 실패 시 degraded(키/본문 미로그 — 상태코드/예외 클래스명만). */
+ public GenResult generate(String prompt, String model, int maxTokens) {
+ if (!isConfigured() || prompt == null || prompt.isBlank()) {
+ return GenResult.fail();
+ }
+ String useModel = (model == null || model.isBlank()) ? DEFAULT_MODEL : model.trim();
+ int tokens = maxTokens > 0 ? maxTokens : DEFAULT_MAX_TOKENS;
+ try {
+ String payload = "{"
+ + "\"model\":" + str(useModel) + ","
+ + "\"max_tokens\":" + tokens + ","
+ + "\"messages\":[{\"role\":\"user\",\"content\":" + str(prompt) + "}]"
+ + "}";
+
+ HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT_SEC))
+ .build();
+ HttpRequest req = HttpRequest.newBuilder()
+ .uri(URI.create(API_URL))
+ .timeout(Duration.ofSeconds(TIMEOUT_SEC))
+ .header("content-type", "application/json")
+ .header("x-api-key", apiKey) // 키는 헤더로만 — 로그/응답 미노출
+ .header("anthropic-version", ANTHROPIC_VERSION)
+ .POST(HttpRequest.BodyPublishers.ofString(payload))
+ .build();
+ HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString());
+ if (resp.statusCode() != 200) {
+ log.warn("Claude API status {} -> degraded fallback", resp.statusCode()); // 본문 미기록
+ return GenResult.fail();
+ }
+ String text = extractText(resp.body());
+ return (text == null || text.isBlank()) ? GenResult.fail() : GenResult.ok(text.trim());
+ } catch (Exception e) {
+ log.warn("Claude API call failed -> degraded fallback: {}", e.getClass().getSimpleName()); // 메시지·키 미기록
+ return GenResult.fail();
+ }
+ }
+
+ /** Messages API 응답에서 content[].text(type=text) 추출·연결. */
+ private static String extractText(String body) {
+ if (body == null || body.isBlank()) {
+ return null;
+ }
+ try {
+ JsonNode content = MAPPER.readTree(body).get("content");
+ if (content == null || !content.isArray()) {
+ return null;
+ }
+ StringBuilder sb = new StringBuilder();
+ for (JsonNode block : content) {
+ JsonNode type = block.get("type");
+ if (type != null && "text".equals(type.asText())) {
+ JsonNode t = block.get("text");
+ if (t != null && !t.isNull()) {
+ sb.append(t.asText());
+ }
+ }
+ }
+ String out = sb.toString();
+ return out.isBlank() ? null : out;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ /** JSON 문자열 이스케이프. */
+ private static String str(String s) {
+ if (s == null) {
+ return "\"\"";
+ }
+ StringBuilder sb = new StringBuilder("\"");
+ for (char c : s.toCharArray()) {
+ switch (c) {
+ case '"' -> sb.append("\\\"");
+ case '\\' -> sb.append("\\\\");
+ case '\n' -> sb.append("\\n");
+ case '\r' -> sb.append("\\r");
+ case '\t' -> sb.append("\\t");
+ default -> {
+ if (c < 0x20) {
+ sb.append(String.format("\\u%04x", (int) c));
+ } else {
+ sb.append(c);
+ }
+ }
+ }
+ }
+ return sb.append("\"").toString();
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/OllamaTextClient.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/OllamaTextClient.java
new file mode 100644
index 0000000..21b0e48
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/OllamaTextClient.java
@@ -0,0 +1,131 @@
+package com.zioinfo.kintex.ai;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 온프레미스 Ollama 텍스트 생성 클라이언트 (F100 폴백 경로).
+ *
+ * 설정 : 베이스 URL 은 환경변수 {@code OLLAMA_URL} 에서만 로드한다({@code @Value("${OLLAMA_URL:}")}).
+ * 미설정 시 폴백을 스킵 ({@link #isConfigured()}=false → 항상 degraded)한다 — 개발 서버 RAM 제약상
+ * Ollama 미기동 환경에서 조용히 degraded 로 흐르게 한다.
+ *
+ *
실패 처리 : 비활성/busy/비200/타임아웃/예외 시 {@code GenResult.fail()}(예외 미투척).
+ * 외부 API 금지 — {@code OLLAMA_URL}(온프레미스) 만 호출한다. 동시성 1(Semaphore) — 콜드로드 RAM 보호.
+ */
+@Service
+public class OllamaTextClient implements TextAiClient {
+
+ private static final Logger log = LoggerFactory.getLogger(OllamaTextClient.class);
+ private static final Semaphore GATE = new Semaphore(1);
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+ private static final int TIMEOUT_SEC = 120;
+
+ /** 온프레미스 Ollama 베이스 URL(env 전용). 미설정 시 폴백 스킵. */
+ @Value("${OLLAMA_URL:}")
+ private String baseUrl;
+
+ /** Ollama 폴백이 구성되어 있는지(URL 설정 여부). */
+ public boolean isConfigured() {
+ return baseUrl != null && !baseUrl.isBlank();
+ }
+
+ @Override
+ public GenResult generate(String prompt) {
+ return generate(prompt, null);
+ }
+
+ /** 모델을 지정해 생성. modelOverride 공백이면 라우터가 넘긴 기본 모델을 사용해야 한다(여기선 공백 방어). */
+ public GenResult generate(String prompt, String model) {
+ if (!isConfigured() || prompt == null || prompt.isBlank()) {
+ return GenResult.fail();
+ }
+ String useModel = (model == null || model.isBlank()) ? "qwen3:1.7b" : model.trim();
+ boolean acquired = false;
+ try {
+ acquired = GATE.tryAcquire(TIMEOUT_SEC, TimeUnit.SECONDS);
+ if (!acquired) {
+ log.warn("Ollama generate busy -> degraded fallback");
+ return GenResult.fail();
+ }
+ String payload = "{"
+ + "\"model\":" + str(useModel) + ","
+ + "\"prompt\":" + str(prompt) + ","
+ + "\"stream\":false,"
+ + "\"options\":{\"temperature\":0.2}"
+ + "}";
+
+ HttpClient client = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(15))
+ .build();
+ HttpRequest req = HttpRequest.newBuilder()
+ .uri(URI.create(baseUrl.trim().replaceAll("/+$", "") + "/api/generate"))
+ .timeout(Duration.ofSeconds(TIMEOUT_SEC))
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(payload))
+ .build();
+ HttpResponse resp = client.send(req, HttpResponse.BodyHandlers.ofString());
+ if (resp.statusCode() != 200) {
+ log.warn("Ollama status {} -> degraded fallback", resp.statusCode());
+ return GenResult.fail();
+ }
+ String text = extractResponse(resp.body());
+ return (text == null || text.isBlank()) ? GenResult.fail() : GenResult.ok(text.trim());
+ } catch (Exception e) {
+ log.warn("Ollama generate failed -> degraded fallback: {}", e.getClass().getSimpleName());
+ return GenResult.fail();
+ } finally {
+ if (acquired) {
+ GATE.release();
+ }
+ }
+ }
+
+ private static String extractResponse(String body) {
+ if (body == null || body.isBlank()) {
+ return null;
+ }
+ try {
+ JsonNode resp = MAPPER.readTree(body).get("response");
+ return (resp != null && !resp.isNull()) ? resp.asText() : null;
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static String str(String s) {
+ if (s == null) {
+ return "\"\"";
+ }
+ StringBuilder sb = new StringBuilder("\"");
+ for (char c : s.toCharArray()) {
+ switch (c) {
+ case '"' -> sb.append("\\\"");
+ case '\\' -> sb.append("\\\\");
+ case '\n' -> sb.append("\\n");
+ case '\r' -> sb.append("\\r");
+ case '\t' -> sb.append("\\t");
+ default -> {
+ if (c < 0x20) {
+ sb.append(String.format("\\u%04x", (int) c));
+ } else {
+ sb.append(c);
+ }
+ }
+ }
+ }
+ return sb.append("\"").toString();
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/TextAiClient.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/TextAiClient.java
new file mode 100644
index 0000000..7eebe7f
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/TextAiClient.java
@@ -0,0 +1,32 @@
+package com.zioinfo.kintex.ai;
+
+/**
+ * 텍스트 생성 provider 추상화 (F100 AI 플랫폼 라우터).
+ *
+ * 온프레미스 Ollama({@link OllamaTextClient})와 외부 Claude({@link ClaudeTextClient})를 동일 계약으로 다룬다.
+ * 구현체는 어떤 사유로든(비활성/실패/타임아웃/키·URL 미설정) 실패 시 {@link GenResult#degraded()}=true·
+ * {@link GenResult#text()}=null 을 반환하고 예외를 던지지 않는다 . 폴백은 호출자({@link AiTextRouter})가 책임진다.
+ *
+ *
provider 선택/폴백 라우팅은 {@link AiTextRouter} 가 담당한다.
+ */
+public interface TextAiClient {
+
+ /** 프롬프트로 텍스트를 생성한다. 실패 시 {@code degraded=true·text=null}(예외 미투척). */
+ GenResult generate(String prompt);
+
+ /** 생성 결과: 텍스트(폴백 시 null) + degraded(폴백 여부). */
+ record GenResult(String text, boolean degraded) {
+
+ public static GenResult fail() {
+ return new GenResult(null, true);
+ }
+
+ public static GenResult ok(String text) {
+ return new GenResult(text, false);
+ }
+
+ public boolean usable() {
+ return !degraded && text != null && !text.isBlank();
+ }
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/dto/AiConfigDto.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/dto/AiConfigDto.java
new file mode 100644
index 0000000..5e250fa
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/dto/AiConfigDto.java
@@ -0,0 +1,20 @@
+package com.zioinfo.kintex.ai.dto;
+
+/**
+ * F100 AI 설정 조회 응답. 키 값·길이·마스킹 일절 미포함 — {@code claudeKeySet} 불리언(env 존재 여부)만.
+ *
+ * @param provider 효과 provider("claude"|"ollama")
+ * @param claudeModel 효과 Claude 모델 ID
+ * @param ollamaModel 효과 Ollama 모델 태그
+ * @param aiEnabled 전역 AI 활성 여부
+ * @param claudeKeySet Claude API 키가 서버 env 로 설정되어 있는지(여부만)
+ * @param ollamaConfigured Ollama 폴백 URL 이 env 로 설정되어 있는지(여부만)
+ */
+public record AiConfigDto(
+ String provider,
+ String claudeModel,
+ String ollamaModel,
+ boolean aiEnabled,
+ boolean claudeKeySet,
+ boolean ollamaConfigured) {
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/dto/AiConfigUpdateRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/dto/AiConfigUpdateRequest.java
new file mode 100644
index 0000000..d0d504d
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/dto/AiConfigUpdateRequest.java
@@ -0,0 +1,17 @@
+package com.zioinfo.kintex.ai.dto;
+
+/**
+ * F100 AI 설정 갱신 요청. API 키는 받지 않는다 (키는 env 전용) — provider/모델/활성화만.
+ * 모든 필드 선택(null/공백 = 기존 유지). 검증(화이트리스트)은 서비스가 수행.
+ *
+ * @param provider "claude" 또는 "ollama"
+ * @param claudeModel 허용 Claude 모델 ID
+ * @param ollamaModel Ollama 모델 태그
+ * @param aiEnabled 전역 AI 활성 토글
+ */
+public record AiConfigUpdateRequest(
+ String provider,
+ String claudeModel,
+ String ollamaModel,
+ Boolean aiEnabled) {
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryController.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryController.java
new file mode 100644
index 0000000..dd0848a
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryController.java
@@ -0,0 +1,57 @@
+package com.zioinfo.kintex.ai.nlquery;
+
+import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.NlQueryRequest;
+import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.NlQueryResult;
+import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.RulesChatRequest;
+import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.RulesChatResult;
+import com.zioinfo.kintex.auth.KintexPrincipal;
+import com.zioinfo.kintex.common.ApiResponse;
+import com.zioinfo.kintex.common.audit.Audited;
+import com.zioinfo.kintex.system.SystemAccessGuard;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * F092 자연어 조회(Text-to-SQL) + F025 규정 자연어 챗봇 API (AI 라우터 경유).
+ *
+ *
+ * POST /api/ai/nlquery — 자연어 → 안전 SELECT 실행(시스템관리자 전용, 행사 횡단 데이터 접근). @Audited
+ * POST /api/ai/rules-chat — 규정/요율 근거 챗봇(인증 사용자). 근거 없으면 정직 응답.
+ *
+ * 두 엔드포인트 모두 산출 출처(provider·degraded)와 근거(생성 SQL / 인용 룰)를 응답에 정직하게 담는다.
+ */
+@RestController
+@RequestMapping("/api/ai")
+public class NlQueryController {
+
+ private final NlQueryService nlQueryService;
+ private final RulesChatService rulesChatService;
+ private final SystemAccessGuard guard;
+
+ public NlQueryController(NlQueryService nlQueryService, RulesChatService rulesChatService,
+ SystemAccessGuard guard) {
+ this.nlQueryService = nlQueryService;
+ this.rulesChatService = rulesChatService;
+ this.guard = guard;
+ }
+
+ /** 자연어 조회 — 시스템관리자(홀매니저/ADMIN) 전용. AI 미가용/가드 위반 시 명확한 4xx. */
+ @Audited(action = "AI_NLQUERY", targetType = "nlquery")
+ @PostMapping("/nlquery")
+ public ApiResponse nlquery(@AuthenticationPrincipal KintexPrincipal principal,
+ @RequestBody NlQueryRequest req) {
+ guard.requireAdmin(principal);
+ return ApiResponse.ok(nlQueryService.ask(req == null ? null : req.question()));
+ }
+
+ /** 규정 챗봇 — 인증 사용자면 열람 가능(규정/요율은 정책 안내 성격). */
+ @PostMapping("/rules-chat")
+ public ApiResponse rulesChat(@AuthenticationPrincipal KintexPrincipal principal,
+ @RequestBody RulesChatRequest req) {
+ guard.require(principal);
+ return ApiResponse.ok(rulesChatService.chat(req == null ? null : req.question()));
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryReadMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryReadMapper.java
new file mode 100644
index 0000000..a324d04
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryReadMapper.java
@@ -0,0 +1,21 @@
+package com.zioinfo.kintex.ai.nlquery;
+
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * F092 자연어 조회 전용 read-only 실행 매퍼. {@link NlQuerySqlGuard} 통과 SQL 만 전달받아 실행한다.
+ *
+ * 이 매퍼는 오직 SELECT 결과를 {@code LinkedHashMap}(컬럼 순서 보존) 리스트로 반환한다.
+ * SQL 은 XML {@code ${sql}} 로 그대로 실행되므로, 호출자는 반드시 사전에 가드 검증을 거쳐야 한다.
+ * 실제 실행은 {@code @Transactional(readOnly=true)} 하에서 이루어진다(이중 방어).
+ */
+@Mapper
+public interface NlQueryReadMapper {
+
+ /** 가드 통과 SELECT 실행. 결과 각 행은 컬럼 순서를 보존한 LinkedHashMap. */
+ List> runReadOnly(@Param("sql") String sql);
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryService.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryService.java
new file mode 100644
index 0000000..b1dff1c
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQueryService.java
@@ -0,0 +1,98 @@
+package com.zioinfo.kintex.ai.nlquery;
+
+import com.zioinfo.kintex.ai.AiTextRouter;
+import com.zioinfo.kintex.ai.AiTextRouter.AiResult;
+import com.zioinfo.kintex.ai.nlquery.NlQuerySqlGuard.GuardedSql;
+import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.NlQueryResult;
+import com.zioinfo.kintex.common.error.ApiException;
+import com.zioinfo.kintex.common.error.ErrorCode;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * F092 자연어 조회(Text-to-SQL) — 자연어 질문을 안전한 read-only SELECT 로 변환·실행한다.
+ *
+ * 파이프라인 : 질문 + 화이트리스트 스키마 프롬프트 → {@link AiTextRouter}(Claude→Ollama) → SQL 추출 →
+ * {@link NlQuerySqlGuard} 결정론 검증(단일 SELECT·화이트리스트·PII 차단·LIMIT 200) →
+ * read-only 트랜잭션 실행 → 결과 + 생성 SQL 근거 반환(투명성).
+ *
+ *
보안 : 프롬프트 스키마에서 PII 원문 컬럼(name/phone/email)을 아예 제외한다. 가드가 재차 차단한다(이중 방어).
+ * AI 미가용/가드 위반은 예외를 던져 명확한 4xx(AI_UNAVAILABLE/VALIDATION)로 응답한다(환각·오작동 방지).
+ */
+@Service
+public class NlQueryService {
+
+ private static final Logger log = LoggerFactory.getLogger(NlQueryService.class);
+
+ private final AiTextRouter router;
+ private final NlQuerySqlGuard guard;
+ private final NlQueryReadMapper readMapper;
+
+ public NlQueryService(AiTextRouter router, NlQuerySqlGuard guard, NlQueryReadMapper readMapper) {
+ this.router = router;
+ this.guard = guard;
+ this.readMapper = readMapper;
+ }
+
+ /** 화이트리스트 테이블 스키마(PII 원문 컬럼 제외) — 프롬프트에 주입되는 유일한 스키마 근거. */
+ private static final String SCHEMA = """
+ -- PostgreSQL. 아래 테이블/컬럼만 조회 가능(그 외 테이블·컬럼 금지). 개인정보(name/phone/email)는 존재하지 않는 것으로 간주하라.
+ event(id, name, start_date, end_date, status)
+ hall(id, exhibition_center, label, width_m, depth_m, height_m, area_m2, floor_load_t_per_m2, floor_type, booth_capacity, has_gas)
+ booth(id, layout_id, booth_no, booth_type, size_w_m, size_d_m, height_m, floor_load_t_per_m2, assigned_company_name, premium)
+ visitor_registration(id, event_id, company, visitor_type, agree_privacy, agree_marketing, badge_issued, checkin_state, registered_at)
+ lead(id, event_id, booth_id, role, company, product, interest, score, ai_generated, collected_at)
+ invoice(id, event_id, exhibitor_name, invoice_no, subtotal, vat, total, paid_amount, status, due_date, issued_at, paid_at)
+ auction(id, event_id, title, category, auction_type, award_criteria, weight_price, weight_reputation, weight_delivery, round, deadline, materials, created_at)
+ """;
+
+ private String buildPrompt(String question) {
+ return "당신은 킨텍스 전시운영 데이터베이스의 SQL 생성기다. 아래 스키마만 근거로 사용자의 질문에 답할 PostgreSQL SELECT 문 하나만 생성하라.\n\n"
+ + SCHEMA + "\n"
+ + "규칙(엄수):\n"
+ + "1) 반드시 단일 SELECT 문. INSERT/UPDATE/DELETE/DDL/세미콜론/주석 금지.\n"
+ + "2) 위 스키마의 테이블/컬럼만 사용. name/phone/email 등 개인정보 컬럼은 절대 사용하지 마라.\n"
+ + "3) SELECT * 대신 필요한 컬럼만 명시하고, booth.geom 같은 공간 컬럼은 선택하지 마라.\n"
+ + "4) 결과는 LIMIT 200 이하로 제한하라.\n"
+ + "5) 스키마에 없는 정보는 지어내지 말고, 답할 수 없으면 가장 근접한 조회를 생성하라.\n"
+ + "6) 출력은 SQL 문 하나만. 설명·마크다운·코드펜스 없이 SQL 텍스트만 반환하라.\n\n"
+ + "질문: " + question;
+ }
+
+ /**
+ * 자연어 질문 → 검증된 SELECT 실행. read-only 트랜잭션(쓰기 원천 차단).
+ * @throws ApiException AI 미가용(AI_UNAVAILABLE·503) 또는 가드 위반(VALIDATION·400)
+ */
+ @Transactional(readOnly = true)
+ public NlQueryResult ask(String question) {
+ if (question == null || question.isBlank()) {
+ throw new ApiException(ErrorCode.VALIDATION, "질문을 입력해 주세요.");
+ }
+ AiResult ai = router.generate(buildPrompt(question), 512);
+ if (!ai.usable()) {
+ throw new ApiException(ErrorCode.AI_UNAVAILABLE);
+ }
+ // 가드: 위반 시 VALIDATION(400) — 실행 전 차단.
+ GuardedSql g = guard.validate(ai.text());
+
+ List> rows;
+ try {
+ rows = readMapper.runReadOnly(g.sql());
+ } catch (org.springframework.dao.DataAccessException e) {
+ // 실행 오류(존재하지 않는 컬럼·문법 등)는 스택트레이스 미노출, 요약만.
+ log.warn("NL query execution failed: {}", e.getClass().getSimpleName());
+ throw new ApiException(ErrorCode.VALIDATION, "생성된 조회를 실행할 수 없습니다. 질문을 더 구체적으로 입력해 주세요.");
+ }
+ if (rows == null) {
+ rows = new ArrayList<>();
+ }
+ List columns = rows.isEmpty() ? List.of() : new ArrayList<>(rows.get(0).keySet());
+ return new NlQueryResult(question, g.sql(), g.tables(), columns, rows, rows.size(), ai.provider());
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQuerySqlGuard.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQuerySqlGuard.java
new file mode 100644
index 0000000..5d5d487
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/NlQuerySqlGuard.java
@@ -0,0 +1,153 @@
+package com.zioinfo.kintex.ai.nlquery;
+
+import com.zioinfo.kintex.common.error.ApiException;
+import com.zioinfo.kintex.common.error.ErrorCode;
+import org.springframework.stereotype.Component;
+
+import java.util.LinkedHashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * F092 자연어 조회(Text-to-SQL) 안전 가드 — LLM 이 생성한 SQL 을 실행 전 검증한다.
+ *
+ * LLM 산출 SQL 은 신뢰할 수 없다(프롬프트 인젝션·환각·오작성). 이 가드는 결정론적(정규식 기반) 검증으로
+ * 오직 단일 읽기 전용 SELECT 만, 화이트리스트 테이블 에 대해서만, PII 원문 컬럼 없이 ,
+ * LIMIT 200 이하 로만 통과시킨다. 하나라도 위반하면 {@link ErrorCode#VALIDATION} 을 던져 실행을 차단한다.
+ *
+ *
이중 방어: 여기서 통과해도 실제 실행은 read-only 트랜잭션({@link NlQueryService})에서 수행한다.
+ */
+@Component
+public class NlQuerySqlGuard {
+
+ /** 조회 허용 테이블(그 외 모든 테이블·시스템 카탈로그 차단). */
+ public static final Set ALLOWED_TABLES = Set.of(
+ "event", "hall", "booth", "visitor_registration", "lead", "invoice", "auction");
+
+ /** PII 원문 컬럼(어떤 경우에도 SQL 에 등장 금지 — 프롬프트에서도 제외). */
+ private static final Set PII_COLUMNS = Set.of("phone", "email");
+ /** visitor_registration·lead 참조 시 추가로 금지되는 PII 컬럼(event.name 등 비-PII 는 허용). */
+ private static final Set PII_NAME_TABLES = Set.of("visitor_registration", "lead");
+
+ /** 쓰기/DDL/제어/우회 키워드(단어 경계). SELECT 단일 읽기문 외 일체 차단. */
+ private static final Pattern FORBIDDEN = Pattern.compile(
+ "\\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|merge|replace|"
+ + "call|exec|execute|do|copy|vacuum|analyze|reindex|cluster|lock|"
+ + "comment|into|returning|nextval|setval|pg_sleep|pg_read_file|lo_import|lo_export|dblink)\\b",
+ Pattern.CASE_INSENSITIVE);
+
+ /** FROM/JOIN 뒤의 테이블 참조(스키마.테이블 형태 포함) 추출. */
+ private static final Pattern TABLE_REF = Pattern.compile(
+ "\\b(?:from|join)\\s+([a-zA-Z_][\\w.]*)", Pattern.CASE_INSENSITIVE);
+
+ private static final Pattern LIMIT_NUM = Pattern.compile("\\blimit\\s+(\\d+)", Pattern.CASE_INSENSITIVE);
+ private static final int MAX_LIMIT = 200;
+
+ /** 가드 통과 SQL 과 검증 근거. */
+ public record GuardedSql(String sql, java.util.List tables, int limit) {
+ }
+
+ /**
+ * LLM 산출 SQL 을 검증·정규화한다. 위반 시 {@link ApiException}(VALIDATION) 을 던진다.
+ * 통과 시 LIMIT 이 강제 부여/제한된 실행용 SQL 을 반환한다.
+ */
+ public GuardedSql validate(String raw) {
+ if (raw == null || raw.isBlank()) {
+ throw new ApiException(ErrorCode.VALIDATION, "생성된 SQL 이 비어 있습니다.");
+ }
+ String sql = strip(raw);
+ String lower = sql.toLowerCase(Locale.ROOT);
+
+ // 1) 주석 차단(주석 기반 우회 방지).
+ if (sql.contains("--") || sql.contains("/*") || sql.contains("*/") || sql.contains("#")) {
+ throw new ApiException(ErrorCode.VALIDATION, "SQL 주석은 허용되지 않습니다.");
+ }
+ // 2) 다중문 차단(세미콜론). 끝의 단일 세미콜론만 허용해 제거.
+ String noTrailing = sql.endsWith(";") ? sql.substring(0, sql.length() - 1) : sql;
+ if (noTrailing.contains(";")) {
+ throw new ApiException(ErrorCode.VALIDATION, "단일 SELECT 문만 허용됩니다(다중 문 금지).");
+ }
+ sql = noTrailing.trim();
+ lower = sql.toLowerCase(Locale.ROOT);
+
+ // 3) SELECT 로만 시작(단일 읽기문). WITH/CTE·서브쿼리 밖 시작 키워드 제한.
+ if (!lower.startsWith("select")) {
+ throw new ApiException(ErrorCode.VALIDATION, "SELECT 로 시작하는 조회문만 허용됩니다.");
+ }
+ // 4) 금지 키워드(쓰기/DDL/제어/우회) 차단.
+ Matcher fm = FORBIDDEN.matcher(sql);
+ if (fm.find()) {
+ throw new ApiException(ErrorCode.VALIDATION, "허용되지 않은 키워드가 포함되어 있습니다: " + fm.group(1));
+ }
+ // 5) 테이블 화이트리스트 검증(FROM/JOIN 대상 전부 허용 목록에 있어야 함).
+ java.util.List tables = new java.util.ArrayList<>();
+ Set refSet = new LinkedHashSet<>();
+ Matcher tm = TABLE_REF.matcher(sql);
+ while (tm.find()) {
+ String ref = tm.group(1);
+ // 스키마 접두어 제거(public.event → event) 및 소문자화.
+ String tbl = ref.contains(".") ? ref.substring(ref.lastIndexOf('.') + 1) : ref;
+ tbl = tbl.toLowerCase(Locale.ROOT);
+ if (!ALLOWED_TABLES.contains(tbl)) {
+ throw new ApiException(ErrorCode.VALIDATION, "조회 허용되지 않은 테이블입니다: " + tbl);
+ }
+ if (refSet.add(tbl)) {
+ tables.add(tbl);
+ }
+ }
+ if (tables.isEmpty()) {
+ throw new ApiException(ErrorCode.VALIDATION, "조회 대상 테이블(FROM)을 찾을 수 없습니다.");
+ }
+ // 6) PII 원문 컬럼 차단. phone/email 은 항상, name 은 visitor_registration/lead 참조 시 차단.
+ for (String pii : PII_COLUMNS) {
+ if (containsWord(lower, pii)) {
+ throw new ApiException(ErrorCode.VALIDATION, "개인정보 컬럼은 조회할 수 없습니다.");
+ }
+ }
+ boolean touchesPiiTable = tables.stream().anyMatch(PII_NAME_TABLES::contains);
+ if (touchesPiiTable && containsWord(lower, "name")) {
+ throw new ApiException(ErrorCode.VALIDATION, "개인정보 컬럼(name)은 조회할 수 없습니다.");
+ }
+ // 7) LIMIT 200 강제. 기존 limit 은 200 이하만 허용, 없으면 부여.
+ int limit;
+ Matcher lm = LIMIT_NUM.matcher(sql);
+ if (lm.find()) {
+ try {
+ limit = Integer.parseInt(lm.group(1));
+ } catch (NumberFormatException e) {
+ throw new ApiException(ErrorCode.VALIDATION, "LIMIT 값이 올바르지 않습니다.");
+ }
+ if (limit > MAX_LIMIT || limit <= 0) {
+ throw new ApiException(ErrorCode.VALIDATION, "LIMIT 는 1~" + MAX_LIMIT + " 사이여야 합니다.");
+ }
+ } else {
+ sql = sql + " LIMIT " + MAX_LIMIT;
+ limit = MAX_LIMIT;
+ }
+ return new GuardedSql(sql, tables, limit);
+ }
+
+ /** 코드펜스(```sql ... ```)·잉여 공백 제거. */
+ private static String strip(String s) {
+ String out = s.trim();
+ if (out.startsWith("```")) {
+ int nl = out.indexOf('\n');
+ if (nl >= 0) {
+ out = out.substring(nl + 1);
+ }
+ int fence = out.lastIndexOf("```");
+ if (fence >= 0) {
+ out = out.substring(0, fence);
+ }
+ }
+ // 여러 줄/탭을 단일 공백으로 정규화(가드 정규식 안정화).
+ return out.replaceAll("\\s+", " ").trim();
+ }
+
+ /** 단어 경계 기반 토큰 포함 검사(부분 문자열 오탐 방지). */
+ private static boolean containsWord(String haystackLower, String word) {
+ return Pattern.compile("\\b" + Pattern.quote(word) + "\\b").matcher(haystackLower).find();
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/RulesChatService.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/RulesChatService.java
new file mode 100644
index 0000000..109440a
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/RulesChatService.java
@@ -0,0 +1,222 @@
+package com.zioinfo.kintex.ai.nlquery;
+
+import com.zioinfo.kintex.ai.AiTextRouter;
+import com.zioinfo.kintex.ai.AiTextRouter.AiResult;
+import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.RuleCitation;
+import com.zioinfo.kintex.ai.nlquery.dto.NlQueryDtos.RulesChatResult;
+import com.zioinfo.kintex.common.error.ApiException;
+import com.zioinfo.kintex.common.error.ErrorCode;
+import com.zioinfo.kintex.rules.ComplianceRuleSet;
+import com.zioinfo.kintex.rules.RateRuleSet;
+import com.zioinfo.kintex.rules.RuleSetLoader;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * F025 규정 자연어 챗봇 — 룰셋(규정·요율 JSON)을 코퍼스로 근거 기반 답변한다.
+ *
+ * 파이프라인 : 질문 → 키워드 매칭으로 관련 룰 항목 선별(근거) → {@link AiTextRouter}(Claude→Ollama)로
+ * 선별된 룰만 근거로 답변 생성(룰 code/값 인용 필수) → 응답에 인용 룰 동봉.
+ *
+ *
환각 방지(핵심) :
+ *
+ * 관련 룰을 하나도 못 찾으면 → AI 호출 없이 "규정에서 찾지 못했습니다" 정직 응답(grounded=false).
+ * 룰은 찾았으나 AI 미가용 → AI 지어내기 대신 찾은 룰 항목 자체를 요약 해 반환(degraded=true, provider="rules").
+ * 프롬프트에 "제공된 규정 항목 밖의 내용은 지어내지 마라"를 명시.
+ *
+ */
+@Service
+public class RulesChatService {
+
+ private final RuleSetLoader loader;
+ private final AiTextRouter router;
+
+ public RulesChatService(RuleSetLoader loader, AiTextRouter router) {
+ this.loader = loader;
+ this.router = router;
+ }
+
+ /** 검색용 룰 항목(내부). */
+ private record Item(String code, String label, String detail, String haystack) {
+ RuleCitation toCitation() {
+ return new RuleCitation(code, label, detail);
+ }
+ }
+
+ public RulesChatResult chat(String question) {
+ if (question == null || question.isBlank()) {
+ throw new ApiException(ErrorCode.VALIDATION, "질문을 입력해 주세요.");
+ }
+ List- corpus = buildCorpus();
+ List
- matched = match(question, corpus, 6);
+
+ // 근거 없음 → AI 호출 없이 정직 응답(환각 차단).
+ if (matched.isEmpty()) {
+ return new RulesChatResult(question,
+ "규정에서 관련 내용을 찾지 못했습니다. 높이·리깅·방염·바닥하중·통로·임대료·전기/네트워크 등 규정/요율 키워드로 다시 질문해 주세요.",
+ List.of(), false, "none", true);
+ }
+
+ List
citations = matched.stream().map(Item::toCitation).toList();
+ AiResult ai = router.generate(buildPrompt(question, matched), 700);
+
+ if (ai.usable()) {
+ return new RulesChatResult(question, ai.text().trim(), citations, true, ai.provider(), false);
+ }
+ // AI 미가용 → 지어내지 말고 찾은 룰 항목을 그대로 요약(근거 기반·정직).
+ StringBuilder sb = new StringBuilder("관련 규정 항목입니다(AI 요약 미가용 — 규정 원문 기준):\n");
+ for (Item it : matched) {
+ sb.append("· [").append(it.code()).append("] ").append(it.label());
+ if (it.detail() != null && !it.detail().isBlank()) {
+ sb.append(" — ").append(it.detail());
+ }
+ sb.append('\n');
+ }
+ return new RulesChatResult(question, sb.toString().trim(), citations, true, "rules", true);
+ }
+
+ private String buildPrompt(String question, List- matched) {
+ StringBuilder rules = new StringBuilder();
+ for (Item it : matched) {
+ rules.append("- code=").append(it.code())
+ .append(" | ").append(it.label());
+ if (it.detail() != null && !it.detail().isBlank()) {
+ rules.append(" | ").append(it.detail());
+ }
+ rules.append('\n');
+ }
+ String version = loader.compliance() != null ? loader.compliance().rulesetVersion() : "compliance";
+ return "당신은 킨텍스 전시 규정·요율 안내 도우미다. 아래 '규정 항목'만 근거로 사용자 질문에 한국어로 간결히 답하라.\n\n"
+ + "규정 항목(룰셋 " + version + "):\n" + rules + "\n"
+ + "규칙(엄수):\n"
+ + "1) 위 규정 항목에 있는 내용만 사용하라. 항목 밖의 수치·규정을 지어내지 마라.\n"
+ + "2) 답변에 근거가 된 규정 code 를 대괄호로 인용하라(예: [HEIGHT_MAX]).\n"
+ + "3) 제공된 항목으로 답할 수 없으면 '제공된 규정에서 확인되지 않습니다'라고 답하라.\n"
+ + "4) 규정은 사전 안내이며 최종 승인은 킨텍스·구조기술사 판단임을 필요 시 덧붙여라.\n\n"
+ + "질문: " + question;
+ }
+
+ // ── 코퍼스 구성 ──
+
+ private List
- buildCorpus() {
+ List
- items = new ArrayList<>();
+ ComplianceRuleSet c = loader.compliance();
+ if (c != null && c.rules() != null) {
+ for (ComplianceRuleSet.Rule r : c.rules()) {
+ String detail = complianceDetail(r);
+ String hay = norm(String.join(" ",
+ safe(r.code()), safe(r.label()), safe(r.group()), safe(r.metric()), safe(r.unit()),
+ safe(r.severity()), detail));
+ items.add(new Item(safe(r.code()), safe(r.label()), detail, hay));
+ }
+ }
+ RateRuleSet rates = loader.rates();
+ if (rates != null) {
+ addRate(items, rates.rental(), "RATE_RENTAL", "임대료·전시장 임대 요율 임대 요금 rental 전시장 로비 옥외 이벤트홀 성수기 할증 보증금 계약금");
+ addRate(items, rates.utility(), "RATE_UTILITY", "유틸리티 전기 전력 kw 네트워크 인터넷 급수 배수 급배수 배관 압축공기 요금 요율 utility power network");
+ addRate(items, rates.boothStandard(), "RATE_BOOTH", "기본부스 조립부스 프리미엄 부스 표준 사양 스포트라이트 콘센트 전력");
+ }
+ return items;
+ }
+
+ private String complianceDetail(ComplianceRuleSet.Rule r) {
+ StringBuilder d = new StringBuilder();
+ if (r.operator() != null) {
+ d.append(r.operator());
+ }
+ if (r.threshold() != null) {
+ d.append(' ').append(trimNum(r.threshold()));
+ }
+ if (r.min() != null && r.max() != null) {
+ d.append(' ').append(trimNum(r.min())).append('~').append(trimNum(r.max()));
+ }
+ if (r.unit() != null) {
+ d.append(r.unit());
+ }
+ if (r.hallLimits() != null && !r.hallLimits().isEmpty()) {
+ d.append(" 홀별한도=").append(r.hallLimits());
+ }
+ if (r.forbidden() != null && !r.forbidden().isEmpty()) {
+ d.append(" 금지=").append(r.forbidden());
+ }
+ if (r.requiresDocument() != null) {
+ d.append(" 필요서류=").append(r.requiresDocument());
+ }
+ if (r.severity() != null) {
+ d.append(" (").append("block".equals(r.severity()) ? "차단" : "경고").append(")");
+ }
+ return d.toString().trim();
+ }
+
+ private void addRate(List
- items, Map
map, String code, String keywords) {
+ if (map == null || map.isEmpty()) {
+ return;
+ }
+ String detail = flatten(map);
+ String hay = norm(code + " " + keywords + " " + detail);
+ items.add(new Item(code, rateLabel(code), detail, hay));
+ }
+
+ private static String rateLabel(String code) {
+ return switch (code) {
+ case "RATE_RENTAL" -> "임대 요율(전시장/로비/옥외/이벤트홀·성수기 할증·보증금)";
+ case "RATE_UTILITY" -> "유틸리티 요율(전기·네트워크·급배수·압축공기)";
+ case "RATE_BOOTH" -> "기본/프리미엄 부스 표준 사양";
+ default -> code;
+ };
+ }
+
+ private static String flatten(Map map) {
+ StringBuilder sb = new StringBuilder();
+ for (Map.Entry e : map.entrySet()) {
+ if (sb.length() > 0) {
+ sb.append(", ");
+ }
+ sb.append(e.getKey()).append('=').append(e.getValue());
+ }
+ return sb.toString();
+ }
+
+ // ── 키워드 매칭 ──
+
+ /** 질문 토큰(길이≥2)이 항목 haystack 에 등장하는 개수로 스코어링, 상위 N 반환(스코어>0). */
+ private List- match(String question, List
- corpus, int topN) {
+ String[] tokens = norm(question).split("\\s+");
+ record Scored(Item item, int score) {
+ }
+ List
scored = new ArrayList<>();
+ for (Item it : corpus) {
+ int s = 0;
+ for (String tok : tokens) {
+ if (tok.length() >= 2 && it.haystack().contains(tok)) {
+ s++;
+ }
+ }
+ if (s > 0) {
+ scored.add(new Scored(it, s));
+ }
+ }
+ scored.sort((a, b) -> Integer.compare(b.score(), a.score()));
+ List- out = new ArrayList<>();
+ for (int i = 0; i < Math.min(topN, scored.size()); i++) {
+ out.add(scored.get(i).item());
+ }
+ return out;
+ }
+
+ private static String norm(String s) {
+ return s == null ? "" : s.toLowerCase(Locale.ROOT);
+ }
+
+ private static String safe(String s) {
+ return s == null ? "" : s;
+ }
+
+ private static String trimNum(double d) {
+ return d == Math.floor(d) ? String.valueOf((long) d) : String.valueOf(d);
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/dto/NlQueryDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/dto/NlQueryDtos.java
new file mode 100644
index 0000000..5584842
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/ai/nlquery/dto/NlQueryDtos.java
@@ -0,0 +1,66 @@
+package com.zioinfo.kintex.ai.nlquery.dto;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * F092 자연어 조회 / F025 규정 챗봇 요청·응답 DTO 모음.
+ * 응답은 항상 산출 출처(provider·degraded)와 근거(생성 SQL / 인용 룰)를 정직하게 담는다(투명성·환각 방지).
+ */
+public final class NlQueryDtos {
+
+ private NlQueryDtos() {
+ }
+
+ /** 자연어 조회 요청. */
+ public record NlQueryRequest(String question) {
+ }
+
+ /**
+ * 자연어 조회 결과.
+ *
+ * @param question 사용자 질문(에코)
+ * @param sql 실제 실행된(가드 통과) SQL — 투명성 위해 반드시 노출
+ * @param tables 참조 테이블(가드 검증 근거)
+ * @param columns 결과 컬럼 순서
+ * @param rows 결과 행(각 행 컬럼→값)
+ * @param rowCount 행 수
+ * @param provider AI provider("claude"|"ollama")
+ */
+ public record NlQueryResult(
+ String question,
+ String sql,
+ List
tables,
+ List columns,
+ List> rows,
+ int rowCount,
+ String provider) {
+ }
+
+ /** 규정 챗봇 요청. */
+ public record RulesChatRequest(String question) {
+ }
+
+ /** 인용 룰 항목(근거). */
+ public record RuleCitation(String code, String label, String detail) {
+ }
+
+ /**
+ * 규정 챗봇 응답.
+ *
+ * @param question 사용자 질문(에코)
+ * @param answer 답변(근거 룰 인용 필수 · 근거 없으면 정직 응답)
+ * @param citations 근거 룰 항목(키/값)
+ * @param grounded 근거 룰을 실제로 찾았는지(false 면 "규정에서 찾지 못함")
+ * @param provider 응답 출처("claude"|"ollama"|"rules"|"none")
+ * @param degraded AI 미가용으로 규칙 기반 요약으로 강등되었는지
+ */
+ public record RulesChatResult(
+ String question,
+ String answer,
+ List citations,
+ boolean grounded,
+ String provider,
+ boolean degraded) {
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java
index a21caa9..3bcf12c 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java
@@ -20,10 +20,12 @@ import java.util.List;
public class AuctionController {
private final AuctionService service;
+ private final BoqService boqService;
private final EventAccessGuard guard;
- public AuctionController(AuctionService service, EventAccessGuard guard) {
+ public AuctionController(AuctionService service, BoqService boqService, EventAccessGuard guard) {
this.service = service;
+ this.boqService = boqService;
this.guard = guard;
}
@@ -54,6 +56,18 @@ public class AuctionController {
return ApiResponse.ok(service.getDetail(id, principal));
}
+ /**
+ * GET /api/auctions/{id}/boq — 물량서(BOQ) 자동산출(자료 패키지 첨부).
+ * 부스 설계 데이터(M2/M4)에서 규칙 기반 집계. 옥션 상세 열람 권한과 동일(인증 + 옥션 존재).
+ */
+ @GetMapping("/{id}/boq")
+ public ApiResponse boq(
+ @AuthenticationPrincipal KintexPrincipal principal,
+ @PathVariable String id) {
+ guard.require(principal);
+ return ApiResponse.ok(boqService.boq(id));
+ }
+
/** POST /api/auctions/{id}/bids — 응찰(견적서 제출). 등록업체만·마감 후 409. */
@Audited(action = "AUCTION_BID", targetType = "bid")
@PostMapping("/{id}/bids")
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java
index 55b9343..91b5e82 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java
@@ -140,6 +140,7 @@ public interface AuctionMapper {
@Select("""
SELECT aw.id, aw.bid_id AS "bidId", aw.reason,
c.name AS "companyName",
+ b.total AS "total",
to_char(aw.awarded_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "awardedAt"
FROM award aw
JOIN bid b ON b.id = aw.bid_id
@@ -229,4 +230,34 @@ public interface AuctionMapper {
ORDER BY aw.awarded_at DESC
""")
List> listMyAwardedBooths(@Param("companyId") String companyId);
+
+ // ── F035 BOQ 물량 집계(M2 booth/layout·M4 utility_order — 읽기 전용) ──────────
+ /**
+ * 부스 폴리곤 면적·둘레·부스수·최대높이 집계. boothNo 가 null 이면 행사 전체 부스, 있으면 해당 부스코드만.
+ * SRID 0 = 홀 로컬 평면(미터) → ST_Area/ST_Perimeter 단위 = ㎡/m(§V3).
+ */
+ @Select("""
+ SELECT COALESCE(sum(ST_Area(b.geom)), 0) AS "areaM2",
+ COALESCE(sum(ST_Perimeter(b.geom)), 0) AS "perimeterM",
+ count(b.id) AS "boothCount",
+ COALESCE(max(b.height_m), 0) AS "maxHeightM"
+ FROM booth b
+ JOIN layout l ON l.id = b.layout_id
+ WHERE l.event_id = #{eventId}
+ AND (#{boothNo}::text IS NULL OR b.booth_no = #{boothNo})
+ """)
+ Map boqBoothAggregate(@Param("eventId") String eventId,
+ @Param("boothNo") String boothNo);
+
+ /** 유틸리티 신청 건수(M4). boothNo 필터 동일. */
+ @Select("""
+ SELECT count(*)::int AS "orderCount"
+ FROM utility_order uo
+ JOIN booth b ON b.id = uo.booth_id
+ JOIN layout l ON l.id = b.layout_id
+ WHERE l.event_id = #{eventId}
+ AND (#{boothNo}::text IS NULL OR b.booth_no = #{boothNo})
+ """)
+ Map boqUtilityAggregate(@Param("eventId") String eventId,
+ @Param("boothNo") String boothNo);
}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java
index c3bff62..2b65143 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java
@@ -31,9 +31,12 @@ public class AuctionService {
private static final int MAX_LIMIT = 500;
private final AuctionMapper mapper;
+ private final com.zioinfo.kintex.settlement.SettlementService settlementService;
- public AuctionService(AuctionMapper mapper) {
+ public AuctionService(AuctionMapper mapper,
+ com.zioinfo.kintex.settlement.SettlementService settlementService) {
this.mapper = mapper;
+ this.settlementService = settlementService;
}
// ── 목록(SCR-26) ──────────────────────────────────────────────────────────
@@ -238,6 +241,16 @@ public class AuctionService {
p.put("awardedBy", principal.userId());
mapper.insertAward(p);
Map aw = mapper.findAward(id);
+
+ // F084 옥션 수수료 → 정산 청구 자동 생성(멱등 — 같은 낙찰 중복 생성 없음). 실패해도 낙찰은 유효.
+ try {
+ settlementService.createAuctionFeeInvoice(
+ str(a.get("eventId")), awardId, id, str(a.get("title")),
+ str(aw.get("companyName")), lngPrim(aw.get("total")), principal);
+ } catch (RuntimeException e) {
+ // 수수료 청구 생성 실패는 낙찰 확정을 되돌리지 않는다(정산은 후속 재생성 가능). 스택트레이스 미노출.
+ }
+
return new AwardResult(awardId, req.bidId(), str(aw.get("companyName")), str(aw.get("awardedAt")));
}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/BoqCalc.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/BoqCalc.java
new file mode 100644
index 0000000..6ba05e2
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/BoqCalc.java
@@ -0,0 +1,78 @@
+package com.zioinfo.kintex.auction;
+
+import com.zioinfo.kintex.auction.dto.AuctionDtos.BoqLine;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * F035 물량서(BOQ) 자동산출 — 부스 설계 데이터(면적·둘레·부스수·유틸리티 주문)에서
+ * 공사 물량 라인을 규칙 기반 으로 집계하는 순수 함수(단위 테스트 대상, DB·스프링 미의존).
+ *
+ * 산식은 단순 명료(면적·주문 합산 기반) — 각 라인은 산출 근거({@code basis})를 함께 표기한다.
+ * 금액(단가)은 산출하지 않는다 — BOQ 는 물량(수량) 산출서이며, 단가·금액은 응찰 업체가 견적서로 채운다.
+ *
+ *
+ * 구조: 시스템 골조 = 바닥면적(1:1) · 벽체 파티션 = 둘레 × 높이
+ * 바닥: 바닥 마감재 = 바닥면적(1:1)
+ * 조명: LED 조명기구 = ⌈면적 / 9㎡⌉ (9㎡당 1등)
+ * 전기: 분전반·전기 인입 = 부스 수(1실당 1)
+ * 설비: 급배수·네트워크 인입 = 유틸리티 신청 건수(있을 때만)
+ *
+ */
+public final class BoqCalc {
+
+ /** 기본 부스 높이(㎡ 데이터에 높이 미지정 시). */
+ static final double DEFAULT_HEIGHT_M = 3.5;
+ /** 조명 밀도 — 9㎡당 1등. */
+ static final double LIGHT_AREA_PER_FIXTURE = 9.0;
+
+ private BoqCalc() {
+ }
+
+ /**
+ * 물량 라인 산출. area/perimeter/height 는 M2 부스 폴리곤(㎡·m) 집계값, utilityOrderCount 는 M4 유틸리티 주문 수.
+ * boothCount==0 또는 area<=0 이면 빈 리스트(설계 미확정) — 서비스가 안내 note 를 붙인다.
+ */
+ public static List compute(double areaM2, double perimeterM, int boothCount,
+ double heightM, int utilityOrderCount) {
+ List lines = new ArrayList<>();
+ long area = Math.round(areaM2);
+ if (boothCount <= 0 || area <= 0) {
+ return lines;
+ }
+ // 둘레 미확보 시 정사각형 근사(4√면적)로 폴백 — 벽체 물량 근거를 명시.
+ boolean perimeterEstimated = perimeterM <= 0;
+ double perimeter = perimeterEstimated ? 4.0 * Math.sqrt(areaM2) : perimeterM;
+ double height = heightM > 0 ? heightM : DEFAULT_HEIGHT_M;
+ long wall = Math.round(perimeter * height);
+ long lights = (long) Math.ceil(areaM2 / LIGHT_AREA_PER_FIXTURE);
+ String pBasis = perimeterEstimated
+ ? "둘레(근사 " + Math.round(perimeter) + "m) × 높이 " + trimNum(height) + "m"
+ : "둘레 " + Math.round(perimeter) + "m × 높이 " + trimNum(height) + "m";
+
+ int seq = 0;
+ lines.add(new BoqLine(++seq, "구조", "시스템 부스 골조(알루미늄 프로파일)", "㎡", area,
+ "바닥면적 " + area + "㎡ 기준(1:1)"));
+ lines.add(new BoqLine(++seq, "구조", "벽체·파티션", "㎡", wall, pBasis));
+ lines.add(new BoqLine(++seq, "바닥", "바닥 마감재(데코타일/카펫)", "㎡", area,
+ "바닥면적 " + area + "㎡ 기준(1:1)"));
+ lines.add(new BoqLine(++seq, "조명", "LED 조명기구", "EA", lights,
+ "9㎡당 1등 기준(면적 " + area + "㎡)"));
+ lines.add(new BoqLine(++seq, "전기", "분전반·전기 인입", "식", boothCount,
+ "부스 " + boothCount + "실 × 1분전반"));
+ if (utilityOrderCount > 0) {
+ lines.add(new BoqLine(++seq, "설비", "급배수·네트워크 인입", "식", utilityOrderCount,
+ "유틸리티 신청 " + utilityOrderCount + "건 기준"));
+ }
+ return lines;
+ }
+
+ /** 소수 0이면 정수로 표기(3.5 → "3.5", 4.0 → "4"). */
+ private static String trimNum(double v) {
+ if (v == Math.floor(v)) {
+ return String.valueOf((long) v);
+ }
+ return String.valueOf(Math.round(v * 10.0) / 10.0);
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/BoqService.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/BoqService.java
new file mode 100644
index 0000000..df252e3
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/BoqService.java
@@ -0,0 +1,103 @@
+package com.zioinfo.kintex.auction;
+
+import com.zioinfo.kintex.auction.dto.AuctionDtos.BoqLine;
+import com.zioinfo.kintex.auction.dto.AuctionDtos.BoqResult;
+import com.zioinfo.kintex.common.error.ApiException;
+import com.zioinfo.kintex.common.error.ErrorCode;
+import org.springframework.stereotype.Service;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * F035 물량서(BOQ) 자동산출 서비스 — 옥션 자료 패키지 첨부용.
+ *
+ * 부스 설계 데이터(M2 booth/layout·M4 utility_order — 읽기 전용 )에서 공사 물량을
+ * 규칙 기반으로 집계한다({@link BoqCalc}). 옥션 제목에서 부스코드를 추출해 해당 부스로 스코프를
+ * 좁히고, 매칭 부스가 없으면 행사 전체 부스로 집계한다. 설계 데이터가 없으면 안내 note 를 반환한다.
+ */
+@Service
+public class BoqService {
+
+ private static final Pattern BOOTH_CODE = Pattern.compile("([A-Za-z]-?\\d{1,4})");
+
+ private final AuctionMapper mapper;
+
+ public BoqService(AuctionMapper mapper) {
+ this.mapper = mapper;
+ }
+
+ public BoqResult boq(String auctionId) {
+ Map a = mapper.findAuction(auctionId);
+ if (a == null) {
+ throw new ApiException(ErrorCode.NOT_FOUND);
+ }
+ String eventId = str(a.get("eventId"));
+ String boothCode = boothCode(str(a.get("title")));
+
+ // 1) 제목의 부스코드로 단일 부스 시도 → 없으면 행사 전체 집계로 폴백.
+ String scope = "booth";
+ String scopeLabel = boothCode;
+ Map agg = boothCode == null ? null : mapper.boqBoothAggregate(eventId, boothCode);
+ int boothCount = agg == null ? 0 : intOr(agg.get("boothCount"), 0);
+ if (boothCount == 0) {
+ agg = mapper.boqBoothAggregate(eventId, null);
+ boothCount = agg == null ? 0 : intOr(agg.get("boothCount"), 0);
+ scope = "event";
+ scopeLabel = "행사 전체";
+ }
+
+ double areaM2 = agg == null ? 0 : dbl(agg.get("areaM2"));
+ double perimeterM = agg == null ? 0 : dbl(agg.get("perimeterM"));
+ double heightM = agg == null ? 0 : dbl(agg.get("maxHeightM"));
+
+ String boothNoFilter = "booth".equals(scope) ? boothCode : null;
+ Map util = mapper.boqUtilityAggregate(eventId, boothNoFilter);
+ int utilityCount = util == null ? 0 : intOr(util.get("orderCount"), 0);
+
+ List lines = BoqCalc.compute(areaM2, perimeterM, boothCount, heightM, utilityCount);
+ String note = lines.isEmpty()
+ ? "배치·설계 데이터가 아직 확정되지 않아 물량이 산출되지 않았습니다. "
+ + "배치도(M2)·설계안(M3)·유틸리티(M4) 확정 후 자동 산출됩니다."
+ : "규칙 기반 자동 산출값입니다. 단가·금액은 응찰 견적서에서 산정됩니다.";
+
+ return new BoqResult(auctionId, eventId, scope, scopeLabel,
+ boothCount, Math.round(areaM2), lines, note, Instant.now().toString());
+ }
+
+ /** 제목에서 부스코드 추출(예: "A-102 독립부스 시공" → "A-102"). 없으면 null → 행사 전체 스코프. */
+ private static String boothCode(String title) {
+ if (title == null || title.isBlank()) {
+ return null;
+ }
+ Matcher m = BOOTH_CODE.matcher(title);
+ return m.find() ? m.group(1) : null;
+ }
+
+ private static String str(Object o) {
+ return o == null ? null : String.valueOf(o);
+ }
+
+ private static int intOr(Object o, int def) {
+ if (o == null) return def;
+ if (o instanceof Number n) return n.intValue();
+ try {
+ return Integer.parseInt(String.valueOf(o));
+ } catch (NumberFormatException e) {
+ return def;
+ }
+ }
+
+ private static double dbl(Object o) {
+ if (o == null) return 0.0;
+ if (o instanceof Number n) return n.doubleValue();
+ try {
+ return Double.parseDouble(String.valueOf(o));
+ } catch (NumberFormatException e) {
+ return 0.0;
+ }
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java
index 2198934..122d681 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java
@@ -28,6 +28,22 @@ public final class AuctionDtos {
public record MaterialPackage(String layoutUrl, String designUrl, String boqUrl, String aiImageUrl) {
}
+ /**
+ * F035 물량서(BOQ) 라인 — 공종·품명·단위·수량·산출근거. 금액(단가)은 미포함(응찰 업체가 견적서로 채움).
+ */
+ public record BoqLine(int seq, String trade, String item, String unit, long qty, String basis) {
+ }
+
+ /**
+ * F035 물량서(BOQ) 자동산출 결과 — 옥션 자료 패키지 첨부용.
+ * scope = booth(제목의 부스코드로 매칭한 단일 부스) | event(행사 전체 부스 집계).
+ * lines 가 비어 있으면 설계 미확정 — note 로 사유 안내.
+ */
+ public record BoqResult(
+ String auctionId, String eventId, String scope, String scopeLabel,
+ int boothCount, long totalAreaM2, List lines, String note, String generatedAt) {
+ }
+
/**
* 실시간 순위 행(응찰자 뷰 · 봉인). priceMasked=true 이면 price 는 null(타사 비공개).
* alias 는 위치 라벨(업체명 아님) — 업체 식별 불가.
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/EventAccessGuard.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/EventAccessGuard.java
index 840039a..6d8b1eb 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/auth/EventAccessGuard.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/EventAccessGuard.java
@@ -52,7 +52,8 @@ public class EventAccessGuard {
return;
}
String eventTenant = eventTenantMapper.findEventTenant(eventId);
- if (eventTenant != null && !eventTenant.equals(principal.tenantId())) {
+ // 테넌트 코드 대소문자 정규화 비교 — 표준 대문자 코드('KINTEX')와 레거시 소문자('kintex') 혼재 하위호환.
+ if (eventTenant != null && !eventTenant.equalsIgnoreCase(principal.tenantId())) {
throw new ApiException(ErrorCode.FORBIDDEN);
}
}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/JwtService.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/JwtService.java
index c4c72a0..d007db1 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/auth/JwtService.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/JwtService.java
@@ -32,7 +32,13 @@ public class JwtService {
}
/** 기준 테넌트(#1 KINTEX) — TenantContext.DEFAULT_TENANT와 일치. tid 미지정·공백 시 폴백(회귀 0). */
- private static final String DEFAULT_TENANT = "kintex";
+ private static final String DEFAULT_TENANT = "KINTEX";
+
+ /** 테넌트 코드 정규화(trim+대문자) — 소문자·레거시 tid를 표준 대문자 코드로 매칭. */
+ private static String normalizeTenant(String tid) {
+ String v = com.zioinfo.kintex.tenant.TenantContext.normalize(tid);
+ return v == null ? DEFAULT_TENANT : v;
+ }
/**
* 액세스 토큰 발급. eventRoles: 행사ID→역할. roleCode: 전역 역할(app_user.role_code, null 허용).
@@ -51,7 +57,7 @@ public class JwtService {
.claim("name", displayName)
.claim("roles", rolesClaim)
.claim("hm", hallManager)
- .claim("tid", (tenantId == null || tenantId.isBlank()) ? DEFAULT_TENANT : tenantId);
+ .claim("tid", normalizeTenant(tenantId));
if (roleCode != null && !roleCode.isBlank()) {
builder.claim("rc", roleCode); // 순증 클레임 — /me 전역 역할 복원용
}
@@ -87,9 +93,8 @@ public class JwtService {
}
boolean hm = Boolean.TRUE.equals(c.get("hm", Boolean.class));
String roleCode = c.get("rc", String.class);
- // tid 부재(2단계 이전 발급 토큰) = 기준 테넌트 'kintex'로 복원 → 하위호환·회귀 0.
- String tid = c.get("tid", String.class);
- String tenantId = (tid == null || tid.isBlank()) ? DEFAULT_TENANT : tid;
+ // tid 부재(2단계 이전 발급 토큰)·소문자('kintex') = 대문자 정규화 후 기준 테넌트 'KINTEX'로 복원(하위호환·회귀 0).
+ String tenantId = normalizeTenant(c.get("tid", String.class));
return new KintexPrincipal(c.getSubject(), c.get("name", String.class), roles, hm, roleCode, tenantId);
} catch (JwtException | IllegalArgumentException e) {
throw new ApiException(ErrorCode.UNAUTHORIZED, "토큰이 유효하지 않거나 만료되었습니다.");
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auth/KintexPrincipal.java b/src/backend/src/main/java/com/zioinfo/kintex/auth/KintexPrincipal.java
index 65f1d16..2828154 100644
--- a/src/backend/src/main/java/com/zioinfo/kintex/auth/KintexPrincipal.java
+++ b/src/backend/src/main/java/com/zioinfo/kintex/auth/KintexPrincipal.java
@@ -23,7 +23,7 @@ public record KintexPrincipal(
) {
/** 기준 테넌트(#1 KINTEX) — {@code com.zioinfo.kintex.tenant.TenantContext.DEFAULT_TENANT}와 일치. */
- private static final String DEFAULT_TENANT = "kintex";
+ private static final String DEFAULT_TENANT = "KINTEX";
/** 하위호환 보조 생성자 — 테넌트 미상(tid 없는 기존 토큰) 경로용. 기존 5-인자 호출 유지·테넌트는 기준값. */
public KintexPrincipal(String userId, String displayName,
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/boothsales/BoothSalesController.java b/src/backend/src/main/java/com/zioinfo/kintex/boothsales/BoothSalesController.java
new file mode 100644
index 0000000..c814b2a
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/boothsales/BoothSalesController.java
@@ -0,0 +1,96 @@
+package com.zioinfo.kintex.boothsales;
+
+import com.zioinfo.kintex.auth.EventAccessGuard;
+import com.zioinfo.kintex.auth.KintexPrincipal;
+import com.zioinfo.kintex.boothsales.dto.BoothSalesDtos.*;
+import com.zioinfo.kintex.common.ApiResponse;
+import com.zioinfo.kintex.common.audit.Audited;
+import com.zioinfo.kintex.settlement.dto.SettlementDtos.GenerateResult;
+import jakarta.validation.Valid;
+import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * F005/F006 부스 판매 인벤토리·온라인 셀프 판매 API (SCR /booth-sales).
+ *
+ * 조회(인벤토리): 행사 접근 권한(참가업체는 available+본인 hold, 매니저는 전 상태).
+ * hold·구매 확정·본인 hold 해제: 행사 멤버(참가업체 포함).
+ * blocked 전환·판매가 override·정산 연계: 매니저 이상(주최자/홀매니저) + {@link Audited}.
+ *
+ * 이중 hold·만료 hold 구매 시도는 409(CONFLICT). 금액은 서버 계산 권위.
+ */
+@RestController
+@RequestMapping("/api/booth-sales")
+public class BoothSalesController {
+
+ private final BoothSalesService service;
+ private final EventAccessGuard guard;
+
+ public BoothSalesController(BoothSalesService service, EventAccessGuard guard) {
+ this.service = service;
+ this.guard = guard;
+ }
+
+ /** GET /api/booth-sales/inventory?eventId=&hallId= — 판매 인벤토리(상태·가격 오버레이). */
+ @GetMapping("/inventory")
+ public ApiResponse inventory(@AuthenticationPrincipal KintexPrincipal principal,
+ @RequestParam String eventId,
+ @RequestParam(required = false) String hallId) {
+ guard.requireEventAccess(principal, eventId);
+ return ApiResponse.ok(service.inventory(eventId, hallId, principal));
+ }
+
+ /** POST /api/booth-sales/hold — 부스 선점(참가업체 셀프 선택). 이중 hold 409. */
+ @Audited(action = "BOOTH_SALE_HOLD", targetType = "booth_sale")
+ @PostMapping("/hold")
+ public ApiResponse hold(@AuthenticationPrincipal KintexPrincipal principal,
+ @Valid @RequestBody HoldRequest req) {
+ guard.requireEventAccess(principal, req.eventId());
+ return ApiResponse.ok(service.hold(req, principal));
+ }
+
+ /** POST /api/booth-sales/purchase — 구매 확정(hold→sold). 만료·미보유 시 409. */
+ @Audited(action = "BOOTH_SALE_PURCHASE", targetType = "booth_sale")
+ @PostMapping("/purchase")
+ public ApiResponse purchase(@AuthenticationPrincipal KintexPrincipal principal,
+ @Valid @RequestBody PurchaseRequest req) {
+ guard.requireEventAccess(principal, req.eventId());
+ return ApiResponse.ok(service.purchase(req, principal));
+ }
+
+ /** POST /api/booth-sales/release — 본인 hold 해제(available 복원). */
+ @Audited(action = "BOOTH_SALE_RELEASE", targetType = "booth_sale")
+ @PostMapping("/release")
+ public ApiResponse release(@AuthenticationPrincipal KintexPrincipal principal,
+ @Valid @RequestBody HoldRequest req) {
+ guard.requireEventAccess(principal, req.eventId());
+ return ApiResponse.ok(service.release(req, principal));
+ }
+
+ /** POST /api/booth-sales/block — blocked/available 전환(매니저 이상). sold 차단 불가 409. */
+ @Audited(action = "BOOTH_SALE_BLOCK", targetType = "booth_sale")
+ @PostMapping("/block")
+ public ApiResponse block(@AuthenticationPrincipal KintexPrincipal principal,
+ @Valid @RequestBody BlockRequest req) {
+ guard.requireEventAccess(principal, req.eventId());
+ return ApiResponse.ok(service.setBlocked(req, principal));
+ }
+
+ /** POST /api/booth-sales/price — 판매가 override(매니저 이상, null=면적×요율 파생 복원). */
+ @Audited(action = "BOOTH_SALE_PRICE", targetType = "booth_sale")
+ @PostMapping("/price")
+ public ApiResponse price(@AuthenticationPrincipal KintexPrincipal principal,
+ @Valid @RequestBody PriceRequest req) {
+ guard.requireEventAccess(principal, req.eventId());
+ return ApiResponse.ok(service.setPrice(req, principal));
+ }
+
+ /** POST /api/booth-sales/settle?eventId= — M9 청구서 집계 트리거(매니저 이상, settlement 생성 연계). */
+ @Audited(action = "BOOTH_SALE_SETTLE", targetType = "invoice")
+ @PostMapping("/settle")
+ public ApiResponse settle(@AuthenticationPrincipal KintexPrincipal principal,
+ @RequestParam String eventId) {
+ guard.requireEventAccess(principal, eventId);
+ return ApiResponse.ok(service.settle(eventId, principal));
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/boothsales/BoothSalesMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/boothsales/BoothSalesMapper.java
new file mode 100644
index 0000000..7cd59ac
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/boothsales/BoothSalesMapper.java
@@ -0,0 +1,215 @@
+package com.zioinfo.kintex.boothsales;
+
+import org.apache.ibatis.annotations.Insert;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.apache.ibatis.annotations.Update;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * F005/F006 부스 판매 매퍼 — booth_sale(판매 오버레이) + M2 booth/layout(좌표, read-only).
+ * ★ Map 반환 @Select 별칭은 반드시 쌍따옴표(AS "x") — PG 는 따옴표 없는 별칭을 소문자로 접어
+ * Map 키가 null 이 된다(WORK_STATUS §7). 선택 파라미터(null)는 {@code COALESCE(CAST(#{p} AS type), 대체)}
+ * 로 흡수해 PG 파라미터 타입 미결정 오류를 회피한다. ★{@code
+ """)
+ int updateDoc(Map p);
+
+ // ─────────────────────────────── 결재함 조회(box: DRAFT/DRAFTED/PENDING/DONE)
+ @Select("""
+
+ """)
+ List> searchBox(Map q);
+
+ @Select("""
+
+ """)
+ long countBox(Map q);
+
+ // ─────────────────────────────── 결재선
+ @Delete("DELETE FROM approval_line WHERE approval_id = #{approvalId}")
+ int deleteLines(@Param("approvalId") String approvalId);
+
+ @Insert("""
+ INSERT INTO approval_line (id, approval_id, step_no, approver_id, approver_name, line_status)
+ VALUES (#{id}, #{approvalId}, #{stepNo}, #{approverId}, #{approverName}, 'WAIT')
+ """)
+ int insertLine(Map p);
+
+ @Select("""
+ SELECT id, step_no AS "stepNo", approver_id AS "approverId", approver_name AS "approverName",
+ line_status AS "lineStatus",
+ to_char(acted_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "actedAt",
+ comment
+ FROM approval_line WHERE approval_id = #{approvalId} ORDER BY step_no ASC
+ """)
+ List> findLines(@Param("approvalId") String approvalId);
+
+ @Update("""
+
+ """)
+ int updateLine(Map p);
+
+ /** 상신/재상신 시 전 단계 초기화(WAIT·의견/처리시각 제거). */
+ @Update("""
+ UPDATE approval_line
+ SET line_status='WAIT', acted_at=NULL, comment=NULL
+ WHERE approval_id = #{approvalId}
+ """)
+ int resetLines(@Param("approvalId") String approvalId);
+
+ // ─────────────────────────────── 이력
+ @Insert("""
+ INSERT INTO approval_history (id, approval_id, step_no, actor_id, actor_name, action, comment)
+ VALUES (#{id}, #{approvalId}, #{stepNo}, #{actorId}, #{actorName}, #{action}, #{comment})
+ """)
+ int insertHistory(Map p);
+
+ @Select("""
+ SELECT id, step_no AS "stepNo", actor_id AS "actorId", actor_name AS "actorName",
+ action, comment,
+ to_char(acted_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "actedAt"
+ FROM approval_history WHERE approval_id = #{approvalId} ORDER BY acted_at ASC, id ASC
+ """)
+ List> findHistory(@Param("approvalId") String approvalId);
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/approval/ApprovalService.java b/src/backend/src/main/java/com/zioinfo/kintex/work/approval/ApprovalService.java
new file mode 100644
index 0000000..53298ed
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/work/approval/ApprovalService.java
@@ -0,0 +1,432 @@
+package com.zioinfo.kintex.work.approval;
+
+import com.zioinfo.kintex.auth.KintexPrincipal;
+import com.zioinfo.kintex.common.PageResponse;
+import com.zioinfo.kintex.common.error.ApiException;
+import com.zioinfo.kintex.common.error.ErrorCode;
+import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalActionRequest;
+import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalDetailDto;
+import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalHistoryDto;
+import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalLineDto;
+import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalLineSaveRequest;
+import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalListDto;
+import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalSaveRequest;
+import com.zioinfo.kintex.work.notification.NotificationService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * §5B 공통 전자결재(F028) 서비스 — 상태머신 서버 권위(WISE/UIWS approval 이식).
+ *
+ * 직렬 다단계(step 1→N 순차) 결재. 상태 전이:
+ * DRAFT →(submit)→ SUBMITTED →(approve 중간)→ IN_PROGRESS →(approve 마지막)→ APPROVED,
+ * (reject)→ REJECTED →(resubmit, version+1)→ SUBMITTED, (cancel)→ CANCELED.
+ * 승인/반려는 현재 단계 결재선 당사자 만 가능(그 외 403). 조회는 기안자·결재선 참여자만.
+ * 알림은 {@link NotificationService#notify}(TYPE=APPROVAL) 재사용(신규 채널 없음).
+ */
+@Service
+public class ApprovalService {
+
+ private final ApprovalMapper mapper;
+ private final NotificationService notificationService;
+
+ public ApprovalService(ApprovalMapper mapper, NotificationService notificationService) {
+ this.mapper = mapper;
+ this.notificationService = notificationService;
+ }
+
+ // ─────────────────────────────── 결재함 목록
+ public PageResponse list(KintexPrincipal principal, String box,
+ String keyword, int page, int size) {
+ int p = Math.max(page, 0);
+ int s = size <= 0 ? 20 : Math.min(size, 200);
+ String b = (box == null || box.isBlank()) ? "DRAFTED" : box.trim().toUpperCase();
+ if (!List.of("DRAFT", "DRAFTED", "PENDING", "DONE").contains(b)) {
+ throw new ApiException(ErrorCode.VALIDATION, "box 는 DRAFT/DRAFTED/PENDING/DONE 중 하나여야 합니다.");
+ }
+ Map q = new HashMap<>();
+ q.put("tenantId", principal.tenantId());
+ q.put("me", principal.userId());
+ q.put("box", b);
+ q.put("keyword", (keyword == null || keyword.isBlank()) ? null : keyword.trim());
+ q.put("size", s);
+ q.put("offset", p * s);
+ List> rows = mapper.searchBox(q);
+ long total = mapper.countBox(q);
+ return PageResponse.of(rows.stream().map(ApprovalService::toListDto).toList(), p, s, total);
+ }
+
+ // ─────────────────────────────── 상세
+ public ApprovalDetailDto get(KintexPrincipal principal, String id) {
+ Map a = load(id);
+ assertTenant(principal, a);
+ List> lines = mapper.findLines(id);
+ assertReadable(principal, a, lines);
+ return toDetail(principal, a, lines, mapper.findHistory(id));
+ }
+
+ // ─────────────────────────────── 기안(DRAFT)
+ @Transactional
+ public ApprovalDetailDto create(KintexPrincipal principal, ApprovalSaveRequest req) {
+ String id = "ap-" + UUID.randomUUID().toString().substring(0, 12);
+ Map p = new HashMap<>();
+ p.put("id", id);
+ p.put("tenantId", principal.tenantId());
+ p.put("eventId", req.eventId());
+ p.put("docType", req.docType());
+ p.put("title", req.title());
+ p.put("content", req.content());
+ p.put("refType", req.refType());
+ p.put("refId", req.refId());
+ p.put("drafterId", principal.userId());
+ p.put("drafterName", principal.displayName());
+ mapper.insert(p);
+ replaceLines(id, req.lines());
+ return get(principal, id);
+ }
+
+ // ─────────────────────────────── 상신(DRAFT→SUBMITTED)
+ @Transactional
+ public ApprovalDetailDto submit(KintexPrincipal principal, String id) {
+ Map a = load(id);
+ assertTenant(principal, a);
+ assertDrafter(principal, a);
+ if (!"DRAFT".equals(status(a))) {
+ throw new ApiException(ErrorCode.CONFLICT, "임시저장(DRAFT) 상태만 상신할 수 있습니다.");
+ }
+ startPending(principal, id, "SUBMIT");
+ return get(principal, id);
+ }
+
+ // ─────────────────────────────── 승인
+ @Transactional
+ public ApprovalDetailDto approve(KintexPrincipal principal, String id, ApprovalActionRequest req) {
+ Map a = load(id);
+ assertTenant(principal, a);
+ assertInFlight(a);
+ Map current = requireCurrentApprover(principal, id);
+ String comment = req == null ? null : req.comment();
+
+ Map up = new HashMap<>();
+ up.put("id", str(current.get("id")));
+ up.put("lineStatus", "APPROVED");
+ up.put("comment", comment);
+ up.put("acted", true);
+ mapper.updateLine(up);
+ addHistory(principal, id, intVal(current.get("stepNo")), "APPROVE", comment);
+
+ Map next = nextWaiting(mapper.findLines(id), intVal(current.get("stepNo")));
+ if (next != null) {
+ Map nl = new HashMap<>();
+ nl.put("id", str(next.get("id")));
+ nl.put("lineStatus", "CURRENT");
+ mapper.updateLine(nl);
+ Map doc = new HashMap<>();
+ doc.put("id", id);
+ doc.put("status", "IN_PROGRESS");
+ doc.put("curStep", intVal(next.get("stepNo")));
+ mapper.updateDoc(doc);
+ notify(str(next.get("approverId")), a, "[결재 요청] " + str(a.get("title")),
+ "결재할 문서가 도착했습니다.", id);
+ } else {
+ complete(a, id);
+ }
+ return get(principal, id);
+ }
+
+ // ─────────────────────────────── 반려(사유 필수)
+ @Transactional
+ public ApprovalDetailDto reject(KintexPrincipal principal, String id, ApprovalActionRequest req) {
+ Map a = load(id);
+ assertTenant(principal, a);
+ assertInFlight(a);
+ Map current = requireCurrentApprover(principal, id);
+ String comment = req == null ? null : req.comment();
+ if (comment == null || comment.isBlank()) {
+ throw new ApiException(ErrorCode.VALIDATION, "반려 사유(의견)를 입력해야 합니다.");
+ }
+ Map up = new HashMap<>();
+ up.put("id", str(current.get("id")));
+ up.put("lineStatus", "REJECTED");
+ up.put("comment", comment);
+ up.put("acted", true);
+ mapper.updateLine(up);
+ addHistory(principal, id, intVal(current.get("stepNo")), "REJECT", comment);
+
+ Map doc = new HashMap<>();
+ doc.put("id", id);
+ doc.put("status", "REJECTED");
+ doc.put("completedAt", true);
+ mapper.updateDoc(doc);
+ notify(str(a.get("drafterId")), a, "[결재 반려] " + str(a.get("title")),
+ "결재 문서가 반려되었습니다.", id);
+ return get(principal, id);
+ }
+
+ // ─────────────────────────────── 재상신(REJECTED→SUBMITTED, version+1)
+ @Transactional
+ public ApprovalDetailDto resubmit(KintexPrincipal principal, String id, ApprovalSaveRequest req) {
+ Map a = load(id);
+ assertTenant(principal, a);
+ assertDrafter(principal, a);
+ if (!"REJECTED".equals(status(a))) {
+ throw new ApiException(ErrorCode.CONFLICT, "반려(REJECTED) 상태만 재상신할 수 있습니다.");
+ }
+ Map doc = new HashMap<>();
+ doc.put("id", id);
+ doc.put("version", intVal(a.get("version")) + 1);
+ doc.put("clearCompleted", true);
+ if (req != null) {
+ if (req.title() != null && !req.title().isBlank()) {
+ doc.put("title", req.title().trim());
+ }
+ if (req.content() != null) {
+ doc.put("content", req.content());
+ }
+ if (req.docType() != null && !req.docType().isBlank()) {
+ doc.put("docType", req.docType().trim());
+ }
+ }
+ mapper.updateDoc(doc);
+ if (req != null && req.lines() != null && !req.lines().isEmpty()) {
+ replaceLines(id, req.lines());
+ }
+ startPending(principal, id, "RESUBMIT");
+ return get(principal, id);
+ }
+
+ // ─────────────────────────────── 회수(기안자 취소)
+ @Transactional
+ public ApprovalDetailDto cancel(KintexPrincipal principal, String id) {
+ Map a = load(id);
+ assertTenant(principal, a);
+ assertDrafter(principal, a);
+ String st = status(a);
+ if (!"DRAFT".equals(st) && !"SUBMITTED".equals(st)) {
+ throw new ApiException(ErrorCode.CONFLICT, "임시저장/상신 상태만 회수할 수 있습니다.");
+ }
+ Map doc = new HashMap<>();
+ doc.put("id", id);
+ doc.put("status", "CANCELED");
+ doc.put("completedAt", true);
+ mapper.updateDoc(doc);
+ addHistory(principal, id, intVal(a.get("curStep")), "CANCEL", null);
+ return get(principal, id);
+ }
+
+ // ─────────────────────────────── 상태머신 헬퍼
+
+ /** DRAFT/REJECTED→SUBMITTED 공통: 결재선 필수·전 단계 리셋·step1 CURRENT·cur_step=1·알림. */
+ private void startPending(KintexPrincipal principal, String id, String action) {
+ List> lines = mapper.findLines(id);
+ if (lines.isEmpty()) {
+ throw new ApiException(ErrorCode.VALIDATION, "결재선을 1명 이상 지정해야 합니다.");
+ }
+ mapper.resetLines(id);
+ Map first = lines.get(0);
+ Map fl = new HashMap<>();
+ fl.put("id", str(first.get("id")));
+ fl.put("lineStatus", "CURRENT");
+ mapper.updateLine(fl);
+
+ Map doc = new HashMap<>();
+ doc.put("id", id);
+ doc.put("status", "SUBMITTED");
+ doc.put("curStep", intVal(first.get("stepNo")));
+ doc.put("submittedAt", true);
+ mapper.updateDoc(doc);
+ addHistory(principal, id, intVal(first.get("stepNo")), action, null);
+
+ Map a = load(id);
+ notify(str(first.get("approverId")), a, "[결재 요청] " + str(a.get("title")),
+ "결재할 문서가 도착했습니다.", id);
+ }
+
+ private void complete(Map a, String id) {
+ Map doc = new HashMap<>();
+ doc.put("id", id);
+ doc.put("status", "APPROVED");
+ doc.put("completedAt", true);
+ mapper.updateDoc(doc);
+ notify(str(a.get("drafterId")), a, "[결재 완료] " + str(a.get("title")),
+ "결재 문서가 최종 승인되었습니다.", id);
+ }
+
+ private void replaceLines(String approvalId, List lineReqs) {
+ mapper.deleteLines(approvalId);
+ if (lineReqs == null || lineReqs.isEmpty()) {
+ return;
+ }
+ List sorted = lineReqs.stream()
+ .sorted((x, y) -> Integer.compare(step(x), step(y)))
+ .toList();
+ int expected = 1;
+ for (ApprovalLineSaveRequest d : sorted) {
+ if (step(d) != expected) {
+ throw new ApiException(ErrorCode.VALIDATION,
+ "결재선 단계는 1부터 연속이어야 합니다(기대: " + expected + ").");
+ }
+ if (d.approverId() == null || d.approverId().isBlank()) {
+ throw new ApiException(ErrorCode.VALIDATION, "결재자 ID가 비어 있습니다.");
+ }
+ Map p = new HashMap<>();
+ p.put("id", "al-" + UUID.randomUUID().toString().substring(0, 12));
+ p.put("approvalId", approvalId);
+ p.put("stepNo", expected);
+ p.put("approverId", d.approverId().trim());
+ p.put("approverName", d.approverName());
+ mapper.insertLine(p);
+ expected++;
+ }
+ }
+
+ private void addHistory(KintexPrincipal principal, String approvalId, Integer stepNo,
+ String action, String comment) {
+ Map p = new HashMap<>();
+ p.put("id", "ah-" + UUID.randomUUID().toString().substring(0, 12));
+ p.put("approvalId", approvalId);
+ p.put("stepNo", stepNo);
+ p.put("actorId", principal.userId());
+ p.put("actorName", principal.displayName());
+ p.put("action", action);
+ p.put("comment", comment);
+ mapper.insertHistory(p);
+ }
+
+ private void notify(String recipientId, Map a, String title, String message, String id) {
+ if (recipientId == null || recipientId.isBlank()) {
+ return;
+ }
+ try {
+ notificationService.notify(recipientId, str(a.get("eventId")), "APPROVAL",
+ title, message, "/work/approvals/" + id);
+ } catch (RuntimeException ignore) {
+ // 알림 실패는 결재 흐름을 막지 않는다(실패 격리).
+ }
+ }
+
+ private Map nextWaiting(List> lines, int afterStep) {
+ return lines.stream()
+ .filter(l -> intVal(l.get("stepNo")) > afterStep && "WAIT".equals(str(l.get("lineStatus"))))
+ .min((x, y) -> Integer.compare(intVal(x.get("stepNo")), intVal(y.get("stepNo"))))
+ .orElse(null);
+ }
+
+ private Map requireCurrentApprover(KintexPrincipal principal, String id) {
+ Map current = mapper.findLines(id).stream()
+ .filter(l -> "CURRENT".equals(str(l.get("lineStatus"))))
+ .findFirst()
+ .orElseThrow(() -> new ApiException(ErrorCode.CONFLICT, "현재 진행 단계가 없습니다."));
+ if (!principal.userId().equals(str(current.get("approverId")))) {
+ throw new ApiException(ErrorCode.FORBIDDEN, "현재 단계 결재자만 처리할 수 있습니다.");
+ }
+ return current;
+ }
+
+ // ─────────────────────────────── 권한/가시
+ private void assertDrafter(KintexPrincipal principal, Map a) {
+ if (!principal.userId().equals(str(a.get("drafterId")))) {
+ throw new ApiException(ErrorCode.FORBIDDEN, "기안자만 수행할 수 있습니다.");
+ }
+ }
+
+ private void assertInFlight(Map a) {
+ String st = status(a);
+ if (!"SUBMITTED".equals(st) && !"IN_PROGRESS".equals(st)) {
+ throw new ApiException(ErrorCode.CONFLICT, "진행중인 문서만 결재할 수 있습니다.");
+ }
+ }
+
+ private void assertTenant(KintexPrincipal principal, Map a) {
+ String t = str(a.get("tenantId"));
+ if (t != null && !t.equals(principal.tenantId())) {
+ throw new ApiException(ErrorCode.FORBIDDEN);
+ }
+ }
+
+ /** 조회 가능: 기안자 본인 또는 결재선 참여자만(은닉). */
+ private void assertReadable(KintexPrincipal principal, Map a, List> lines) {
+ String me = principal.userId();
+ if (me.equals(str(a.get("drafterId")))) {
+ return;
+ }
+ boolean inLine = lines.stream().anyMatch(l -> me.equals(str(l.get("approverId"))));
+ if (!inLine) {
+ throw new ApiException(ErrorCode.NOT_FOUND);
+ }
+ }
+
+ private Map load(String id) {
+ Map a = mapper.findById(id);
+ if (a == null) {
+ throw new ApiException(ErrorCode.NOT_FOUND);
+ }
+ return a;
+ }
+
+ private static int step(ApprovalLineSaveRequest d) {
+ return d.stepNo() == null ? 0 : d.stepNo();
+ }
+
+ // ─────────────────────────────── 매핑
+ private static ApprovalListDto toListDto(Map r) {
+ return new ApprovalListDto(
+ str(r.get("id")), str(r.get("docType")), str(r.get("title")), str(r.get("status")),
+ str(r.get("drafterId")), str(r.get("drafterName")),
+ intVal(r.get("curStep")), intVal(r.get("totalSteps")), intVal(r.get("version")),
+ str(r.get("eventId")), str(r.get("refType")), str(r.get("refId")),
+ str(r.get("submittedAt")), str(r.get("createdAt")));
+ }
+
+ private ApprovalDetailDto toDetail(KintexPrincipal principal, Map a,
+ List> lineRows, List> histRows) {
+ String me = principal.userId();
+ String st = str(a.get("status"));
+ List lines = lineRows.stream().map(l -> new ApprovalLineDto(
+ str(l.get("id")), intVal(l.get("stepNo")), str(l.get("approverId")), str(l.get("approverName")),
+ str(l.get("lineStatus")), str(l.get("actedAt")), str(l.get("comment")))).toList();
+ List history = histRows.stream().map(h -> new ApprovalHistoryDto(
+ str(h.get("id")), h.get("stepNo") == null ? null : intVal(h.get("stepNo")),
+ str(h.get("actorId")), str(h.get("actorName")),
+ str(h.get("action")), str(h.get("comment")), str(h.get("actedAt")))).toList();
+
+ boolean inFlight = "SUBMITTED".equals(st) || "IN_PROGRESS".equals(st);
+ boolean canAct = inFlight && lineRows.stream()
+ .anyMatch(l -> "CURRENT".equals(str(l.get("lineStatus"))) && me.equals(str(l.get("approverId"))));
+ boolean isDrafter = me.equals(str(a.get("drafterId")));
+ boolean canCancel = isDrafter && ("DRAFT".equals(st) || "SUBMITTED".equals(st));
+ boolean canResubmit = isDrafter && "REJECTED".equals(st);
+
+ return new ApprovalDetailDto(
+ str(a.get("id")), str(a.get("docType")), str(a.get("title")), str(a.get("content")), st,
+ str(a.get("drafterId")), str(a.get("drafterName")),
+ intVal(a.get("curStep")), lineRows.size(), intVal(a.get("version")),
+ str(a.get("eventId")), str(a.get("refType")), str(a.get("refId")),
+ str(a.get("submittedAt")), str(a.get("completedAt")), str(a.get("createdAt")),
+ lines, history, canAct, canCancel, canResubmit);
+ }
+
+ private static String status(Map a) {
+ return str(a.get("status"));
+ }
+
+ private static String str(Object o) {
+ return o == null ? null : String.valueOf(o);
+ }
+
+ private static int intVal(Object o) {
+ if (o instanceof Number n) {
+ return n.intValue();
+ }
+ try {
+ return o == null ? 0 : Integer.parseInt(String.valueOf(o));
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+}
diff --git a/src/backend/src/main/java/com/zioinfo/kintex/work/approval/dto/ApprovalDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/work/approval/dto/ApprovalDtos.java
new file mode 100644
index 0000000..90853e6
--- /dev/null
+++ b/src/backend/src/main/java/com/zioinfo/kintex/work/approval/dto/ApprovalDtos.java
@@ -0,0 +1,61 @@
+package com.zioinfo.kintex.work.approval.dto;
+
+import jakarta.validation.constraints.NotBlank;
+
+import java.util.List;
+
+/**
+ * §5B 공통 전자결재(F028) DTO 모음 — WISE/UIWS approval 패턴 이식.
+ * 상태(문서): DRAFT→SUBMITTED→IN_PROGRESS→APPROVED|REJECTED (+CANCELED).
+ */
+public final class ApprovalDtos {
+
+ private ApprovalDtos() {
+ }
+
+ /** 결재함 목록 행. */
+ public record ApprovalListDto(
+ String id, String docType, String title, String status,
+ String drafterId, String drafterName,
+ int curStep, int totalSteps, int version,
+ String eventId, String refType, String refId,
+ String submittedAt, String createdAt) {
+ }
+
+ /** 결재선 단계. */
+ public record ApprovalLineDto(
+ String id, int stepNo, String approverId, String approverName,
+ String lineStatus, String actedAt, String comment) {
+ }
+
+ /** 처리 이력 항목. */
+ public record ApprovalHistoryDto(
+ String id, Integer stepNo, String actorId, String actorName,
+ String action, String comment, String actedAt) {
+ }
+
+ /** 문서 상세(결재선·이력·액션 가능 플래그 포함). */
+ public record ApprovalDetailDto(
+ String id, String docType, String title, String content, String status,
+ String drafterId, String drafterName,
+ int curStep, int totalSteps, int version,
+ String eventId, String refType, String refId,
+ String submittedAt, String completedAt, String createdAt,
+ List lines, List history,
+ boolean canAct, boolean canCancel, boolean canResubmit) {
+ }
+
+ /** 결재선 저장 항목(기안/재상신). */
+ public record ApprovalLineSaveRequest(Integer stepNo, @NotBlank String approverId, String approverName) {
+ }
+
+ /** 기안/재상신 저장 요청. lines 는 상신 시 필수(1부터 연속). */
+ public record ApprovalSaveRequest(
+ String eventId, String docType, @NotBlank String title, String content,
+ String refType, String refId, List lines) {
+ }
+
+ /** 승인/반려 액션 요청(의견). 반려는 comment 필수(서비스 검증). */
+ public record ApprovalActionRequest(String comment) {
+ }
+}
diff --git a/src/backend/src/main/resources/db/migration/V27__booth_sales.sql b/src/backend/src/main/resources/db/migration/V27__booth_sales.sql
new file mode 100644
index 0000000..cd6dc24
--- /dev/null
+++ b/src/backend/src/main/resources/db/migration/V27__booth_sales.sql
@@ -0,0 +1,40 @@
+-- 킨텍스 — F005 부스 판매 인벤토리 + F006 온라인 셀프 선택·판매 스키마 (멱등)
+-- PLANNING/FEATURE_GAP F005/F006: 행사×홀의 부스 "판매 상태" 오버레이(available|held|sold|blocked).
+-- 기존 M2 공간 데이터(booth POLYGON·layout)를 판매 단위로 소비(읽기만) — 본 마이그레이션은 M2 스키마 불변.
+-- 가격은 서버 권위: 기본 = ST_Area(booth.geom) × 요율(master_data RATE.exhibitionHallPerM2), 주최자 override 가능.
+-- hold 동시성: UPDATE ... WHERE status='available'(또는 만료 held) 원자 전이 + (event_id, booth_id) 유니크.
+-- 보안 불변: 본 마이그레이션은 V27 단독. V1~V26 및 타 팀 마이그레이션을 변경하지 않는다.
+-- 멀티테넌시 표준(소유자 지시 2026-07-12): tenant_id = PK 선두 복합키 + DEFAULT 'KINTEX'.
+
+-- 부스 판매 오버레이(BoothSale) — booth(booth_id) 1행 대응. booth_id 는 M2 booth 소프트 참조(FK 미설정:
+-- 배치 재저장 시 booth 교체 가능성). status 전이는 서비스 원자 UPDATE 로 제어. --------------------
+CREATE TABLE IF NOT EXISTS booth_sale (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
+ hall_id varchar(20) NOT NULL REFERENCES hall(id),
+ booth_id varchar(40) NOT NULL, -- M2 booth.id 소프트 참조
+ booth_no varchar(40), -- 표시용 부스 번호(스냅샷)
+ status varchar(20) NOT NULL DEFAULT 'available', -- available|held|sold|blocked
+ unit_price bigint, -- 주최자 override(원). null = 면적×요율 파생
+ area_m2 numeric(10,2), -- 판매면적 스냅샷(참고)
+ held_by varchar(40), -- hold 점유 사용자
+ held_until timestamptz, -- hold 만료(TTL) — 경과 시 lazy available
+ sold_to_company varchar(200), -- 구매 확정 참가업체명(표시용, 민감정보 아님)
+ sold_by varchar(40),
+ sold_at timestamptz,
+ blocked_reason varchar(300),
+ created_by varchar(40),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (event_id, booth_id), -- 부스당 1 판매 상태(동시 hold 이중 방지)
+ UNIQUE (id), -- 단일 id 조회·FK 대상 보존
+ PRIMARY KEY (tenant_id, id) -- ★표준: tenant_id 선두 복합 PK
+);
+CREATE INDEX IF NOT EXISTS idx_booth_sale_scope ON booth_sale (tenant_id, event_id, hall_id, status);
+CREATE INDEX IF NOT EXISTS idx_booth_sale_held ON booth_sale (status, held_until);
+CREATE INDEX IF NOT EXISTS idx_booth_sale_booth ON booth_sale (event_id, booth_id);
+
+COMMENT ON TABLE booth_sale IS 'F005/F006 부스 판매 인벤토리 — M2 booth 좌표를 소비하는 판매 상태 오버레이(available|held|sold|blocked)';
+COMMENT ON COLUMN booth_sale.unit_price IS '서버 권위 판매가(원). null 이면 면적×요율(rates-v1.0) 파생';
+COMMENT ON COLUMN booth_sale.held_until IS 'hold TTL 만료 시각 — 경과분은 조회 시 available 로 lazy 만료';
diff --git a/src/backend/src/main/resources/db/migration/V28__boq_auction_fee.sql b/src/backend/src/main/resources/db/migration/V28__boq_auction_fee.sql
new file mode 100644
index 0000000..7e54f11
--- /dev/null
+++ b/src/backend/src/main/resources/db/migration/V28__boq_auction_fee.sql
@@ -0,0 +1,43 @@
+-- 킨텍스 — F035 물량서(BOQ) 데모 부스 + F084 옥션 수수료·정산 연동 (멱등)
+-- PLANNING FEATURE_GAP F035/F084:
+-- F035 BOQ = 부스 설계 데이터(M2 booth/layout·M4 utility_order — 읽기)에서 규칙 기반 물량 집계(신규 테이블 불필요).
+-- F084 옥션 수수료 = 낙찰 확정 시 수수료(요율 룰셋 RATE.auctionFeeRate, 없으면 기본 5%)를
+-- 정산 invoice(category='auction_fee', source_ref=낙찰 id)로 멱등 청구.
+-- 보안 불변: 본 마이그레이션은 V28 단독. V1~V27 및 타 팀 마이그레이션을 변경하지 않는다. 전부 멱등.
+
+-- 1) 정산 청구서에 유형·출처 순증 (F084) ----------------------------------------
+-- 기존 청구는 DEFAULT 'rental'로 자동 백필(회귀 0). source_ref = 낙찰 id(멱등 키).
+ALTER TABLE invoice ADD COLUMN IF NOT EXISTS category varchar(30) NOT NULL DEFAULT 'rental';
+ALTER TABLE invoice ADD COLUMN IF NOT EXISTS source_ref varchar(80);
+COMMENT ON COLUMN invoice.category IS 'rental(임대·유틸) | auction_fee(옥션 수수료) — 정산 유형 구분(F084)';
+COMMENT ON COLUMN invoice.source_ref IS '출처 참조(옥션 수수료=award.id) — 낙찰당 수수료 1건 멱등 보장';
+
+-- 낙찰당 수수료 청구 1건 — source_ref 부분 유니크(중복 생성 원천 차단) --------------
+CREATE UNIQUE INDEX IF NOT EXISTS uq_invoice_source_ref ON invoice (source_ref) WHERE source_ref IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_invoice_category ON invoice (event_id, category);
+
+-- 2) 옥션 수수료율 룰셋(%) — 없으면 서비스 기본 5% (F084) -------------------------
+INSERT INTO master_data (category, code, name, value_json, ruleset_version) VALUES
+ ('RATE', 'auctionFeeRate', '옥션 낙찰 수수료율(%)', '5'::jsonb, 'rates-v1.0')
+ON CONFLICT (category, code) DO NOTHING;
+
+-- 3) BOQ 데모 부스 — AUC-2026-0102(제목 "A-102 …")가 실 물량으로 산출되게 (F035) ----
+-- SRID 0 = 홀 로컬 평면(미터). 12m × 9m = 108㎡ 폴리곤. 기존 event(e-2026-smf)·hall(H1) 참조.
+-- assigned_company_name 은 기존 청구(inv-smf-0001)와 동일 → 정산 generate 시 멱등 스킵(중복 없음).
+INSERT INTO layout (id, event_id, hall_id, version, name, status) VALUES
+ ('ly-smf-boq', 'e-2026-smf', 'H1', 1, 'SMF 데모 배치(BOQ)', 'confirmed')
+ON CONFLICT (id) DO NOTHING;
+
+INSERT INTO booth (id, layout_id, booth_no, booth_type, geom, size_w_m, size_d_m, height_m,
+ floor_load_t_per_m2, assigned_company_name, premium) VALUES
+ ('booth-smf-a102', 'ly-smf-boq', 'A-102', 'independent',
+ ST_GeomFromText('POLYGON((0 0,12 0,12 9,0 9,0 0))', 0), 12, 9, 3.5, 1.0,
+ '(주)스마트오토메이션', true)
+ON CONFLICT (id) DO NOTHING;
+
+-- 유틸리티 신청 1건(전기·네트워크) — BOQ 설비 라인 근거 --------------------------
+INSERT INTO utility_order (id, event_id, booth_id, status, quote, supply_timing) VALUES
+ ('uo-smf-a102', 'e-2026-smf', 'booth-smf-a102', 'submitted',
+ '{"total":2340000,"items":[{"kind":"electric","amount":1800000},{"kind":"network","amount":540000}]}'::jsonb,
+ '반입일 D-1')
+ON CONFLICT (id) DO NOTHING;
diff --git a/src/backend/src/main/resources/db/migration/V29__approval_workflow.sql b/src/backend/src/main/resources/db/migration/V29__approval_workflow.sql
new file mode 100644
index 0000000..0e230d5
--- /dev/null
+++ b/src/backend/src/main/resources/db/migration/V29__approval_workflow.sql
@@ -0,0 +1,65 @@
+-- V29 §5B 공통 전자결재(F028) — 결재 문서 + 다단계 결재선 + 처리 이력 (멱등·tenant_id)
+-- 규칙(불변): V1~V28 불변. 전부 CREATE ... IF NOT EXISTS 로 재실행 안전.
+-- WISE/UIWS TB_APPROVAL/LINE/HISTORY 패턴 이식(kintex work 모듈 컨벤션: varchar id·tenant_id·소문자 테이블).
+-- 상태(문서): DRAFT → SUBMITTED → IN_PROGRESS → APPROVED | REJECTED (+ CANCELED 회수)
+-- 결재선(단계): WAIT / CURRENT / APPROVED / REJECTED / SKIPPED
+-- 이력(액션): SUBMIT / APPROVE / REJECT / RESUBMIT / CANCEL
+-- M6 서류 연계: ref_type='DOCUMENT' + ref_id(docType/booth 등)로 서류 검수 흐름과 느슨 결합.
+
+-- 1) 결재 문서 -----------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS approval (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ event_id varchar(40),
+ doc_type varchar(30) NOT NULL DEFAULT 'GENERAL',
+ title varchar(300) NOT NULL,
+ content text,
+ ref_type varchar(30), -- M6 연계 대상 유형(예: DOCUMENT)
+ ref_id varchar(120), -- M6 연계 대상 식별(예: 부스/docType)
+ drafter_id varchar(40) NOT NULL,
+ drafter_name varchar(120),
+ status varchar(20) NOT NULL DEFAULT 'DRAFT',
+ cur_step integer NOT NULL DEFAULT 0, -- 현재 진행 단계(1부터, DRAFT=0)
+ version integer NOT NULL DEFAULT 1, -- 재상신 시 증가
+ submitted_at timestamptz,
+ completed_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (id), -- 자식 FK(approval_id→approval.id) 대상 보존
+ PRIMARY KEY (tenant_id, id) -- ★표준: tenant_id 선두 복합 PK
+);
+CREATE INDEX IF NOT EXISTS idx_approval_drafter ON approval (drafter_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_approval_event ON approval (event_id, status);
+CREATE INDEX IF NOT EXISTS idx_approval_ref ON approval (ref_type, ref_id);
+CREATE INDEX IF NOT EXISTS idx_approval_tenant ON approval (tenant_id, status, created_at DESC);
+
+-- 2) 결재선 단계 ---------------------------------------------------------------
+CREATE TABLE IF NOT EXISTS approval_line (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ approval_id varchar(40) NOT NULL REFERENCES approval(id) ON DELETE CASCADE,
+ step_no integer NOT NULL, -- 1부터 연속
+ approver_id varchar(40) NOT NULL,
+ approver_name varchar(120),
+ line_status varchar(20) NOT NULL DEFAULT 'WAIT',
+ acted_at timestamptz,
+ comment varchar(500),
+ PRIMARY KEY (tenant_id, id) -- ★표준
+);
+CREATE INDEX IF NOT EXISTS idx_approval_line ON approval_line (approval_id, step_no);
+CREATE INDEX IF NOT EXISTS idx_approval_line_approver ON approval_line (approver_id, line_status);
+
+-- 3) 처리 이력(감사·타임라인, 불변) --------------------------------------------
+CREATE TABLE IF NOT EXISTS approval_history (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ approval_id varchar(40) NOT NULL REFERENCES approval(id) ON DELETE CASCADE,
+ step_no integer,
+ actor_id varchar(40) NOT NULL,
+ actor_name varchar(120),
+ action varchar(20) NOT NULL,
+ comment varchar(500),
+ acted_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (tenant_id, id) -- ★표준
+);
+CREATE INDEX IF NOT EXISTS idx_approval_history ON approval_history (approval_id, acted_at);
diff --git a/src/backend/src/main/resources/db/migration/V30__ai_config.sql b/src/backend/src/main/resources/db/migration/V30__ai_config.sql
new file mode 100644
index 0000000..7e85620
--- /dev/null
+++ b/src/backend/src/main/resources/db/migration/V30__ai_config.sql
@@ -0,0 +1,21 @@
+-- V30: F100 AI 플랫폼 설정 (AiConfig — provider/모델/활성화 런타임 전환)
+-- 규칙(불변): V1~V26 불변. 전부 멱등(CREATE ... IF NOT EXISTS / ON CONFLICT DO NOTHING) — 재실행 안전.
+-- ★ 보안 불변: 이 테이블에는 API 키를 저장하지 않는다(ANTHROPIC_API_KEY 는 서버 env 전용).
+-- provider/모델/활성화 플래그만 보관한다. 키 값·마스킹·env 사본 어떤 형태로도 저장 금지.
+
+CREATE TABLE IF NOT EXISTS ai_config (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX', -- ★표준: tenant_id 선두 복합 PK(테넌트별 AI 설정)
+ config_key varchar(64) NOT NULL, -- ai.provider | ai.claude.model | ai.ollama.model | ai.enabled
+ config_val varchar(200) NOT NULL,
+ updated_by varchar(64),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (tenant_id, config_key)
+);
+
+-- 기본값 시드(멱등). Claude 기본 + 소형 Ollama 폴백 모델. 키 미설정 시 라우터가 Ollama 로 자동 폴백한다.
+INSERT INTO ai_config (tenant_id, config_key, config_val, updated_by) VALUES
+ ('KINTEX', 'ai.provider', 'claude', 'SYSTEM'),
+ ('KINTEX', 'ai.claude.model', 'claude-sonnet-4-6', 'SYSTEM'),
+ ('KINTEX', 'ai.ollama.model', 'qwen3:1.7b', 'SYSTEM'),
+ ('KINTEX', 'ai.enabled', 'true', 'SYSTEM')
+ON CONFLICT (tenant_id, config_key) DO NOTHING;
diff --git a/src/backend/src/main/resources/db/migration/V31__tenant_standard.sql b/src/backend/src/main/resources/db/migration/V31__tenant_standard.sql
new file mode 100644
index 0000000..b911ea3
--- /dev/null
+++ b/src/backend/src/main/resources/db/migration/V31__tenant_standard.sql
@@ -0,0 +1,118 @@
+-- V31 소유자 데이터 표준 — 킨텍스 테넌트 표준화 (소유자 지시 2026-07-12)
+-- 목표: ① 테넌트 코드 'kintex' → 'KINTEX'(대문자) 재명명(마스터 + 전 tenant_id 데이터/기본값)
+-- ② tenant_id를 복합 PK의 선두 컬럼으로 전환(PRIMARY KEY (tenant_id, id))
+-- ③ 표준 선언: 신규 테넌트 소유 테이블은 생성 시부터 PRIMARY KEY (tenant_id, id) + DEFAULT 'KINTEX'.
+-- 불변: V1~V30 파일 불변. 본 마이그레이션은 단일 트랜잭션(Flyway 기본) — 실패 시 전체 롤백.
+-- 멱등: 전 단계 재실행 안전(존재 가드·복합 PK 스킵 가드·ON CONFLICT).
+-- 하위호환: 기존 발급 JWT(tid='kintex')·X-Tenant-Id 소문자 입력은 백엔드에서 대문자 정규화 후 매칭(코드 정합 참조).
+
+-- ============================================================================
+-- 1) 테넌트 재명명 'kintex' → 'KINTEX' (마스터 + 전 tenant_id 보유 테이블 동적 스윕)
+-- ============================================================================
+DO $$
+DECLARE
+ r record;
+BEGIN
+ -- (a) 'KINTEX' 마스터 행 확보 — 기존 'kintex' 행 속성(domain/name/status/created_at) 복사, 없으면 기본값.
+ INSERT INTO tenant (id, name, domain, status, created_at)
+ SELECT 'KINTEX', name, domain, status, created_at
+ FROM tenant WHERE id = 'kintex'
+ ON CONFLICT (id) DO NOTHING;
+
+ INSERT INTO tenant (id, name, domain, status)
+ VALUES ('KINTEX', 'KINTEX', 'kintex.zioinfo.co.kr', 'active')
+ ON CONFLICT (id) DO NOTHING;
+
+ -- (b) tenant_id 컬럼을 보유한 모든 테이블(event·hall·app_user·booth_sale·approval 등)의
+ -- 데이터 재지정 + DEFAULT 'KINTEX' 전환 — information_schema 동적 스윕(향후 tenant_id 추가분 포함).
+ FOR r IN
+ SELECT table_name
+ FROM information_schema.columns
+ WHERE table_schema = 'public'
+ AND column_name = 'tenant_id'
+ LOOP
+ EXECUTE format('UPDATE %I SET tenant_id = ''KINTEX'' WHERE tenant_id = ''kintex''', r.table_name);
+ EXECUTE format('ALTER TABLE %I ALTER COLUMN tenant_id SET DEFAULT ''KINTEX''', r.table_name);
+ END LOOP;
+
+ -- (c) 레거시 'kintex' 마스터 행 제거(이제 무참조 — tenant는 FK 대상이 아님, V26 확인).
+ DELETE FROM tenant WHERE id = 'kintex';
+END $$;
+
+-- ============================================================================
+-- 2) 복합 PK 전환 (tenant_id 선두) — event·hall·app_user
+-- 절차: (a) 대상 테이블 id를 참조하는 FK를 pg_constraint에서 동적 수집·정의 보관 후 드롭
+-- (b) 단일 PK(id) 드롭 → PK(tenant_id, id) 추가 → UNIQUE(id) 추가(단일 id 참조 FK 보존)
+-- (c) 보관한 FK를 UNIQUE(id) 대상으로 재생성
+-- 가드: PK에 tenant_id가 이미 포함(복합)이면 스킵(재실행 안전).
+-- ============================================================================
+DO $$
+DECLARE
+ t text;
+ pk_name text;
+ is_composite boolean;
+ fk record;
+BEGIN
+ CREATE TEMP TABLE _kx_fk_bak (child text, conname text, def text) ON COMMIT DROP;
+
+ FOREACH t IN ARRAY ARRAY['event', 'hall', 'app_user'] LOOP
+ -- 이미 복합 PK(tenant_id 포함)면 스킵.
+ SELECT EXISTS (
+ SELECT 1
+ FROM pg_constraint c
+ JOIN pg_attribute a
+ ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey)
+ WHERE c.conrelid = t::regclass
+ AND c.contype = 'p'
+ AND a.attname = 'tenant_id'
+ ) INTO is_composite;
+
+ IF is_composite THEN
+ CONTINUE;
+ END IF;
+
+ -- 현재 PK 제약 이름.
+ SELECT conname INTO pk_name
+ FROM pg_constraint
+ WHERE conrelid = t::regclass AND contype = 'p';
+
+ -- (a) 이 테이블(id)을 참조하는 FK 정의 보관 후 드롭.
+ TRUNCATE _kx_fk_bak;
+ INSERT INTO _kx_fk_bak (child, conname, def)
+ SELECT con.conrelid::regclass::text, con.conname, pg_get_constraintdef(con.oid)
+ FROM pg_constraint con
+ WHERE con.confrelid = t::regclass
+ AND con.contype = 'f';
+
+ FOR fk IN SELECT child, conname FROM _kx_fk_bak LOOP
+ EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', fk.child, fk.conname);
+ END LOOP;
+
+ -- (b) 단일 PK 드롭 → 복합 PK 추가 → UNIQUE(id) 추가.
+ IF pk_name IS NOT NULL THEN
+ EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', t, pk_name);
+ END IF;
+ EXECUTE format('ALTER TABLE %I ADD PRIMARY KEY (tenant_id, id)', t);
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conrelid = t::regclass AND conname = t || '_id_key'
+ ) THEN
+ EXECUTE format('ALTER TABLE %I ADD CONSTRAINT %I UNIQUE (id)', t, t || '_id_key');
+ END IF;
+
+ -- (c) 보관 FK 재생성(정의는 REFERENCES (id) → 이제 UNIQUE(id) 대상).
+ FOR fk IN SELECT child, conname, def FROM _kx_fk_bak LOOP
+ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I %s', fk.child, fk.conname, fk.def);
+ END LOOP;
+ END LOOP;
+END $$;
+
+-- ============================================================================
+-- 3) 표준 선언(주석) — 신규 테넌트 소유 테이블 규약.
+-- ============================================================================
+COMMENT ON TABLE tenant IS
+ '전시관(Venue) 테넌트 마스터 — 최상위 격리 스코프. KINTEX=테넌트 #1(표준 코드 대문자). '
+ '표준: 신규 테넌트 소유 테이블은 생성 시부터 PRIMARY KEY (tenant_id, id) + tenant_id DEFAULT ''KINTEX''.';
+COMMENT ON COLUMN event.tenant_id IS '소속 테넌트 코드(선두 PK 컬럼). 표준 대문자 코드 — DEFAULT ''KINTEX''.';
+COMMENT ON COLUMN hall.tenant_id IS '소속 테넌트 코드(선두 PK 컬럼). 표준 대문자 코드 — DEFAULT ''KINTEX''.';
+COMMENT ON COLUMN app_user.tenant_id IS '소속 테넌트 코드(선두 PK 컬럼). 표준 대문자 코드 — DEFAULT ''KINTEX''.';
diff --git a/src/backend/src/main/resources/db/migration/V32__kintex_events_2026h2.sql b/src/backend/src/main/resources/db/migration/V32__kintex_events_2026h2.sql
new file mode 100644
index 0000000..6c01610
--- /dev/null
+++ b/src/backend/src/main/resources/db/migration/V32__kintex_events_2026h2.sql
@@ -0,0 +1,138 @@
+-- V32 킨텍스 최신 행사 데이터 적재(크롤 2026 하반기, 34건) — 소유자 지시 2026-07-12
+-- 원천: kintex.com 크롤 (seq·title·hall·start·end·poster). 대부분 V10 시드에 (name,start_date)로 이미 존재 →
+-- source_seq·poster_url 백필이 주 경로. 미존재분만 신규 INSERT.
+-- 순증: event 에 poster_url·source_seq 컬럼(IF NOT EXISTS) + source_seq 부분 유니크(NULL 허용).
+-- 멱등: 3단계 upsert 전부 재실행 안전(source_seq 클레임 가드).
+-- 불변: V1~V31 불변. event 기존 컬럼(id·name·start_date·end_date·status·category·hall_label·tenant_id) 불변 — 컬럼 추가만.
+
+-- 1) 순증 컬럼 + 부분 유니크 인덱스 ------------------------------------------------
+ALTER TABLE event ADD COLUMN IF NOT EXISTS poster_url varchar(500);
+ALTER TABLE event ADD COLUMN IF NOT EXISTS source_seq varchar(20);
+COMMENT ON COLUMN event.poster_url IS '크롤 원천 포스터 이미지 URL(kintex.com imageView).';
+COMMENT ON COLUMN event.source_seq IS '크롤 원천 행사 시퀀스 키(중복 적재 방지 · 부분 유니크).';
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_event_source_seq
+ ON event (source_seq) WHERE source_seq IS NOT NULL;
+
+-- 2) 크롤 스테이징(34건) — 트랜잭션 종료 시 자동 소멸 -------------------------------
+DO $$
+BEGIN
+ CREATE TEMP TABLE _kxw_stage (
+ seq varchar(20),
+ title varchar(200),
+ hall_label varchar(120),
+ start_date date,
+ end_date date,
+ poster_url varchar(500)
+ ) ON COMMIT DROP;
+
+ INSERT INTO _kxw_stage (seq, title, hall_label, start_date, end_date, poster_url) VALUES
+ ('2025044109', '대한민국 국제 관광박람회', '전시홀 10', DATE '2026-07-03', DATE '2026-07-05', 'https://www.kintex.com/imageView.do?atchmnflNo=468981&fileseq=1'),
+ ('26033102', '2026년 제5회 킨텍스 캠핑페스타', '전시홀 2', DATE '2026-07-03', DATE '2026-07-05', 'https://www.kintex.com/imageView.do?atchmnflNo=469112&fileseq=1'),
+ ('26030510', 'AI안전보건박람회(2026국제안전보건전시회)', '전시홀 7 , 전시홀 8', DATE '2026-07-06', DATE '2026-07-09', 'https://www.kintex.com/imageView.do?atchmnflNo=469220&fileseq=1'),
+ ('26031206', '제24회 국제 나노기술융합전시회', '전시홀 3 , 전시홀 4', DATE '2026-07-08', DATE '2026-07-10', 'https://www.kintex.com/imageView.do?atchmnflNo=469249&fileseq=2'),
+ ('26042803', '국제광융합엑스포 2026', '전시홀 2A', DATE '2026-07-08', DATE '2026-07-10', 'https://www.kintex.com/imageView.do?atchmnflNo=469230&fileseq=2'),
+ ('26051208', 'KDTEX 2026', '전시홀 7B , 전시홀 8A , 전시홀 8B', DATE '2026-07-17', DATE '2026-07-19', 'https://www.kintex.com/imageView.do?atchmnflNo=469185&fileseq=1'),
+ ('26041410', '2026 펫쇼코리아(하)', '전시홀 10', DATE '2026-07-24', DATE '2026-07-26', 'https://www.kintex.com/imageView.do?atchmnflNo=469147&fileseq=1'),
+ ('26033003', '2026 코베 베이비페어&유아교육전', '전시홀 7A-1 , 전시홀 7B-1', DATE '2026-08-06', DATE '2026-08-09', 'https://www.kintex.com/imageView.do?atchmnflNo=469133&fileseq=36'),
+ ('26052102', '2026 카페디저트페어', '전시홀 10A', DATE '2026-08-13', DATE '2026-08-16', 'https://www.kintex.com/imageView.do?atchmnflNo=469201&fileseq=3'),
+ ('26042207', 'K-PRINT 2026', '전시홀 7A , 전시홀 7B , 전시홀 8A , 전시홀 8B-1', DATE '2026-08-19', DATE '2026-08-22', 'https://www.kintex.com/imageView.do?atchmnflNo=469204&fileseq=1'),
+ ('26052702', '제71회 MBC건축박람회 외(통합명칭)', '전시홀 4 , 전시홀 5', DATE '2026-08-20', DATE '2026-08-23', 'https://www.kintex.com/imageView.do?atchmnflNo=469219&fileseq=1'),
+ ('26031709', '2026 생활낚시박람회', '전시홀 10B', DATE '2026-08-21', DATE '2026-08-23', 'https://www.kintex.com/imageView.do?atchmnflNo=469082&fileseq=1'),
+ ('26062208', '2026 월드 시티테크 엑스포', '전시홀 4', DATE '2026-08-26', DATE '2026-08-28', 'https://www.kintex.com/imageView.do?atchmnflNo=469254&fileseq=2'),
+ ('26012601', '한국국제가구 및 인테리어산업대전 (코펀)', '전시홀 7 , 전시홀 8', DATE '2026-08-27', DATE '2026-08-30', 'https://www.kintex.com/imageView.do?atchmnflNo=468958&fileseq=1'),
+ ('26061902', '마이펫페어 2026 일산 Part 2', '전시홀 10', DATE '2026-08-28', DATE '2026-08-30', 'https://www.kintex.com/imageView.do?atchmnflNo=469262&fileseq=1'),
+ ('26060205', '제19회 폐기물·자원순환산업전(RETECH) & 차세대 분체·유체기술전(A-POWDER TECH)', '전시홀 7', DATE '2026-09-02', DATE '2026-09-04', 'https://www.kintex.com/imageView.do?atchmnflNo=469256&fileseq=2'),
+ ('26040606', '이차전지 소재 · 부품 및 장비전 (K-BATTERY SHOW 2026)', '전시홀 7A-1 , 전시홀 7B-1', DATE '2026-09-09', DATE '2026-09-11', 'https://www.kintex.com/imageView.do?atchmnflNo=469205&fileseq=1'),
+ ('26041504', '제6회 국제 운송 · 공급망관리 산업전', '전시홀 5', DATE '2026-09-09', DATE '2026-09-11', 'https://www.kintex.com/imageView.do?atchmnflNo=469244&fileseq=1'),
+ ('26021906', '2026 로컬 콘텐츠 페스타', '전시홀 4', DATE '2026-09-18', DATE '2026-09-20', 'https://www.kintex.com/imageView.do?atchmnflNo=469246&fileseq=2'),
+ ('26033004', '2026 코베 베이비페어&유아교육전', '전시홀 10', DATE '2026-10-08', DATE '2026-10-11', 'https://www.kintex.com/imageView.do?atchmnflNo=469129&fileseq=6'),
+ ('26032304', '2026 국제아웃도어캠핑&레포츠페스티벌 더 파이널 시즌 PART 1', '전시홀 7 , 전시홀 8', DATE '2026-10-09', DATE '2026-10-11', 'https://www.kintex.com/imageView.do?atchmnflNo=469174&fileseq=1'),
+ ('26042903', '2026 도시안전기술박람회', '전시홀 1', DATE '2026-10-14', DATE '2026-10-16', 'https://www.kintex.com/imageView.do?atchmnflNo=469162&fileseq=9'),
+ ('26042904', '2026 한국건설·안전박람회', '전시홀 2', DATE '2026-10-14', DATE '2026-10-16', 'https://www.kintex.com/imageView.do?atchmnflNo=469163&fileseq=2'),
+ ('26030306', '2026 K-뷰티엑스포 코리아', '전시홀 3 , 전시홀 4 , 전시홀 5', DATE '2026-10-15', DATE '2026-10-17', 'https://www.kintex.com/imageView.do?atchmnflNo=469277&fileseq=2'),
+ ('26031015', '2026 TEX+VISION', '전시홀 10', DATE '2026-10-21', DATE '2026-10-23', 'https://www.kintex.com/imageView.do?atchmnflNo=469060&fileseq=1'),
+ ('26070208', '국제 모빌리티 산업전', '전시홀 3', DATE '2026-10-21', DATE '2026-10-23', 'https://www.kintex.com/imageView.do?atchmnflNo=469283&fileseq=1'),
+ ('26042905', '2026 국제 공구 및 스마트 용접 자동화전', '전시홀 2A , 전시홀 2B-1', DATE '2026-10-27', DATE '2026-10-30', 'https://www.kintex.com/imageView.do?atchmnflNo=469207&fileseq=1'),
+ ('26051112', '2026 솔라 아시아', '전시홀 7', DATE '2026-10-28', DATE '2026-10-30', 'https://www.kintex.com/imageView.do?atchmnflNo=469236&fileseq=1'),
+ ('26060404', '2026 카페&베이커리페어 시즌2', '전시홀 10', DATE '2026-10-29', DATE '2026-11-01', 'https://www.kintex.com/imageView.do?atchmnflNo=469247&fileseq=1'),
+ ('26030312', '2026 드론·도심항공모빌리티 박람회', '전시홀 2', DATE '2026-11-04', DATE '2026-11-06', 'https://www.kintex.com/imageView.do?atchmnflNo=469266&fileseq=1'),
+ ('26040310', 'WORLD HYDROGEN EXPO 2026 (H₂ MEET 2026)', '전시홀 6C , 전시홀 7 , 전시홀 8', DATE '2026-11-04', DATE '2026-11-06', 'https://www.kintex.com/imageView.do?atchmnflNo=469276&fileseq=1'),
+ ('26060202', '한국국제승강기엑스포 2026', '전시홀 5', DATE '2026-11-11', DATE '2026-11-13', 'https://www.kintex.com/imageView.do?atchmnflNo=469240&fileseq=1'),
+ ('26042204', '2026 메가주 일산 시즌2', '전시홀 2 , 전시홀 3 , 전시홀 4 , 전시홀 5', DATE '2026-11-20', DATE '2026-11-22', 'https://www.kintex.com/imageView.do?atchmnflNo=469186&fileseq=2'),
+ ('26043007', '2026 국제아웃도어캠핑&레포츠페스티벌 더 파이널 시즌 PART 2', '전시홀 8A , 전시홀 7', DATE '2026-11-27', DATE '2026-11-29', 'https://www.kintex.com/imageView.do?atchmnflNo=469177&fileseq=2');
+
+ -- 규칙 ① source_seq 일치 행 갱신(기간·홀·포스터). 재실행 시 최신 원천으로 리프레시.
+ UPDATE event e
+ SET start_date = s.start_date,
+ end_date = s.end_date,
+ hall_label = s.hall_label,
+ poster_url = s.poster_url
+ FROM _kxw_stage s
+ WHERE e.source_seq = s.seq;
+
+ -- 규칙 ② (name, start_date) 일치 기존 행 백필(source_seq·poster_url·hall_label).
+ -- 미클레임(source_seq IS NULL) + 해당 seq 미사용 가드 → 멱등.
+ UPDATE event e
+ SET source_seq = s.seq,
+ poster_url = s.poster_url,
+ hall_label = s.hall_label
+ FROM _kxw_stage s
+ WHERE e.source_seq IS NULL
+ AND e.name = s.title
+ AND e.start_date = s.start_date
+ AND NOT EXISTS (SELECT 1 FROM event e2 WHERE e2.source_seq = s.seq);
+
+ -- 규칙 ③ 그래도 미존재분 신규 INSERT — id='kxw-', tenant_id='KINTEX', category='전시',
+ -- status=기간 기준(종료<오늘→ended, 그 외→active: V10 컨벤션 active=진행/예정 통합).
+ INSERT INTO event (id, name, start_date, end_date, status, tenant_id, category, hall_label, poster_url, source_seq)
+ SELECT 'kxw-' || s.seq, s.title, s.start_date, s.end_date,
+ CASE WHEN s.end_date < CURRENT_DATE THEN 'ended' ELSE 'active' END,
+ 'KINTEX', '전시', s.hall_label, s.poster_url, s.seq
+ FROM _kxw_stage s
+ WHERE NOT EXISTS (SELECT 1 FROM event e WHERE e.source_seq = s.seq)
+ AND NOT EXISTS (SELECT 1 FROM event e WHERE e.name = s.title AND e.start_date = s.start_date)
+ ON CONFLICT (id) DO NOTHING;
+END $$;
+
+-- ── 행사 상세 크롤 보강(2026-07-12 view.do 34건) — 주최/홈페이지/품목/소개/입장·관람 ──
+ALTER TABLE event ADD COLUMN IF NOT EXISTS organizer varchar(300);
+ALTER TABLE event ADD COLUMN IF NOT EXISTS official_site varchar(500);
+ALTER TABLE event ADD COLUMN IF NOT EXISTS items varchar(600);
+ALTER TABLE event ADD COLUMN IF NOT EXISTS description text;
+ALTER TABLE event ADD COLUMN IF NOT EXISTS admission varchar(300);
+COMMENT ON COLUMN event.official_site IS 'kintex.com 상세(view.do) 크롤 — 행사 공식 홈페이지';
+UPDATE event SET official_site = COALESCE(official_site, 'http://www.ktourismshow.com/'), items = COALESCE(items, 'KITS 지자체 홍보관, 국내/해외 관광 홍보관, 테마여행관, 여행정보관, B2B바이어상담회, KITS 어워드, 체험행사 등'), description = COALESCE(description, '*국내외 다양한 관광시장 동향 및 추세 공유를 통한 유익한 정보 습득 및 관련 체험 진행 *기업간 협업 기회를 모색하여 관광산업 관계자들의 네트워크 형성 *관광시장 확대 및 지역 관광 균형 발전을 위한 우수 관광 명소 홍보 - 대한민국 고향사랑기부 페스티벌 : 지역과 상생을 실천하는 고향사랑기부 문화 확산의 장 - 대한민국 귀농귀촌 행복박람회 : 농촌 정착 정보와 정책을 한자리에서 만나는 귀농·귀촌 종합 상담의 장') WHERE source_seq = '2025044109';
+UPDATE event SET official_site = COALESCE(official_site, 'www.campingfesta.com'), items = COALESCE(items, '캠핑차량, 캠핑용품, 캠핑소품, 캠핑서비스, 캠핑푸드'), description = COALESCE(description, '국내 캠핑관련 최신트렌드 제시, B2B 및 B2C를 통한 참가사 판로확보 지속 가능하고 안전한 캠핑문화 기반조성 및 계기마련 참가기업과 참관객의 NEEDS를 해소할 수 있는 Market Place 제공') WHERE source_seq = '26033102';
+UPDATE event SET official_site = COALESCE(official_site, 'www.safetyshow.co.kr'), items = COALESCE(items, '산업안전 및 보호구 분야 산업보건 및 직업건강 분야 방재산업 분야 공정안전관리 및 가스 산업 분야 실험실 안전관리 분야 스마트안전 분야 공공서비스 분야'), description = COALESCE(description, '안전보건 관련 국내·외 대표 브랜드가 참가해 최신 기술과 제품을 시연 320여개 사의 전문가들과의 상담을 통한 신기술 · 신제품 정보획득') WHERE source_seq = '26030510';
+UPDATE event SET official_site = COALESCE(official_site, 'https://nanokorea.or.kr/main/main.php'), items = COALESCE(items, 'ㅇ 나노기술을 기반 소재·부품·장비, 반도체, 바이오, 에너지, 센서, 레이저 등 첨단산업 전반의 기술과 제품 전시'), description = COALESCE(description, 'ㅇ 전시회, 컨퍼런스, NANO Biz Forum, Award, 1:1투자자상담회, 해외바이어 초청상담회 등') WHERE source_seq = '26031206';
+UPDATE event SET official_site = COALESCE(official_site, 'www.light-convergence.com'), items = COALESCE(items, 'LED & OLED 소부장, LIGHTING전시관, ICT융합 Display & Signage 전시관, 광융합 기술전시관'), description = COALESCE(description, '국내·외 다양한 광융합기술의 신제품과 기술력등을 한자리에 볼수있는 산업전시') WHERE source_seq = '26042803';
+UPDATE event SET official_site = COALESCE(official_site, 'http://kdtex.org/'), items = COALESCE(items, '학술대회 및 치과기자재 전시'), description = COALESCE(description, '* 지식의 향연, 학술강의 국내외 최고 권위자들이 전하는 최신 지식의 장입니다. 치과기공사의 패러다임을 바꿀 혁신적인 이론과 실전 노하우를 한자리에서 만나보세요. * 기술의 정점, 치과기자재 전시회 글로벌 치과 산업의 현재와 미래를 잇는 최첨단 장비와 재료들이 한곳에 모입니다. 디지털 덴티스트리의 정수를 직접 체험하고 비교해 보실 수 있습니다. * 예술과 기술의 조화, 보철작품 & 포스터 전시 단순한 기공물을 넘어 예술적 가치를 담아낸 회원 및 학생들의 수준 높은 보철 작품을 선보입니다. 연구 성과가 집약된 학술 포스터를 통해 최신 트렌드를 확인하세요. * 차세대 리더의 탄생, 학생 실기경진대회 미래의 치과기공계를 이끌어갈 인재들의 뜨거운 열정과 정교한 기술이 맞붙는 현장입니다. 예비 치과기공사들의 패기 넘치는 도전과 성장을 함께 응원해 주세요.') WHERE source_seq = '26051208';
+UPDATE event SET official_site = COALESCE(official_site, 'https://www.pet-show.co.kr/main'), items = COALESCE(items, '펫푸드(사료, 간식, 영양제, 의약품 등) / 펫용품(미용, 목욕, 야외, 놀이, 교육, 훈련, 위생, 배변, 건강관리 외 관련용품) / 가구&장비(하우스, 케이지, 미용대, 호텔장, 진열대, 캣타워, 드라이기 등) / 패션(의류, 액세서리, 소품, 홈 인테리어 등) / 서비스(병원, 교육 및 학습서비스, 헬스케어, 간호, 장례, 보험, 출판, IT 서비스 등) / 기타(협회 및 단체 매거진 등)'), description = COALESCE(description, '반려동물 용품 박람회') WHERE source_seq = '26041410';
+UPDATE event SET official_site = COALESCE(official_site, 'https://cobe.co.kr/__kintex.php'), items = COALESCE(items, '- 안전용품 : 유모차, 카시트, 아기띠, 보행기 등 - 임신출산 용품 : 태교용품, 수유용품, 스킨케어, 침구류, 의류, 의료기기, 건강보조식품, 목욕용품 등 - 영유아 용품 : 분유, 이유식, 유아전용생수, 친환경식품, 아토피식품, 건강보조식품, 의류, 가방 등 - 학습교육 : 도서출판물, 교육교재, 교육프로그램, 자습서, 육아잡지, 영어영재교육 등 - 영유아 서비스 : 제대혈은행, 태아보험, 베이비스튜디오, 돌잔치용품, 어린이병원, 소아과, 한의원, 여행사, 이벤트 기획사 등 - 임산부 서비스 : 산후조리원, 산후도우미, 산모체조교실, 산후마사지, 산부인과, 산모상담서비스 등 - 가전가구 : 유아가구, 아가방꾸미기, 친환경인테리어제품, 아가용가전 등 - 교육용 문구완구 : 지능계발완구, 팬시류, 악기류, 퍼즐블록, 조립식완구류, 미술용품, 캐릭터제품, 교육용 완구, 원목완구 등 - 정보제공기관 : 임신출산육아관련 정보기관(육아지원센터, 온라인커뮤니티, 어린이재단 등)'), description = COALESCE(description, '코베 베이비페어&유아교육전은 임신, 출산, 육아, 유아교육 관련 다양한 브랜드와 서비스를 한자리에서 만날 수 있는 국내 최대 규모 영유아 전문 전시회입니다. 아이를 위한 육아용품과 서비스를 직접 비교·체험하고 현장 상담까지 받을 수 있으며, 제품 구매 시 사용할 수 있는 코베페이 할인 혜택, 다양한 참여 이벤트도 함께 준비되어 있습니다. *더 자세한 이벤트 및 내용은 공식 홈페이지에서 확인 가능합니다.') WHERE source_seq = '26033003';
+UPDATE event SET official_site = COALESCE(official_site, 'www.cafedessertfair.com'), items = COALESCE(items, '커피(원두, 생두, 드립백 등), 디저트(케이크,마카롱,초콜릿,떡,약과 등), 머신(커피머신, 제빙기, 반죽기, 키오스크 등), 베이커리(생지, 완제품빵, 베이킹용품, 베이킹재료 등), 음료/주류(시럽, 청, 에이드, 와인, 수제맥주, 막걸리 등), 차(한국전통차, 홍차, 콤부차, 밀크티 등), 프랜차이즈(프랜차이즈 창업 컨설팅, 인테리어컨설팅, 납품 상담 등), 그린패키징(친환경 패키징, 종이컵, 포장재 등), 홈카페용품(가정용커피머신, 홈베이킹 용품, 커피추출기구 등), 인테리어(카페 인테리어 상담, 테이블웨어, 플레이트, 가구 등), 창업컨설팅(카페창업컨설팅, 장비설치상담, 원두납품 상담 등)'), description = COALESCE(description, '2026년 하반기 카페 산업의 핵심 트렌드를 가장 빠르게 만나볼 수 있는 전시회입니다. 8월, 여름 시즌 음료와 디저트는 물론, 다가오는 하반기 및 겨울 시즌 트렌드를 미리 경험할 수 있는 자리로 운영됩니다. 카페 운영자와 예비 창업자, 유통 바이어 및 일반 소비자들이 메뉴 개발 아이디어와 시즌 운영 인사이트를 얻을 수 있도록 구성되며, 커피·디저트·베이커리·음료·원부자재·장비·패키징 등 카페 산업 전반의 브랜드들이 참여해 시음·시식·체험·비즈니스 상담이 가능한 복합 F&B 플랫폼으로 운영됩니다.') WHERE source_seq = '26052102';
+UPDATE event SET official_site = COALESCE(official_site, 'www.kprint.kr'), items = COALESCE(items, '인쇄기기, 디지털인쇄기기, 평판인쇄기기, 라벨인쇄기기, 라벨 부착기, 후가공기기, 제본기기, 스크린인쇄기기, 라벨시스템, 바코드프린터, 제함기, 제지포장재료, 플로터, UV프린터, 전사프린터, 텍스타일프린터, CNC가공기, 목금형, 친환경 섬유, 소재 등'), description = COALESCE(description, '제29회 국제인쇄기자재회 KIPES 2026 한국레이블전시회 K-Label 2026 한국패키징전시회 K-PACK 2026 한국골판지·제지전시회 K-Corrugated 2026 한국디지털인쇄·솔루션전시회 K-Digital Print 2026 한국텍스타일전시회 K-Textile 2026 한국사인·광고전시회 K-Sign&AD 2026') WHERE source_seq = '26042207';
+UPDATE event SET official_site = COALESCE(official_site, 'www.dong-afairs.co.kr'), items = COALESCE(items, '건축 - 건축자재, 인테리어, 전원주택, 농촌체류형쉼터, 건축공구, 건축정보, 조경, 홈데코, 건축 및 주택관련 업체 냉난방 및 건축설비 - 냉난방기자재, 건축설비기자재, 신재생에너지,기타 냉난방관련제품 홈&리빙 - 가전, 주방용품, 홈인테리어, 인테리어 소품, 기타 생활용품 차·공예 - 차(茶), 도기류, 차(茶)관련 공예품, 차문화 및 생활용품 스포츠·레저·골프 - 헬스·휘트니스, 아웃도어용품, 캠핑용품, 레저차량, 골프, 기타 스포츠 및 골프관련 제품 SENDEX(재활&복지)-메디컬, 재활의료보조기기, 실버용품, 건강용품 및 식품, 기타 재활&복지관련 제품 추석명절선물 - 명절선물(전통상품, 식품, 음료, 주류, 건강상품), 선물용품, 판촉용품 등'), description = COALESCE(description, '이번 박람회는 제71회 MBC건축박람회 / 2026 서울 냉·난방 및 건축설비 박람회 / 제43회 동아 홈&리빙페어 / 2026 추석 명절 선물 박람회/ 제30회 동아 차·공예 박람회 / 제28회 동아 스포츠·레저·골프산업 박람회/ 2026 센덱스(재활&복지)박람회가 동시에 개최되는 행사로 관련산업의 다양한 신제품 및 최신 기술 정보, 업계 최신 트렌드를 한 눈에 보실 수 있습니다.') WHERE source_seq = '26052702';
+UPDATE event SET items = COALESCE(items, '낚시 관련 용품, 캠핑 및 레저용품, 캠핑카, 낚시보트, 아웃도어 등'), description = COALESCE(description, '● 국내 최초 루어낚시 전문박람회 동시개최 ● 캠핑특별관, 간편식특별관 동시개최 ● 낚시, 캠핑, 보트까지!') WHERE source_seq = '26031709';
+UPDATE event SET official_site = COALESCE(official_site, 'www.worldcitytechexpo.com'), items = COALESCE(items, '디지털 트윈, 도시재생, 재건축/재개발, 친환경 도시기술, 넷제로 에너지, 지능형 도시 기술 및 서비스, 스마트시티 인프라, 스마트 홈, 스마트 모빌리티, 스마트 정부, 스마트시티 서비스, 스마트빌리지 서비스 등'), description = COALESCE(description, '전시회, 컨퍼런스, 전문가 포럼, 해외바이어 초청 1:1 수출상담회, IR피칭 프로그램, 도슨트투어 등') WHERE source_seq = '26062208';
+UPDATE event SET official_site = COALESCE(official_site, 'www.kofurn.or.kr'), items = COALESCE(items, '가정용가구, 사무용가구, 산업용가구, 아트퍼니처, 조명&인테리어, 키친&테이블웨어, 홈네트워크&가전, 원부자재&목공기계'), description = COALESCE(description, '국내 유일의 국제 가구전시회 코펀KOFURN 전세계 15개국 350여개 가구 기업이 참가하여 1,350부스로 개최되며, 6만여명의 참관객이 방문하는 가구산업전시회로 산업 내 신제품 개발 및 기술혁신을 선보이는 전시회이다. 가구 제조, 원부자재, 유통, 디자인 등 가구산업을 총망라하여 펼쳐지는 4일간의 전시회는 국내 가구산업의 경쟁력을 강화하여 지속적인 산업의 성장 및 국제 사회로의 나아갈 길을 제시하고 있다.') WHERE source_seq = '26012601';
+UPDATE event SET items = COALESCE(items, '펫푸드 : 사료, 간식, 영양제 등 펫리빙 : 하우스, 매트, 식기, 위생/배변용품, 펫테리어 등 펫웨어 : 의류, 악세서리 등 펫케어 : 미용/목욕용품, 펫가전, 펫금융 건강관리 앱 등 펫아웃도어 : 유모차, 카시트, 하네스, 리드줄, 외출용품 등 펫플레이 : 장난감, 훈련/놀이용품 등 펫서비스 : 카페, 유치원,미용, 훈련, 여행, 펫시터, 펫택시, 장례 등'), description = COALESCE(description, '마이펫페어는 반려동물 관련 제품 및 브랜드가 참가하여 홍보 및 제품판매를 하는 B2C전시회와 더불어 반려동물과 함께 즐길 수 있는 [마이펫랜드]가 구성되어 반려견과 함께 축제처럼 즐길수 있는 전시회입니다. [마이펫랜드]에서는 반려견과 보호자가 함께하는 ‘도전하개,미로탈출’, ‘기다릴개’, ‘달려갈개’, ''킁킁놀이터'' ''맞춰볼개'' ''가져올개'' 등 체험이벤트로 반려동물과 함께 소중한 추억을 만들 수 있습니다.') WHERE source_seq = '26061902';
+UPDATE event SET official_site = COALESCE(official_site, 'https://www.re-tech.org/main/main.php'), items = COALESCE(items, '> 폐기물처리·자원재활용 기기·설비·기술·정책 > 지속가능 소재·원료, ESG·순환경제 솔루션 및 플랫폼 서비스 > 고부가가치 소재·원료 생산을 위한 분체기술 및 시스템 > 정밀 유체 제어 시스템과 지속 가능한 산업 환경을 위한 통합 설비'), description = COALESCE(description, '글로벌 전문 산업전시회와 컨퍼런스/세미나/전문 비즈니스 전시회') WHERE source_seq = '26060205';
+UPDATE event SET official_site = COALESCE(official_site, 'www.kbatteryshow.com'), items = COALESCE(items, '· 양극소재 · 음극소재 · 분리막 · 전해질 · 보호회로 · 집전장치 · 첨가제 · 자동화설비 · 기압환기구 · 전극포일 · 절연튜브 · 활성화탄소 · 이온액체 · 수소흡장합금 · 충전용 배터리 · 캐퍼시터 · 제조장비 · 검사/측정장비 · 기타 이차전지 소재 및 부품 · 기타 배터리'), description = COALESCE(description, 'K-BATTERY SHOW 2026은 양극소재, 음극소재, 분리막, 전해질 등 이차전지 대표 소재 외에 소형 이차전지, ESS전지, 모빌리티 전지 등 중대형 이차전지를 비롯하여, 차세대‘꿈의 배터리’로 불리는 전고체 배터리 기술 및 배터리 충전시스템, 인프라외에 리사이클링 시스템 등을 전시 및 소개 합니다.') WHERE source_seq = '26040606';
+UPDATE event SET official_site = COALESCE(official_site, 'www.scmfair.kr'), items = COALESCE(items, '● Digital Freight & End-to-End Solution 실시간 가시성 플랫폼 (RTVP), 디지털 포워딩 / LaaS, 통합 관제 및 관세 / 규제 솔루션 등 ● Optimizing Supply Chain AI 기반 예측 및 플래닝, 공급망 리스크 관리 (SCRM), 운송관리 시스템 (TMS), 창고관리 시스템 (WMS), 고도화 솔루션 등 ● Intelligence Robot & Automation 지능형 로봇 (AMR/AGV), 창고 자동화 설비 / 솔루션, 자율 운송 및 드론, AI 기반 머신비전 솔루션, 자동인식(AIDC) 솔루션 등 ● Equipment & Materials Handling 지게차, 팔레트 트럭, 랙 / 선반 시스템, 컨테이너 크레인, 스태커, 카고트럭 (상용차), 벌크 컨테이너 시스템, 갠트리 크레인, 컨베이어, 랩핑기, 모터 / 감속기 / 배터리, 베어링, 유압 / 공압 부품, 케이블 및 하네스 등 ● Digital Retail & Las'), description = COALESCE(description, 'SCM FAIR 2026은 디지털 포워딩부터 AI SCM 솔루션, 지능형 로봇, 친환경 포장 및 최신 하드웨어 장비까지 공급망 전 주기를 총망라합니다. 공급 기업과 수요(화주) 기업이 한자리에 모이는 최적의 비즈니스 플랫폼으로서, 격변하는 세계 시장 속 우리 기업의 글로벌 경쟁력을 극대화할 명확한 비전과 방향성을 제시합니다.') WHERE source_seq = '26041504';
+UPDATE event SET items = COALESCE(items, '전국 우수 지자체 로컬 콘텐츠 홍보 (광역, 기초, 유관기관 등) 우수 로컬 푸드 홍보 및 판매'), description = COALESCE(description, '개막식 (KBC 광주방송, 지방자치TV, 유튜브 라이브) 전국 우수 지자체 로컬콘텐츠 전시 지자체 고향사랑기부금 전달식 추석맞이 우수 로컬 푸드 홍보 및 판매 인기 가수 축하 공연 (기간 중 매일)') WHERE source_seq = '26021906';
+UPDATE event SET official_site = COALESCE(official_site, 'https://cobe.co.kr/__kintex.php'), items = COALESCE(items, '- 안전용품 : 유모차, 카시트, 아기띠, 보행기 등 - 임신출산 용품 : 태교용품, 수유용품, 스킨케어, 침구류, 의류, 의료기기, 건강보조식품, 목욕용품 등 - 영유아 용품 : 분유, 이유식, 유아전용생수, 친환경식품, 아토피식품, 건강보조식품, 의류, 가방 등 - 학습교육 : 도서출판물, 교육교재, 교육프로그램, 자습서, 육아잡지, 영어영재교육 등 - 영유아 서비스 : 제대혈은행, 태아보험, 베이비스튜디오, 돌잔치용품, 어린이병원, 소아과, 한의원, 여행사, 이벤트 기획사 등 - 임산부 서비스 : 산후조리원, 산후도우미, 산모체조교실, 산후마사지, 산부인과, 산모상담서비스 등 - 가전가구 : 유아가구, 아가방꾸미기, 친환경인테리어제품, 아가용가전 등 - 교육용 문구완구 : 지능계발완구, 팬시류, 악기류, 퍼즐블록, 조립식완구류, 미술용품, 캐릭터제품, 교육용 완구, 원목완구 등 - 정보제공기관 : 임신출산육아관련 정보기관(육아지원센터, 온라인커뮤니티, 어린이재단 등)'), description = COALESCE(description, '코베 베이비페어&유아교육전은 임신, 출산, 육아, 유아교육 관련 다양한 브랜드와 서비스를 한자리에서 만날 수 있는 국내 최대 규모 영유아 전문 전시회입니다. 아이를 위한 육아용품과 서비스를 직접 비교·체험하고 현장 상담까지 받을 수 있으며, 제품 구매 시 사용할 수 있는 코베페이 할인 혜택, 다양한 참여 이벤트도 함께 준비되어 있습니다. *더 자세한 이벤트 및 내용은 공식 홈페이지에서 확인 가능합니다.') WHERE source_seq = '26033004';
+UPDATE event SET official_site = COALESCE(official_site, 'www.caf.co.kr'), items = COALESCE(items, '캠핑장비, 캠핑차량, 캠핑푸드, 아웃도어 용품, 레저스포츠 용품, 안전용품, 관련 서비스 등'), description = COALESCE(description, '고카프는 캠핑∙레포츠 관련 용품, 장비, 서비스, 푸드, 테크까지 모두 관람할 수 있는 박람회 입니다.') WHERE source_seq = '26032304';
+UPDATE event SET items = COALESCE(items, '* 기후위기 대응 기술(기후·재난, 홍수·산불·지진·폭염, 자연재난 대응, 재난 시뮬레이션 및 예측) * 도시위기 대응 기술(사회재난, 도시위기, 화재·감염병·대형사고, 위기관리, 공공재난 대응) * 인프라 관리 기술(도시안전 플랫폼, 교통안전, 통합 관제) * 취약계층 보호 기술(생활안전, 시민 체감 안전, 고령자·취약계층, 주거·공공시설 안전)'), description = COALESCE(description, '* 국내 최대규모 ‘재난안전’분야 바이어 방문 전시회 * 시민의 일상을 지키는 ‘도시안전’분야 국내유일 특화 전시회 * 전시회+ 프로그램을 통한 관계자 네트워킹&거래실적 창출하는 전시회') WHERE source_seq = '26042903';
+UPDATE event SET official_site = COALESCE(official_site, 'https://k-consafetyexpo.com/'), items = COALESCE(items, 'AI·디지털 기반 건설현장 지능화 솔루션(스마트건설) AI 기반 사고 예측·위험관리 솔루션(스마트안전/ 출입통제·보안) 추락·붕괴 사고 예방 구조 안전 솔루션(가설기자재/안전장비) 근로자 행동·상태 인식 안전 솔루션(개인보호구/건설현장안전) 중장비·이동체 충돌 사고 예방 솔루션(건설장비) 화재·폭발·유해물질 사고 예방 기술(화재예방감지기/장치) 재난·비상 대응 로봇·드론 솔루션(재난대비건축재) VR·XR 기반 안전교육·훈련 솔루션(안전교육) 공공·발주처 안전관리 및 ESG 플랫폼(공공부문)'), description = COALESCE(description, '● 첨단기술이 적용된 융복합 첨단안전산업 제품 및 기술 특별관 구축 ● 한국건설가설협회, 한국건설안전회 등 관련 기관 및 협단체 컨퍼런스 개최') WHERE source_seq = '26042904';
+UPDATE event SET official_site = COALESCE(official_site, 'www.kbeautyexpo.com'), items = COALESCE(items, '화장품, 헤어, 네일, 에스테틱, 바디, 스마트뷰티, 이너뷰티, OEM/ODM, 원료, 패키징, 의료서비스 등'), description = COALESCE(description, '2026년 18회차를 맞이한 K-BEAUTY EXPO KOREA는 국내 최대 규모의 B2B 뷰티전문 산업박람회입니다. 해외바이어 초청 1:1 매칭 수출상담회, 자비참관 해외바이어 프리 매칭, 국내 주요 유통업체 MD초청 구매상담회, 전문 컨퍼런스 및 세미나 등 다양한 B2B 프로그램과 더불어 참가기업의 제품 홍보를 위한 BeautyFull 사회공헌행사 등 다채로운 B2C 프로모션까지, K-BEAUTY EXPO KOREA에서 만나보세요.') WHERE source_seq = '26030306';
+UPDATE event SET official_site = COALESCE(official_site, 'www.texvision.co.kr'), items = COALESCE(items, '●Sustainability Lab : 섬유 재활용 및 업사이클링 소재, 바이오 기반 섬유 소재, 친환경 염색/가공 및 디지털 프린팅 기술, 인증/검사 및 공정 관리 솔루션 등 ●Industry & Tech Zone : 의료/헬스케어 기능성 섬유, 건축/인테리어 기능성 패브릭, 차량용 고기능 섬유 소재, 농업/환경 인프라용 섬유 ●Lifestyle & Performance Zone : 스포츠/아웃도어 기능성 섬유, 스마트/웨어러블 섬유 제품, 기능성 침구 및 리커버리웨어, 위생/방수/방오 등 생활 기능성 섬유'), description = COALESCE(description, '전시회, 해외바이어 초청 수출상담회, 섬유산업 컨퍼런스, 혁신기술 어워즈, 신기술 쇼케이스 등') WHERE source_seq = '26031015';
+UPDATE event SET official_site = COALESCE(official_site, 'www.koaashow.com'), items = COALESCE(items, '이모빌리티, Mechanism/HMI 부품, 지능형 외장 시스템, 융합전장 시스템, 다목적 소재, 케미칼, 액세서리, 시험기기, 제조설비, 소프트웨어, EV 충전솔루션, 정비, 신기술 등'), description = COALESCE(description, 'ㅇ 제품 및 기술 전시 : 참가기업의 제품 및 기술을 국내외 참관객에게 선보입니다. ㅇ 비즈니스 매칭 : 참가기업과 해외바이어의 관심품목을 사전 조율하여 1:1 구매상담회를 개최합니다. ㅇ 컨퍼런스 : 모빌리티 산업 동향과 최신 기술 트렌드를 소개합니다.') WHERE source_seq = '26070208';
+UPDATE event SET official_site = COALESCE(official_site, 'www.tool-welding.com'), items = COALESCE(items, '- 공구(절삭공구, 다이아몬드공구, 연마/금형공구, 전동 및 공기압공구, 작업공구, 측정공구 등) - 용접(스마트용접기, 용접검사장비, 용접자동화, 레이저절단기기, 용접부품 및 주변기기, 용접 재료) - 관련제품(공구함, 조경/배관/목공공구, 운반하역기기, 산업안전 및 보호구, 다이캐스팅 재료/ 금형/설비, 기타 산업용재)'), description = COALESCE(description, '가정용부터 산업용까지 모든 종류의 공구 및 용접 관련 제품이 총망라되는 전시회 - 소부장뿌리기술대전, 금속산업대전 등 연관 전시회와 합동 개최 - 공구 정밀가공기술 / 용접 기술 관련 세미나, AR 시뮬레이터 용접 기능경기대회 등 부대행사 진행 예정') WHERE source_seq = '26042905';
+UPDATE event SET official_site = COALESCE(official_site, 'https://www.solarasia.co.kr/'), items = COALESCE(items, '태양광 셀&모듈, 태양광 생산장비, 태양광 전력 및 발전설비, 태양광 구조물 및 설치시스템, 태양광 구조물 및 설치시스템, 태양전지 평가·테스트·분석장비, 태양광 시스템 설치·SI·O&M 서비스, 태양광 소재 및 부품, 정부 지원 및 정책 홍보'), description = COALESCE(description, '• 부대행사: 탄소중립산업포럼, PV월드포럼, 배터리테크포럼 외 관련 세미나 등') WHERE source_seq = '26051112';
+UPDATE event SET official_site = COALESCE(official_site, 'https://www.cafenbakeryfair.com'), items = COALESCE(items, '커피, 베이커리, 디저트, 음료·주류, 시럽, 원부재료, 카페 장비, 바리스타 용품, 주방·포장용품, 프랜차이즈, 인테리어, 가구 및 소품 등'), description = COALESCE(description, '국내 대표 커피 비즈니스 플랫폼, 카페&베이커리페어 2012년 첫 개최 이후 꾸준히 성장해 온 카페&베이커리페어는 커피·베이커리를 비롯한 다양한 F&B 제품과 브랜드, 최신 트렌드를 소개하는 전문 전시회입니다. 참가기업에게는 브랜드 홍보와 판로 확대의 기회를, 바이어와 예비 창업자에게는 새로운 비즈니스와 창업 정보를, 일반 참관객에게는 다양한 커피와 디저트, F&B 제품을 직접 체험하고 구매할 수 있는 기회를 제공합니다. ■ 카페&베이커리페어 주요 구성 및 특별관 ▲ 창업 장비 특별관 창업과 매장 운영에 필요한 장비 및 솔루션 전문관 ▲ 스페셜티 원두 특별관 ''로스터리비'' 스페셜티 커피와 로스터리 브랜드 전문관 ▲ 차 특별관 ''티빌리지'' 차(Tea), 티푸드, 다기 등 다양한 티(Tea) 전문관 ▲ 주류 특별관 ''코리아스피릿페스타'' 전통주, 와인, 위스키, 맥주 등 다양한 주류 브랜드 전문관 ▲ 마이프차 프랜차이즈 창업박람회 동시 개최 카페·외식 프랜차이즈를 비롯한 다양한 창업 브랜') WHERE source_seq = '26060404';
+UPDATE event SET official_site = COALESCE(official_site, 'https://www.droneuamexpo.kr/ko'), items = COALESCE(items, 'ㅇ 항공 무인이동체: 무인항공기, 드론 부품·소재, 활용 서비스, 데이터 수집·분석 기술 등 ㅇ 도심항공교통(UAM): UAM 시뮬레이션, 버티포트, 교통 관리 시스템(UTM), 운항 제어 솔루션 등'), description = COALESCE(description, '2026 드론·도심항공모빌리티 박람회는 국내외 첨단 드론 및 UAM(도심항공교통) 분야의 혁신 기술과 최신 제품을 한눈에 조망하는 전문 비즈니스 전시회입니다. 글로벌 선도 기업들이 대거 참가하여 다채로운 솔루션을 선보이며, 비즈니스 성과 창출을 위한 1:1 수출 상담회, 전문가 세미나 및 워크숍, 대중 참여형 드론 체험 및 시연 행사 등 다각적인 프로그램이 동시 개최됩니다.') WHERE source_seq = '26030312';
+UPDATE event SET official_site = COALESCE(official_site, 'www.h2meet.com'), items = COALESCE(items, '수소 생산 분야 (H₂ Production) · 수전해, 탄소포집, 신재생에너지(풍력, 수력, 원자력 등) 수소 저장 · 운송 분야 (H₂ Storage & Distribution) · 수소충전소, 수소탱크, 액화/기체, 파이프라인, 운송(해상, 육상), 안전 등 수소 활용 분야 (H₂ Utilization) · 모빌리티, 연료전지, 서비스, 철강, 화학 등 관련 산업 기관 및 단체 (Institute) · 국내 지자체, 유관기관, 단체 및 해외 국가관, 대사관, 무역투자대표부 등'), description = COALESCE(description, '행사구성 · 전시회, 국제컨퍼런스 및 포럼, 세미나, 학술대회 등 부대행사 · H₂ Global Award, Country Day, H₂ 글로벌 비즈니스 파트너십 페어, 리셉션, 투어 등') WHERE source_seq = '26040310';
+UPDATE event SET official_site = COALESCE(official_site, 'www.liftexpokorea.com'), items = COALESCE(items, '▶ 승강기 완제품 엘리베이터,에스컬레이터,휠체어리프트 등 ▶ 승강기 부품 권상기,제어반,가이드레일,테스트 장비,카 판넬, 디스플레이 제품 등 ▶ 승강기 관련 시스템 자율주행로봇 연동시스템,관제시스템,보안시스템,방재시스템,점검시스템 등 ▶ 승강기 설치 및 유지관리 장비 호이스트,안전장비,소음측정장비, 열화상카메라, 영상장비 등'), description = COALESCE(description, '승강기 산업 전문 전시회') WHERE source_seq = '26060202';
+UPDATE event SET official_site = COALESCE(official_site, 'https://megazoo.co.kr/'), items = COALESCE(items, '■ 사료 : 건사료, 습식사료, 수제사료, 생식, 화식 등 ■ 간식 : 동결건조, 저키, 캔, 파우치, 수제간식, 스틱간식 등 ■ 헬스케어 : 영양제, 보조제, 의료기기, 검사키트, 수의상담 등 ■ 외출&놀이용품 : 장난감, 노즈워크, 하네스, 리드줄, 유모차, 카시트, 인식표, 산책용품 등 ■ 목욕&미용용품 : 샴푸, 세정제, 보습제, 스파, 칫솔, 타월, 드라이룸, 미용기기 등 ■ 리빙용품 : 식기, 방석, 계단, 하우스, 매트, 급식기, 급수기 등 ■ 의류&악세서리 : 옷, 악세서리 등 ■ 서비스/기타 : 호텔, 장례, 보험, 스튜디오, 애플리케이션, 매거진 등'), description = COALESCE(description, '■ 반려동물 사료&간식, 용품 판매 ■ 글로벌 B2B 존 : 글로벌 파빌리온, 비즈니스라운지, 비즈매칭 상담회 ■ 부대행사 : 쇼케이스, 체험 클래스, 세미나 등 * 참관료 안내 ■ 1차 사전등록 : 무료 ■ 2차 사전등록 : 6,000원 ■ 현장등록 : 12,000원 ■ 무료입장 대상 : 반려동물산업 종사자/1961년생 및 이전 출생자(66세 이상)/2011년생 및 이후 출생자(16세 이하)/장애인(복지카드 소지자)/국가 유공자(신분증 소지자)/강아지 헌혈증 소지자 본인(1인)/프레스/외국인') WHERE source_seq = '26042204';
+UPDATE event SET official_site = COALESCE(official_site, 'www.caf.co.kr'), items = COALESCE(items, '캠핑장비, 캠핑차량, 캠핑푸드, 아웃도어 용품, 레저스포츠 용품, 안전용품, 관련 서비스 등'), description = COALESCE(description, '고카프는 캠핑∙레포츠 관련 용품, 장비, 서비스, 푸드, 테크까지 모두 관람할 수 있는 박람회 입니다.') WHERE source_seq = '26043007';
diff --git a/src/backend/src/main/resources/db/migration/V33__m8_logistics_ext.sql b/src/backend/src/main/resources/db/migration/V33__m8_logistics_ext.sql
new file mode 100644
index 0000000..6c3f216
--- /dev/null
+++ b/src/backend/src/main/resources/db/migration/V33__m8_logistics_ext.sql
@@ -0,0 +1,141 @@
+-- =====================================================================
+-- V33 M8 물류 확장 — F041 지게차·부대장비 신청 / F042 부대물품 렌탈 주문 / F043 선입고 트래킹
+-- 멱등: CREATE TABLE IF NOT EXISTS + 시드 ON CONFLICT DO NOTHING.
+-- 기존 M8(V15 dock·dock_reservation) 불변 — 본 파일은 V33 단일 버전만 사용, V1~V32 및 타 팀 마이그레이션 변경 금지.
+-- 멀티테넌시 표준(소유자 지시 2026-07-12): tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX'
+-- + PRIMARY KEY (tenant_id, id) (+ FK 대상은 UNIQUE(id) 보존).
+-- 금액은 서버 권위: 장비=단가×수량×일수 / 렌탈=일단가×수량×기간(일). 카탈로그 단가는 마스터가 정본.
+-- =====================================================================
+
+-- ── ① 부대장비 품목 마스터(지게차·리프트·크레인·인력 등) — F041 ──────────────
+CREATE TABLE IF NOT EXISTS logistics_equipment (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ code varchar(40) NOT NULL, -- 표시/조회용 품목코드
+ name varchar(120) NOT NULL,
+ category varchar(20) NOT NULL DEFAULT 'forklift', -- forklift|lift|crane|manpower|tool|other
+ unit varchar(20) NOT NULL DEFAULT '대/일', -- 과금 단위 표기
+ unit_price bigint NOT NULL DEFAULT 0, -- 단가(원) — 서버 금액 계산 정본
+ spec varchar(200), -- 규격/비고(예: 2.5t 지게차)
+ active boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (id),
+ UNIQUE (tenant_id, code),
+ PRIMARY KEY (tenant_id, id)
+);
+CREATE INDEX IF NOT EXISTS idx_logi_equip_scope ON logistics_equipment (tenant_id, category, active);
+
+-- ── 장비 신청(부스별) — requested→approved/rejected(매니저)→done ──────────────
+CREATE TABLE IF NOT EXISTS logistics_equipment_request (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
+ equipment_id varchar(40) NOT NULL, -- logistics_equipment.id 소프트 참조(스냅샷 병행)
+ equipment_name varchar(120) NOT NULL, -- 신청 시점 품목명 스냅샷
+ unit_price bigint NOT NULL DEFAULT 0, -- 신청 시점 단가 스냅샷
+ booth_no varchar(40),
+ quantity integer NOT NULL DEFAULT 1,
+ use_date date, -- 사용 예정일
+ days integer NOT NULL DEFAULT 1, -- 사용 일수
+ amount bigint NOT NULL DEFAULT 0, -- 서버 계산 = unit_price×quantity×days
+ status varchar(20) NOT NULL DEFAULT 'requested',-- requested|approved|rejected|done
+ note varchar(300),
+ reject_reason varchar(300),
+ requested_by varchar(40),
+ decided_by varchar(40),
+ decided_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (id),
+ PRIMARY KEY (tenant_id, id)
+);
+CREATE INDEX IF NOT EXISTS idx_logi_equip_req_scope ON logistics_equipment_request (tenant_id, event_id, status);
+
+-- ── ② 부대물품 렌탈 카탈로그 — F042 ─────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS logistics_rental_item (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ code varchar(40) NOT NULL,
+ name varchar(120) NOT NULL,
+ category varchar(20) NOT NULL DEFAULT 'furniture', -- furniture|electric|display|av|decor|other
+ unit varchar(20) NOT NULL DEFAULT '개/일',
+ daily_price bigint NOT NULL DEFAULT 0, -- 1일 렌탈 단가(원)
+ spec varchar(200),
+ active boolean NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (id),
+ UNIQUE (tenant_id, code),
+ PRIMARY KEY (tenant_id, id)
+);
+CREATE INDEX IF NOT EXISTS idx_logi_rental_item_scope ON logistics_rental_item (tenant_id, category, active);
+
+-- ── 렌탈 주문 — ordered→confirmed/cancelled(매니저)→delivered ──────────────
+CREATE TABLE IF NOT EXISTS logistics_rental_order (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
+ item_id varchar(40) NOT NULL, -- logistics_rental_item.id 소프트 참조
+ item_name varchar(120) NOT NULL, -- 주문 시점 품목명 스냅샷
+ daily_price bigint NOT NULL DEFAULT 0, -- 주문 시점 일단가 스냅샷
+ booth_no varchar(40),
+ quantity integer NOT NULL DEFAULT 1,
+ start_date date,
+ end_date date,
+ days integer NOT NULL DEFAULT 1, -- 렌탈 기간(일) — 서버 산출
+ amount bigint NOT NULL DEFAULT 0, -- 서버 계산 = daily_price×quantity×days
+ status varchar(20) NOT NULL DEFAULT 'ordered', -- ordered|confirmed|cancelled|delivered
+ note varchar(300),
+ ordered_by varchar(40),
+ decided_by varchar(40),
+ decided_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (id),
+ PRIMARY KEY (tenant_id, id)
+);
+CREATE INDEX IF NOT EXISTS idx_logi_rental_order_scope ON logistics_rental_order (tenant_id, event_id, status);
+
+-- ── ③ 배송·창고 선입고 트래킹 — F043 ───────────────────────────────────────
+-- 상태 전이: registered → inbound_scheduled → arrived_warehouse → delivered_booth
+CREATE TABLE IF NOT EXISTS logistics_inbound (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
+ tracking_no varchar(60) NOT NULL, -- 운송장 번호
+ carrier varchar(80), -- 운송사
+ booth_no varchar(40),
+ item_desc varchar(200), -- 화물 내용
+ quantity integer NOT NULL DEFAULT 1,
+ status varchar(24) NOT NULL DEFAULT 'registered',-- registered|inbound_scheduled|arrived_warehouse|delivered_booth
+ eta_date date, -- 입고 예정일
+ warehouse_at timestamptz, -- 창고 도착 시각
+ delivered_at timestamptz, -- 부스 전달 시각
+ note varchar(300),
+ registered_by varchar(40),
+ updated_by varchar(40),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (id),
+ PRIMARY KEY (tenant_id, id)
+);
+CREATE INDEX IF NOT EXISTS idx_logi_inbound_scope ON logistics_inbound (tenant_id, event_id, status);
+CREATE INDEX IF NOT EXISTS idx_logi_inbound_track ON logistics_inbound (tenant_id, event_id, tracking_no);
+
+-- ── 시드: 부대장비 마스터 ──────────────────────────────────────────────────
+INSERT INTO logistics_equipment (id, code, name, category, unit, unit_price, spec) VALUES
+ ('leq-fork-25', 'FORK-2.5T', '지게차 2.5t', 'forklift', '대/일', 180000, '2.5t 디젤 지게차(기사 포함)'),
+ ('leq-fork-05', 'FORK-5T', '지게차 5t', 'forklift', '대/일', 260000, '5t 디젤 지게차(기사 포함)'),
+ ('leq-lift-01', 'LIFT-1', '고소작업 리프트', 'lift', '대/일', 150000, '시저 리프트 최대 10m'),
+ ('leq-crane-5', 'CRANE-5T', '이동식 크레인 5t', 'crane', '대/일', 420000, '카고 크레인 5t'),
+ ('leq-manp-01', 'MANPOWER', '상하차 인력', 'manpower', '인/일', 90000, '숙련 상하차 인력 1인')
+ON CONFLICT (id) DO NOTHING;
+
+-- ── 시드: 렌탈 카탈로그 ────────────────────────────────────────────────────
+INSERT INTO logistics_rental_item (id, code, name, category, unit, daily_price, spec) VALUES
+ ('lri-table-01', 'TBL-STD', '접이식 테이블', 'furniture', '개/일', 8000, '1800×600 접이식'),
+ ('lri-chair-01', 'CHR-STD', '상담용 의자', 'furniture', '개/일', 3000, '패브릭 상담 의자'),
+ ('lri-info-01', 'INFO-DESK','인포메이션 데스크', 'furniture', '개/일', 25000, '곡면 안내 데스크'),
+ ('lri-tv-55', 'TV-55', '55인치 모니터', 'av', '대/일', 60000, '55" UHD 거치형'),
+ ('lri-spot-01', 'SPOT-LED', 'LED 스포트 조명', 'electric', '개/일', 5000, '20W LED 스포트'),
+ ('lri-shelf-01', 'SHELF', '진열 선반', 'display', '개/일', 12000, '4단 진열 선반')
+ON CONFLICT (id) DO NOTHING;
diff --git a/src/backend/src/main/resources/db/migration/V34__m9_refund_tax.sql b/src/backend/src/main/resources/db/migration/V34__m9_refund_tax.sql
new file mode 100644
index 0000000..e5d0e1a
--- /dev/null
+++ b/src/backend/src/main/resources/db/migration/V34__m9_refund_tax.sql
@@ -0,0 +1,67 @@
+-- 킨텍스 — M9 확장: F085 환불·취소 규정 + F083 세금계산서 (멱등)
+-- FEATURE_GAP:
+-- F085 환불·취소 = 취소 시점 D-일(행사 개막일 기준) 구간별 환불율(룰셋 rulesets/refund-v1.0.json).
+-- 청구 취소 시 status='cancelled' + 환불 레코드(invoice_refund) 기록. 실 PG 환불 없음(G2 재무 게이트).
+-- F083 세금계산서 = 청구서 기반 세금계산서 데이터 문서(tax_invoice) 발행·조회. 국세청 전송 없음(G2 게이트).
+-- 보안 불변: 본 마이그레이션은 V34 단독. V1~V33 및 타 팀 마이그레이션을 변경하지 않는다. 전부 멱등.
+-- 멀티테넌시 표준(소유자 지시 2026-07-12): 신규 테넌트 소유 테이블은 tenant_id DEFAULT 'KINTEX' + PK (tenant_id, id) + UNIQUE(id).
+
+-- 1) invoice 취소 메타 순증 (F085) — 기존 상태에 'cancelled' 추가(체크 제약 없음, 값만 확장) ----
+ALTER TABLE invoice ADD COLUMN IF NOT EXISTS cancelled_at timestamptz;
+ALTER TABLE invoice ADD COLUMN IF NOT EXISTS cancel_reason varchar(500);
+COMMENT ON COLUMN invoice.cancelled_at IS 'F085 취소 확정 시각(status=cancelled)';
+COMMENT ON COLUMN invoice.cancel_reason IS 'F085 취소 사유(표시용, 민감정보 아님)';
+
+-- 2) 환불 레코드(InvoiceRefund) — 청구 취소당 환불 산정 근거 1건 (F085) --------------------
+-- base_amount = 실 납입액(paid_amount) 기준. refund = base × rate/100, forfeit = base − refund.
+-- status='recorded' = 산정·기록만(PG 미집행). 후속 재무 집행 시 'settled' 전환 여지.
+CREATE TABLE IF NOT EXISTS invoice_refund (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ invoice_id varchar(40) NOT NULL REFERENCES invoice(id) ON DELETE CASCADE,
+ event_id varchar(40) NOT NULL,
+ base_amount bigint NOT NULL DEFAULT 0, -- 환불 산정 기준(실 납입액)
+ refund_rate_percent numeric(5,2) NOT NULL DEFAULT 0, -- 적용 환불율(%)
+ refund_amount bigint NOT NULL DEFAULT 0, -- 환불액 = base × rate/100
+ forfeit_amount bigint NOT NULL DEFAULT 0, -- 공제·위약액 = base − refund
+ days_before_event integer, -- 취소 시점 D-일(음수=행사 후, null=개막일 미정)
+ bracket_label varchar(80), -- 적용 구간 라벨
+ reason varchar(500),
+ ruleset_version varchar(40), -- refund-v1.0
+ status varchar(20) NOT NULL DEFAULT 'recorded', -- recorded(기록·PG 미집행) | settled(후속)
+ created_by varchar(40),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (id),
+ PRIMARY KEY (tenant_id, id)
+);
+CREATE INDEX IF NOT EXISTS idx_invoice_refund_invoice ON invoice_refund (invoice_id);
+CREATE INDEX IF NOT EXISTS idx_invoice_refund_event ON invoice_refund (event_id);
+COMMENT ON TABLE invoice_refund IS 'F085 청구 취소·환불 산정 레코드 — 규정(refund-v1.0) 기준 서버 산정. 실 PG 환불 없음(G2 게이트)';
+
+-- 3) 세금계산서(TaxInvoice) — 청구서당 1건(멱등). 공급자=킨텍스 상수, 공급받는자=참가업체 (F083) ----
+-- supplier_biz_no·supplier_ceo 는 재무 확정 전 플레이스홀더(G2). 국세청 전송 없음(status=issued=문서 발행).
+CREATE TABLE IF NOT EXISTS tax_invoice (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ invoice_id varchar(40) NOT NULL REFERENCES invoice(id) ON DELETE CASCADE,
+ event_id varchar(40) NOT NULL,
+ tax_invoice_no varchar(40) NOT NULL,
+ supplier_name varchar(200) NOT NULL, -- 공급자(킨텍스)
+ supplier_biz_no varchar(20), -- 사업자등록번호(재무 확정 전 플레이스홀더)
+ supplier_ceo varchar(60), -- 대표자(재무 확정 전 플레이스홀더)
+ supplier_addr varchar(300),
+ buyer_name varchar(200) NOT NULL, -- 공급받는자(참가업체 표시명)
+ supply_amount bigint NOT NULL DEFAULT 0, -- 공급가액(invoice.subtotal 스냅샷)
+ vat bigint NOT NULL DEFAULT 0,
+ total bigint NOT NULL DEFAULT 0,
+ item_summary varchar(300),
+ status varchar(20) NOT NULL DEFAULT 'issued', -- issued(문서 발행·국세청 미전송) | void
+ issued_at timestamptz NOT NULL DEFAULT now(),
+ issued_by varchar(40),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (id),
+ UNIQUE (invoice_id), -- 청구서당 세금계산서 1건(멱등)
+ PRIMARY KEY (tenant_id, id)
+);
+CREATE INDEX IF NOT EXISTS idx_tax_invoice_event ON tax_invoice (event_id);
+COMMENT ON TABLE tax_invoice IS 'F083 세금계산서 데이터 문서 — 청구서 기반. 국세청 전자세금계산서 전송은 별도(G2 게이트)';
diff --git a/src/backend/src/main/resources/db/migration/V35__webhook_automation.sql b/src/backend/src/main/resources/db/migration/V35__webhook_automation.sql
new file mode 100644
index 0000000..f07d9f2
--- /dev/null
+++ b/src/backend/src/main/resources/db/migration/V35__webhook_automation.sql
@@ -0,0 +1,107 @@
+-- 킨텍스 — F099 API·웹훅 게이트웨이(인바운드 한정) + F052 리드 팔로업 EDM 자동 트리거 (멱등)
+-- 리스크 G4 정합(FEATURE_GAP §7-2·§보안 불변): 외부 API 금지 원칙 —
+-- ① 아웃바운드 발송은 스텁(발송 큐 레코드 기록까지 · 실 HTTP 콜 disabled)
+-- ② 인바운드 수신만 실동작(토큰 + HMAC-SHA256 서명 이중 검증 후 저장).
+-- 시크릿은 응답 미노출. 아웃바운드 구독 시크릿은 SHA-256 해시 저장(발송 disabled → 원문 불필요),
+-- 인바운드 시크릿은 AES-256-GCM 봉인 저장(SecretCipher) — HMAC 검증은 원문이 필요하기 때문(가역).
+-- 보안 불변: 본 마이그레이션은 V35 단독. V1~V34 및 타 팀 마이그레이션(visitor/cms/marketing)을 변경하지 않는다.
+-- 멀티테넌시 표준(소유자 지시 2026-07-12): tenant_id = PK 선두 복합키 + DEFAULT 'KINTEX'.
+
+-- ── ① 아웃바운드 웹훅 구독 등록(관리자 CRUD, @Audited) ─────────────────────────
+-- event_type(lead.hot·invoice.paid·auction.awarded 등)별 콜백 URL + 시크릿(해시) 등록.
+-- 실 발송은 disabled(스텁) — webhook_delivery 에 큐 레코드만 남긴다.
+CREATE TABLE IF NOT EXISTS webhook_subscription (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ event_type varchar(60) NOT NULL, -- lead.hot|invoice.paid|auction.awarded ...
+ callback_url varchar(500) NOT NULL, -- 표시/전달용(발송 disabled — 실 콜 없음)
+ secret_hash varchar(120) NOT NULL, -- SHA-256(secret) hex — 원문 미저장·미노출
+ description varchar(300),
+ active boolean NOT NULL DEFAULT true,
+ created_by varchar(40),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (id),
+ PRIMARY KEY (tenant_id, id) -- ★표준: tenant_id 선두 복합 PK
+);
+CREATE INDEX IF NOT EXISTS idx_webhook_sub_type ON webhook_subscription (tenant_id, event_type, active);
+
+COMMENT ON TABLE webhook_subscription IS 'F099 아웃바운드 웹훅 구독 — 발송은 스텁(disabled). event_type별 콜백 등록';
+COMMENT ON COLUMN webhook_subscription.secret_hash IS 'SHA-256(secret) hex — 원문 미저장. 발송 disabled 이므로 재계산 불요';
+
+-- ── 아웃바운드 발송 큐(스텁 — 실 HTTP 콜 없음, disabled 레코드만) ───────────────
+CREATE TABLE IF NOT EXISTS webhook_delivery (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ subscription_id varchar(40) NOT NULL, -- webhook_subscription.id 소프트 참조
+ event_type varchar(60) NOT NULL,
+ payload_json text NOT NULL DEFAULT '{}', -- 발송 예정 페이로드(민감정보 제외 계약)
+ status varchar(20) NOT NULL DEFAULT 'disabled',-- disabled(스텁) — 실발송 시 queued|sent|failed
+ note varchar(300),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (id),
+ PRIMARY KEY (tenant_id, id)
+);
+CREATE INDEX IF NOT EXISTS idx_webhook_delivery_sub ON webhook_delivery (tenant_id, subscription_id, created_at);
+
+COMMENT ON TABLE webhook_delivery IS 'F099 아웃바운드 발송 큐(스텁) — status=disabled 는 기록만·실 HTTP 콜 미수행';
+
+-- ── ③ 인바운드 수신 엔드포인트 설정(토큰 + 봉인 시크릿) ────────────────────────
+-- POST /api/webhooks/in/{token} 가 조회할 설정. token 은 공개 식별자, secret_enc 는 HMAC 검증용(봉인 저장).
+CREATE TABLE IF NOT EXISTS webhook_inbound_endpoint (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ token varchar(80) NOT NULL, -- 공개 경로 토큰(추측 불가·랜덤)
+ secret_enc varchar(400) NOT NULL, -- AES-256-GCM 봉인(enc:v1:...) — 응답 미노출
+ source_label varchar(120), -- 발신 시스템 표시명(민감정보 아님)
+ active boolean NOT NULL DEFAULT true,
+ created_by varchar(40),
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (id),
+ UNIQUE (token),
+ PRIMARY KEY (tenant_id, id)
+);
+CREATE INDEX IF NOT EXISTS idx_webhook_in_token ON webhook_inbound_endpoint (token, active);
+
+COMMENT ON TABLE webhook_inbound_endpoint IS 'F099 인바운드 수신 엔드포인트 — token(공개) + secret_enc(봉인) 이중 검증';
+COMMENT ON COLUMN webhook_inbound_endpoint.secret_enc IS 'AES-256-GCM 봉인 HMAC 시크릿 — 응답 미노출. 검증에 원문 필요하여 가역 저장';
+
+-- ── 인바운드 수신 이벤트 로그(서명 검증 결과 포함) ───────────────────────────
+CREATE TABLE IF NOT EXISTS webhook_inbound_event (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ endpoint_token varchar(80) NOT NULL,
+ event_type varchar(60), -- 페이로드 declared 유형(신뢰 전 검증됨)
+ payload_json text NOT NULL DEFAULT '{}',
+ signature_valid boolean NOT NULL DEFAULT false,
+ received_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (id),
+ PRIMARY KEY (tenant_id, id)
+);
+CREATE INDEX IF NOT EXISTS idx_webhook_in_event ON webhook_inbound_event (tenant_id, endpoint_token, received_at);
+
+COMMENT ON TABLE webhook_inbound_event IS 'F099 인바운드 수신 이벤트 로그 — 토큰·HMAC 검증 후 저장(source IP 등 민감정보 미저장)';
+
+-- ── ② F052 리드 팔로업 EDM 자동 트리거 멱등 로그 ─────────────────────────────
+-- hot 리드(score>=80) 1건당 팔로업 캠페인 초안(edm_campaign, status=draft) 1건 자동 생성.
+-- UNIQUE(event_id, lead_id) 가 스케줄 스윕 중복 생성을 차단하는 멱등 키.
+CREATE TABLE IF NOT EXISTS edm_followup_log (
+ tenant_id varchar(40) NOT NULL DEFAULT 'KINTEX',
+ id varchar(40) NOT NULL,
+ event_id varchar(40) NOT NULL, -- lead.event_id (soft ref)
+ lead_id varchar(40) NOT NULL, -- lead.id (soft ref)
+ campaign_id varchar(40), -- 생성된 edm_campaign.id
+ hot_score integer NOT NULL DEFAULT 0,
+ body_draft text, -- lead.followup_draft 스냅샷(추적용)
+ created_at timestamptz NOT NULL DEFAULT now(),
+ UNIQUE (id),
+ UNIQUE (event_id, lead_id), -- ★멱등 키: 리드당 팔로업 1회
+ PRIMARY KEY (tenant_id, id)
+);
+CREATE INDEX IF NOT EXISTS idx_edm_followup_event ON edm_followup_log (tenant_id, event_id, created_at);
+
+COMMENT ON TABLE edm_followup_log IS 'F052 리드 팔로업 EDM 자동 트리거 멱등 로그 — hot 리드당 캠페인 초안 1회(발송 없음)';
+
+-- ── 시드: 인바운드 데모 엔드포인트(봉인 시크릿은 애플리케이션이 최초 기동 시 채움) ──
+-- 봉인은 SecretCipher(런타임 키) 필요 → SQL 로 평문 저장 금지. 데모 토큰만 예약하고 secret 은
+-- 부팅 시 WebhookInboundSeeder 가 미봉인('') 엔드포인트에 한해 채운다. 여기선 레코드 미삽입(안전).
diff --git a/src/backend/src/main/resources/mybatis/mapper/NlQueryReadMapper.xml b/src/backend/src/main/resources/mybatis/mapper/NlQueryReadMapper.xml
new file mode 100644
index 0000000..8bbd7b4
--- /dev/null
+++ b/src/backend/src/main/resources/mybatis/mapper/NlQueryReadMapper.xml
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+ ${sql}
+
+
+
diff --git a/src/backend/src/main/resources/rulesets/refund-v1.0.json b/src/backend/src/main/resources/rulesets/refund-v1.0.json
new file mode 100644
index 0000000..ea2cb71
--- /dev/null
+++ b/src/backend/src/main/resources/rulesets/refund-v1.0.json
@@ -0,0 +1,23 @@
+{
+ "rulesetVersion": "refund-v1.0",
+ "effectiveDate": "2026-07-12",
+ "source": "PLANNING FEATURE_GAP F085 환불·취소 규정 / 킨텍스 참가계약 표준약관(취소 위약 구간)",
+ "disclaimer": "본 환불율은 취소 시점 기준 사전 산정용이며, 실제 환불(전자결제/PG 역거래)은 킨텍스 재무 확정 후 집행된다(G2 재무 협의 게이트). 본 시스템은 환불액 산정·기록·문서화까지만 수행한다.",
+ "currency": "KRW",
+ "policy": {
+ "basis": "eventDMinus",
+ "note": "취소 신청일 기준 행사 개막일(start_date)까지 남은 일수(D-일) 구간별 환불율. 기준액 = 실 납입액(paid_amount).",
+ "unknownDateRefundRatePercent": 0,
+ "unknownDateNote": "행사 개막일 미확정 시 자동 산정 불가 — 0%로 표기하고 재무 수기 검토 대상.",
+ "afterEventRefundRatePercent": 0,
+ "brackets": [
+ { "minDaysBefore": 90, "refundRatePercent": 100, "label": "D-90 이전" },
+ { "minDaysBefore": 60, "refundRatePercent": 90, "label": "D-60 ~ D-89" },
+ { "minDaysBefore": 30, "refundRatePercent": 70, "label": "D-30 ~ D-59" },
+ { "minDaysBefore": 14, "refundRatePercent": 50, "label": "D-14 ~ D-29" },
+ { "minDaysBefore": 7, "refundRatePercent": 30, "label": "D-7 ~ D-13" },
+ { "minDaysBefore": 0, "refundRatePercent": 0, "label": "D-6 이내" }
+ ]
+ },
+ "rules": []
+}
diff --git a/src/backend/src/test/java/com/zioinfo/kintex/ai/nlquery/NlQuerySqlGuardTest.java b/src/backend/src/test/java/com/zioinfo/kintex/ai/nlquery/NlQuerySqlGuardTest.java
new file mode 100644
index 0000000..be8b527
--- /dev/null
+++ b/src/backend/src/test/java/com/zioinfo/kintex/ai/nlquery/NlQuerySqlGuardTest.java
@@ -0,0 +1,141 @@
+package com.zioinfo.kintex.ai.nlquery;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.zioinfo.kintex.ai.nlquery.NlQuerySqlGuard.GuardedSql;
+import com.zioinfo.kintex.common.error.ApiException;
+import com.zioinfo.kintex.common.error.ErrorCode;
+import org.junit.jupiter.api.Test;
+
+/**
+ * F092 Text-to-SQL 안전 가드 회귀 — 금지어·비SELECT·화이트리스트·PII·다중문·LIMIT 강제.
+ * 이 가드가 LLM 산출 SQL 실행의 유일한 결정론 방어선이므로 위반 경로를 빠짐없이 검증한다.
+ */
+class NlQuerySqlGuardTest {
+
+ private final NlQuerySqlGuard guard = new NlQuerySqlGuard();
+
+ private ApiException reject(String sql) {
+ return assertThrows(ApiException.class, () -> guard.validate(sql));
+ }
+
+ @Test
+ void validSelect_passes_andLimitAppended() {
+ GuardedSql g = guard.validate("SELECT id, name, status FROM event WHERE status = 'active'");
+ assertTrue(g.sql().toLowerCase().contains("limit 200"), "LIMIT 200 강제 부여");
+ assertEquals(200, g.limit());
+ assertTrue(g.tables().contains("event"));
+ }
+
+ @Test
+ void codeFence_stripped_andPasses() {
+ GuardedSql g = guard.validate("```sql\nSELECT id FROM hall\n```");
+ assertTrue(g.sql().toLowerCase().startsWith("select"));
+ assertTrue(g.tables().contains("hall"));
+ }
+
+ @Test
+ void existingLimitUnderCap_kept() {
+ GuardedSql g = guard.validate("SELECT id FROM booth LIMIT 50");
+ assertEquals(50, g.limit());
+ }
+
+ @Test
+ void limitOverCap_rejected() {
+ assertEquals(ErrorCode.VALIDATION, reject("SELECT id FROM booth LIMIT 5000").getCode());
+ }
+
+ // ── 금지어(쓰기/DDL/제어) ──
+ @Test
+ void insert_rejected() {
+ assertEquals(ErrorCode.VALIDATION, reject("INSERT INTO event(id) VALUES ('x')").getCode());
+ }
+
+ @Test
+ void update_rejected() {
+ reject("UPDATE event SET status = 'x'");
+ }
+
+ @Test
+ void delete_rejected() {
+ reject("DELETE FROM event");
+ }
+
+ @Test
+ void dropAndAlter_rejected() {
+ reject("DROP TABLE event");
+ reject("ALTER TABLE event ADD COLUMN x int");
+ }
+
+ @Test
+ void selectWithForbiddenKeyword_rejected() {
+ // SELECT 로 시작해도 본문에 쓰기/우회 키워드가 있으면 차단.
+ reject("SELECT id FROM event WHERE id IN (SELECT id FROM event) ; DROP TABLE hall");
+ reject("SELECT id INTO temp FROM event");
+ reject("SELECT pg_sleep(10)");
+ }
+
+ // ── 비 SELECT / 다중문 / 주석 ──
+ @Test
+ void nonSelect_rejected() {
+ reject("WITH x AS (SELECT 1) SELECT * FROM x"); // WITH 시작 금지(단일 SELECT 시작만)
+ reject("TRUNCATE event");
+ }
+
+ @Test
+ void multiStatement_rejected() {
+ assertEquals(ErrorCode.VALIDATION,
+ reject("SELECT id FROM event; SELECT id FROM hall").getCode());
+ }
+
+ @Test
+ void comment_rejected() {
+ reject("SELECT id FROM event -- comment");
+ reject("SELECT id FROM event /* block */");
+ }
+
+ @Test
+ void trailingSemicolon_allowedAndStripped() {
+ GuardedSql g = guard.validate("SELECT id FROM event;");
+ assertTrue(!g.sql().contains(";"));
+ }
+
+ // ── 화이트리스트 테이블 ──
+ @Test
+ void nonWhitelistTable_rejected() {
+ assertEquals(ErrorCode.VALIDATION, reject("SELECT * FROM app_user").getCode());
+ reject("SELECT * FROM company");
+ reject("SELECT u.id FROM event e JOIN app_user u ON u.id = e.id");
+ }
+
+ @Test
+ void systemCatalog_rejected() {
+ reject("SELECT * FROM information_schema.tables");
+ reject("SELECT * FROM pg_catalog.pg_tables");
+ }
+
+ // ── PII 컬럼 ──
+ @Test
+ void piiPhoneEmail_alwaysRejected() {
+ assertEquals(ErrorCode.VALIDATION, reject("SELECT phone FROM visitor_registration").getCode());
+ reject("SELECT email FROM lead");
+ }
+
+ @Test
+ void nameOnPiiTable_rejected_butEventNameAllowed() {
+ // visitor_registration/lead 의 name 은 PII → 차단.
+ reject("SELECT name FROM visitor_registration");
+ reject("SELECT l.name FROM lead l");
+ // event.name 은 비-PII → 허용.
+ GuardedSql g = guard.validate("SELECT name FROM event");
+ assertTrue(g.tables().contains("event"));
+ }
+
+ @Test
+ void emptyOrBlank_rejected() {
+ reject("");
+ reject(" ");
+ }
+}
diff --git a/src/backend/src/test/java/com/zioinfo/kintex/auction/BoqCalcTest.java b/src/backend/src/test/java/com/zioinfo/kintex/auction/BoqCalcTest.java
new file mode 100644
index 0000000..46d2041
--- /dev/null
+++ b/src/backend/src/test/java/com/zioinfo/kintex/auction/BoqCalcTest.java
@@ -0,0 +1,68 @@
+package com.zioinfo.kintex.auction;
+
+import com.zioinfo.kintex.auction.dto.AuctionDtos.BoqLine;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * F035 BOQ 규칙 기반 물량 산출 단위테스트 — 면적·둘레·부스수·유틸리티 주문 기반 라인 산식 고정.
+ */
+class BoqCalcTest {
+
+ @Test
+ void computesRuleBasedLines_forSingleBooth() {
+ // 12×9=108㎡, 둘레 42m, 높이 3.5m, 유틸리티 1건.
+ List lines = BoqCalc.compute(108.0, 42.0, 1, 3.5, 1);
+ assertEquals(6, lines.size()); // 구조2 + 바닥1 + 조명1 + 전기1 + 설비1(유틸)
+
+ assertEquals("구조", lines.get(0).trade());
+ assertEquals(108L, lines.get(0).qty()); // 골조 = 바닥면적
+ assertEquals(Math.round(42.0 * 3.5), lines.get(1).qty()); // 벽체 = 둘레×높이 = 147
+ assertEquals(108L, lines.get(2).qty()); // 바닥 = 바닥면적
+ assertEquals(12L, lines.get(3).qty()); // 조명 = ceil(108/9) = 12
+ assertEquals(1L, lines.get(4).qty()); // 전기 = 부스 1실
+ assertEquals("설비", lines.get(5).trade());
+ assertEquals(1L, lines.get(5).qty()); // 설비 = 유틸리티 1건
+ }
+
+ @Test
+ void lightingRoundsUp() {
+ // 100㎡ → ceil(100/9)=12
+ List lines = BoqCalc.compute(100.0, 40.0, 1, 3.5, 0);
+ assertEquals(12L, lines.get(3).qty());
+ }
+
+ @Test
+ void omitsUtilityLineWhenNoOrders() {
+ List lines = BoqCalc.compute(108.0, 42.0, 1, 3.5, 0);
+ assertEquals(5, lines.size());
+ assertTrue(lines.stream().noneMatch(l -> "설비".equals(l.trade())));
+ }
+
+ @Test
+ void estimatesPerimeterWhenMissing() {
+ // 둘레 0 → 정사각형 근사 4√100 = 40, 벽체 = round(40×3.5) = 140
+ List lines = BoqCalc.compute(100.0, 0.0, 2, 3.5, 0);
+ assertEquals(140L, lines.get(1).qty());
+ assertTrue(lines.get(1).basis().contains("근사"));
+ assertEquals(2L, lines.get(4).qty()); // 전기 = 부스 2실
+ }
+
+ @Test
+ void usesDefaultHeightWhenMissing() {
+ // 높이 0 → 3.5 기본. 벽체 = round(40×3.5)=140
+ List lines = BoqCalc.compute(100.0, 40.0, 1, 0.0, 0);
+ assertEquals(Math.round(40.0 * BoqCalc.DEFAULT_HEIGHT_M), lines.get(1).qty());
+ }
+
+ @Test
+ void emptyWhenNoBoothsOrZeroArea() {
+ assertTrue(BoqCalc.compute(0.0, 0.0, 0, 0.0, 0).isEmpty());
+ assertTrue(BoqCalc.compute(100.0, 40.0, 0, 3.5, 0).isEmpty()); // boothCount 0
+ assertTrue(BoqCalc.compute(0.0, 40.0, 1, 3.5, 0).isEmpty()); // area 0
+ }
+}
diff --git a/src/backend/src/test/java/com/zioinfo/kintex/auth/JwtServiceTenantTest.java b/src/backend/src/test/java/com/zioinfo/kintex/auth/JwtServiceTenantTest.java
new file mode 100644
index 0000000..d747758
--- /dev/null
+++ b/src/backend/src/test/java/com/zioinfo/kintex/auth/JwtServiceTenantTest.java
@@ -0,0 +1,75 @@
+package com.zioinfo.kintex.auth;
+
+import com.zioinfo.kintex.tenant.TenantContext;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import org.junit.jupiter.api.Test;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+/**
+ * 테넌트 표준화(2026-07-12) 하위호환 단위테스트 — 표준 코드 대문자('KINTEX') 정규화가
+ * 레거시 소문자 tid('kintex')·미지정 토큰을 계속 통과시키는지 검증한다.
+ */
+class JwtServiceTenantTest {
+
+ private static final String SECRET = "kintex-test-secret-key-0123456789-abcdef"; // >= 32 bytes
+
+ private JwtService newService() {
+ AuthProperties props = new AuthProperties();
+ props.setJwtSecret(SECRET);
+ props.setAccessTokenTtlSeconds(3600);
+ return new JwtService(props);
+ }
+
+ /** tid 미지정 발급 → 검증 시 기준 테넌트 'KINTEX'로 복원. */
+ @Test
+ void issuesDefaultTenantAsUppercase() {
+ JwtService jwt = newService();
+ String token = jwt.issue("u1", "사용자", Map.of(), false);
+ KintexPrincipal p = jwt.verify(token);
+ assertEquals("KINTEX", p.tenantId());
+ }
+
+ /** 소문자 'kintex'로 발급해도 검증 결과는 표준 대문자 'KINTEX'. */
+ @Test
+ void normalizesLowercaseTenantOnIssue() {
+ JwtService jwt = newService();
+ String token = jwt.issue("u1", "사용자", Map.of(), false, "MANAGER", "kintex");
+ KintexPrincipal p = jwt.verify(token);
+ assertEquals("KINTEX", p.tenantId());
+ }
+
+ /** 레거시(기존 발급) 소문자 tid='kintex' 토큰도 검증 시 'KINTEX'로 정규화 복원(하위호환). */
+ @Test
+ void verifiesLegacyLowercaseTidToken() {
+ JwtService jwt = newService();
+ SecretKey key = Keys.hmacShaKeyFor(SECRET.getBytes(StandardCharsets.UTF_8));
+ String legacy = Jwts.builder()
+ .subject("u-legacy")
+ .claim("name", "레거시")
+ .claim("roles", Map.of())
+ .claim("hm", false)
+ .claim("tid", "kintex") // 표준화 이전 발급 토큰 모사
+ .signWith(key)
+ .compact();
+ KintexPrincipal p = jwt.verify(legacy);
+ assertEquals("KINTEX", p.tenantId());
+ }
+
+ /** TenantContext.normalize 계약 — trim+대문자, null/공백 → null. */
+ @Test
+ void normalizeContract() {
+ assertEquals("KINTEX", TenantContext.normalize("kintex"));
+ assertEquals("KINTEX", TenantContext.normalize(" KINTEX "));
+ assertEquals("COEX", TenantContext.normalize("coex"));
+ assertNull(TenantContext.normalize(null));
+ assertNull(TenantContext.normalize(" "));
+ assertEquals("KINTEX", TenantContext.currentTenantId()); // 미설정 폴백
+ }
+}
diff --git a/src/backend/src/test/java/com/zioinfo/kintex/boothsales/BoothSalesServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/boothsales/BoothSalesServiceTest.java
new file mode 100644
index 0000000..4687a83
--- /dev/null
+++ b/src/backend/src/test/java/com/zioinfo/kintex/boothsales/BoothSalesServiceTest.java
@@ -0,0 +1,160 @@
+package com.zioinfo.kintex.boothsales;
+
+import com.zioinfo.kintex.auth.EventRole;
+import com.zioinfo.kintex.auth.KintexPrincipal;
+import com.zioinfo.kintex.boothsales.dto.BoothSalesDtos.*;
+import com.zioinfo.kintex.common.error.ApiException;
+import com.zioinfo.kintex.common.error.ErrorCode;
+import com.zioinfo.kintex.settlement.SettlementService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * F005/F006 부스 판매 서비스 단위테스트 — 참가업체 인벤토리 필터(available+본인 hold)·서버 권위 가격
+ * (override vs 면적×요율)·이중 hold/만료 구매 409·매니저 전용(block/price/settle) 403 검증.
+ */
+class BoothSalesServiceTest {
+
+ private static final String EVENT = "e-2026-smf";
+ private static final long RATE = 145_000L; // 원/㎡
+
+ private BoothSalesMapper mapper;
+ private SettlementService settlement;
+ private BoothSalesService service;
+
+ private KintexPrincipal exhibitor;
+ private KintexPrincipal manager;
+
+ @BeforeEach
+ void setUp() {
+ mapper = mock(BoothSalesMapper.class);
+ settlement = mock(SettlementService.class);
+ service = new BoothSalesService(mapper, settlement);
+ when(mapper.findRatePerM2()).thenReturn((double) RATE);
+
+ exhibitor = new KintexPrincipal("u-ex", "참가업체A", Map.of(EVENT, EventRole.EXHIBITOR), false);
+ manager = new KintexPrincipal("u-mgr", "홀매니저", Map.of(), true);
+ }
+
+ private Map row(String boothId, String status, Long unitPrice, String heldBy, double area) {
+ Map m = new HashMap<>();
+ m.put("boothId", boothId);
+ m.put("boothNo", boothId);
+ m.put("boothType", "independent");
+ m.put("hallId", "H1");
+ m.put("hallLabel", "제1전시장 홀1");
+ m.put("areaM2", area);
+ m.put("status", status);
+ m.put("unitPrice", unitPrice);
+ m.put("heldBy", heldBy);
+ m.put("heldUntil", null);
+ m.put("soldToCompany", null);
+ return m;
+ }
+
+ @Test
+ void exhibitorSeesOnlyAvailableAndOwnHold_withServerPrice() {
+ List> rows = List.of(
+ row("b1", "available", null, null, 100.0), // 보임 — 파생가 100×145000
+ row("b2", "held", null, "u-ex", 50.0), // 보임 — 본인 hold
+ row("b3", "held", null, "u-other", 60.0), // 숨김 — 타인 hold
+ row("b4", "sold", null, "u-other", 70.0), // 숨김 — 판매됨
+ row("b5", "available", 9_000_000L, null, 40.0) // 보임 — override 가격
+ );
+ when(mapper.findInventory(anyString(), eq(EVENT), any())).thenReturn(rows);
+
+ InventoryResponse inv = service.inventory(EVENT, null, exhibitor);
+
+ // 요약은 전 상태 반영(필터 전 카운트: available b1,b5 / held b2,b3 / sold b4), 목록은 참가업체 가시분만.
+ assertEquals(2, inv.available());
+ assertEquals(2, inv.held());
+ assertEquals(1, inv.sold());
+ assertFalse(inv.canManage());
+ assertEquals(3, inv.units().size()); // available b1,b5 + 본인 hold b2
+
+ BoothSaleUnit b1 = inv.units().stream().filter(u -> u.boothId().equals("b1")).findFirst().orElseThrow();
+ assertEquals(100.0 * RATE, b1.unitPrice()); // 서버 권위 파생가
+ assertFalse(b1.heldByMe());
+
+ BoothSaleUnit b2 = inv.units().stream().filter(u -> u.boothId().equals("b2")).findFirst().orElseThrow();
+ assertTrue(b2.heldByMe());
+
+ BoothSaleUnit b5 = inv.units().stream().filter(u -> u.boothId().equals("b5")).findFirst().orElseThrow();
+ assertEquals(9_000_000L, b5.unitPrice()); // override 우선
+ }
+
+ @Test
+ void managerSeesAllStatuses() {
+ List> rows = List.of(
+ row("b3", "held", null, "u-other", 60.0),
+ row("b4", "sold", null, "u-other", 70.0),
+ row("b6", "blocked", null, null, 30.0)
+ );
+ when(mapper.findInventory(anyString(), eq(EVENT), any())).thenReturn(rows);
+
+ InventoryResponse inv = service.inventory(EVENT, null, manager);
+ assertTrue(inv.canManage());
+ assertEquals(3, inv.units().size()); // 전 상태 노출
+ }
+
+ @Test
+ void holdConflictWhenAtomicTransitionAffectsZeroRows() {
+ Map booth = new HashMap<>();
+ booth.put("boothId", "b1");
+ booth.put("boothNo", "b1");
+ booth.put("hallId", "H1");
+ booth.put("areaM2", 100.0);
+ when(mapper.findBooth(EVENT, "b1")).thenReturn(booth);
+ when(mapper.holdBooth(any())).thenReturn(0); // 이중 hold — 원자 전이 실패
+
+ ApiException ex = assertThrows(ApiException.class,
+ () -> service.hold(new HoldRequest(EVENT, "b1"), exhibitor));
+ assertEquals(ErrorCode.CONFLICT, ex.getCode());
+ }
+
+ @Test
+ void purchaseConflictWhenHoldExpiredOrNotHolder() {
+ Map booth = new HashMap<>();
+ booth.put("boothId", "b1");
+ booth.put("boothNo", "b1");
+ booth.put("hallId", "H1");
+ booth.put("areaM2", 100.0);
+ booth.put("assignedCompany", "참가업체A");
+ when(mapper.findBooth(EVENT, "b1")).thenReturn(booth);
+ when(mapper.purchaseBooth(any())).thenReturn(0);
+
+ ApiException ex = assertThrows(ApiException.class,
+ () -> service.purchase(new PurchaseRequest(EVENT, "b1", null), exhibitor));
+ assertEquals(ErrorCode.CONFLICT, ex.getCode());
+ }
+
+ @Test
+ void managementRequiresManager() {
+ // 참가업체는 block/price/settle 불가 → FORBIDDEN (mapper 호출 이전 차단).
+ ApiException block = assertThrows(ApiException.class,
+ () -> service.setBlocked(new BlockRequest(EVENT, "b1", true, "통로 확보"), exhibitor));
+ assertEquals(ErrorCode.FORBIDDEN, block.getCode());
+
+ ApiException price = assertThrows(ApiException.class,
+ () -> service.setPrice(new PriceRequest(EVENT, "b1", 5_000_000L), exhibitor));
+ assertEquals(ErrorCode.FORBIDDEN, price.getCode());
+
+ ApiException settle = assertThrows(ApiException.class,
+ () -> service.settle(EVENT, exhibitor));
+ assertEquals(ErrorCode.FORBIDDEN, settle.getCode());
+ }
+}
diff --git a/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java b/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java
index 4639128..1bef028 100644
--- a/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java
+++ b/src/backend/src/test/java/com/zioinfo/kintex/cms/CmsServiceTest.java
@@ -1,5 +1,7 @@
package com.zioinfo.kintex.cms;
+import com.zioinfo.kintex.ai.AiTextRouter;
+import com.zioinfo.kintex.ai.AiTextRouter.AiResult;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.cms.dto.CmsContentUpdateRequest;
import com.zioinfo.kintex.common.error.ApiException;
@@ -14,16 +16,18 @@ import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.*;
/**
- * M17 CMS 서비스 단위테스트 — 게시 전이 가드(전진만·권한)·본문 sanitize·버전 스냅샷·롤백·AI 번역 501.
+ * M17 CMS 서비스 단위테스트 — 게시 전이 가드(전진만·권한)·본문 sanitize·버전 스냅샷·롤백·AI 번역(F100 배선).
*/
class CmsServiceTest {
private CmsMapper mapper;
private SystemAccessGuard scope;
+ private AiTextRouter aiRouter;
private CmsService service;
private final KintexPrincipal editor =
@@ -33,7 +37,8 @@ class CmsServiceTest {
void setUp() {
mapper = mock(CmsMapper.class);
scope = mock(SystemAccessGuard.class);
- service = new CmsService(mapper, scope, "./build/test-uploads");
+ aiRouter = mock(AiTextRouter.class);
+ service = new CmsService(mapper, scope, aiRouter, "./build/test-uploads");
}
private Map contentRow(String status) {
@@ -141,12 +146,49 @@ class CmsServiceTest {
assertEquals(ErrorCode.NOT_FOUND, ex.getCode());
}
- // ── AI 번역 스텁 501 ───────────────────────────────────────────────────────
+ // ── AI 번역 (F100 배선) ──────────────────────────────────────────────────────
+ /** AI 미가용(degraded) 시 환각 없이 503(AI_UNAVAILABLE) — 저장하지 않는다. */
@Test
- void aiTranslateNotImplementedReturns501Code() {
+ void aiTranslateDegradedReturns503AndDoesNotPersist() {
when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft"));
- ApiException ex = assertThrows(ApiException.class, () -> service.aiTranslate("cms-1", "en"));
- assertEquals(ErrorCode.NOT_IMPLEMENTED, ex.getCode());
+ when(aiRouter.generate(anyString(), anyInt())).thenReturn(new AiResult(null, "none", true));
+ ApiException ex = assertThrows(ApiException.class,
+ () -> service.aiTranslate(editor, "cms-1", "en"));
+ assertEquals(ErrorCode.AI_UNAVAILABLE, ex.getCode());
+ verify(scope).requireManager(editor); // 매니저↑ 게이트
+ verify(mapper, never()).upsertTranslation(any()); // 실패 시 저장 안 함
+ }
+
+ /** ko(원문 언어)로의 번역 요청은 거부(400). */
+ @Test
+ void aiTranslateRejectsSourceLang() {
+ when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft"));
+ ApiException ex = assertThrows(ApiException.class,
+ () -> service.aiTranslate(editor, "cms-1", "ko"));
+ assertEquals(ErrorCode.VALIDATION, ex.getCode());
+ }
+
+ /** AI 성공 시 sanitize 후 trans_status='ai' 로 upsert. */
+ @Test
+ void aiTranslatePersistsAiDraftOnSuccess() {
+ when(mapper.findContentById("cms-1")).thenReturn(contentRow("draft"));
+ when(aiRouter.generate(anyString(), anyInt()))
+ .thenReturn(new AiResult("{\"title\":\"Title\",\"body\":\"Body
\"}", "claude", false));
+ Map trRow = new HashMap<>();
+ trRow.put("contentId", "cms-1");
+ trRow.put("lang", "en");
+ trRow.put("title", "Title");
+ trRow.put("body", "Body
");
+ trRow.put("transStatus", "ai");
+ when(mapper.findTranslations("cms-1")).thenReturn(java.util.List.of(trRow));
+ service.aiTranslate(editor, "cms-1", "en");
+ ArgumentCaptor> cap = ArgumentCaptor.forClass(Map.class);
+ verify(mapper).upsertTranslation(cap.capture());
+ assertEquals("en", cap.getValue().get("lang"));
+ assertEquals("ai", cap.getValue().get("transStatus"));
+ String body = String.valueOf(cap.getValue().get("body"));
+ assertTrue(body.contains("Body
"), "허용 태그 보존");
+ assertFalse(body.toLowerCase().contains("
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+search
+
+
개장 D-31
+
+apps
+account_circle
+
+
+
+
+
+
+
+
+
2026년 3월 10일 화요일
+
+
안녕하세요, 김주최님
+총괄 관리자
+
+
+
+
+
+진행 중 전시
+3 건
+
+
+오늘 체크인
+1,240 명
+
+
+임박 행사
+D-6
+
+
+미결 결재
+5 건
+
+
+
+
+
+
+
+
NEXT EXHIBITION
+
제 20회 한국 국제 보트쇼 2026
+
아시아 최대 규모의 해양 레저 산업 박람회, 스마트 테크놀로지와 결합된 미래 지향적 전시를 경험하세요.
+
운영 현황 보기
+
+
+
+
+
+
+
+
+
+
+
+진행 중
+이번 달
+다음 달
+
+
전체보기 chevron_right
+
+
+
+
+
+
+
+
+
+calendar_month
+ 전시 일정
+
+
+
+전체 전시장
+제1전시장 (1-5관)
+제2전시장 (6-10관)
+
+
+chevron_left
+2026.03
+chevron_right
+
+
+
+
+
+
+
일
+
월
+
화
+
수
+
목
+
금
+
토
+
+
+
+
+
+
+
+
Smart Factory & AI Expo 2026
+
+
+03.10 - 03.15 | 제1전시장
+진행 중
+
+
+
+
+
+
Korea International Boat Show
+
+
+03.12 - 03.16 | 제2전시장
+D-2
+
+
+
+
+
+
+
+
+
+
+
내 할 일 (7)
+more_horiz
+
+
+
+
+
+
결재 대기 · 부스 설계 승인
+
부스 A-102 디자인 최종 승인 필요
+
기한: 오늘까지 (D-1)
+
+
+
+
+
+
참가업체 오리엔테이션 준비
+
발표 자료 및 홀 배치도 검수
+
기한: 03.12 (D-2)
+
+
+
+
+
+
+
빠른 작업
+
+
+map
+배치 에디터
+
+
+mail
+업체 초대
+
+
+description
+서류 워크플로
+
+
+payments
+정산 관리
+
+
+campaign
+공지 작성
+
+
+search
+통합 검색
+
+
+
+
+
+
+
공지사항
+전체보기
+
+
+
+
+
+
NEW
+
KINTEX 제2전시장 주차 공간 일시 제한 안내
+
+
2026.03.10
+
+
+
+
+
AI 마케팅 툴 신규 기능 업데이트 배포 (v2.4.0)
+
2026.03.08
+
+
+
+
+
2026 하반기 대관 정기 신청 접수 일정 공고
+
2026.03.05
+
+
+
+
+
+
+
최근 활동
+
+
+
+done
+
+
부스 설계 승인됨 · A-102
+
2시간 전 · 운영본부
+
+
+
+person_add
+
+
신규 참가업체 등록 · (주)넥스트테크
+
5시간 전 · 김주최
+
+
+
+chat_bubble
+
+
운영 리포트 발송 완료
+
어제 · 시스템 자동
+
+
+
+
+
+
+
+
+workspaces
+ 내 행사 워크스페이스
+
+
+
+
+
+
건축/인테리어 박람회
+
총괄 관리자 권한
+
+
+
+
+
킨텍스 푸드 위크
+
운영팀장 권한
+
+
+
+
+
뷰티 & 헬스 엑스포
+
마케팅 관리 권한
+
+
+
+add_circle
+새 워크스페이스 추가
+
+
+
+
+
+
+
+
+