feat(home,features,tenant): SCR-HOME landing + 100-feature waves 8-9 + tenant-first PK standard + crawled events (V27-V35)
- SCR-HOME (/home): post-login landing per owner directive — role-based KPI row, banner
carousel, exhibition poster showcase rail, WISE-pattern month calendar with dynamic
hall-center filter, role-based my-tasks widget, notices/quick-actions/activity,
workspace grid, ShellFooter; login now lands on /home (workspace picker absorbed);
backend /api/home/{todos,showcase,hall-centers,summary}; Stitch mockup harvested and reconciled
- tenant standard (owner directive): tenant id 'KINTEX', tenant_id leads composite PKs
(V31 conversion with FK-safe UNIQUE(id) preservation; V27/29/30/33/34/35 born compliant);
JWT tid backward-compat normalization
- crawl: 34 upcoming kintex.com exhibitions with posters + detail enrichment
(organizer/site/items/description) upserted idempotently (V32)
- wave 8: F100 AI router (Claude->Ollama fallback, AiConfig) + lead AI rescore + CMS AI
translation; F005/6 booth sales (V27, atomic hold); F035 BOQ + F084 auction fee -> invoice
(V28); F028 approval workflow (V29, /work/approval)
- wave 9: F041/42/43 logistics tabs (V33); F085 refund rules + F083 tax invoice/report
(V34, fiscal gates documented); F092 text-to-SQL with 7-step guard + F025 rules chatbot;
F099 webhook gateway (HMAC inbound, outbound stub) + F052 follow-up EDM trigger (V35)
- verified: compileJava, full test suite, tsc -b --force, vite build all EXIT 0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9d47f9373f
commit
f6635edb06
@ -99,6 +99,7 @@
|
||||
| 2026-07-12 | **Stitch 전 화면 확보 완료(64/64)** — 서비스 회복 후 수확 패스 재실행: 잔여 39화면 전부 서버측 지연 생성 완료 상태(프로젝트 27→155 화면)로 재생성 0회·다운로드 43파일(보조 변형 5 포함: P4 3단계·P7 예매확인·M15 권종선택) 실패 0. `_workspace/stitch_gen/{harvest2.py,screen_titles.txt,harvest_report.md}`. 다음: frontend/mobile dev 이식 + design.md 🔲미생성 마커 갱신(designer 경유) |
|
||||
| 2026-07-12 | **실데이터·2FA 트랙 마감** — ①실데이터 집계 API 5패키지(analytics·dashboard·ops·admin·catalog + V13 인덱스) ②TOTP 2FA 완결(SecretCipher AES-256-GCM·V12·OtpChallengePanel/OtpSetupPage·OTP_ENFORCE env) ③공통 업무기능 SCR-39~48 화면(`screens/work/`) 배선 ④M2 부스 겹침 검사(BOOTH_OVERLAP·compliance-v1.1) + 단위테스트 3종. 검증: backend compileJava+test·frontend tsc 통과, QA PASS(blocker/major 0, minor 2=RIGGING_RANGE semantics 소유자 결정 대기·운영 KINTEX_OTP_ENC_KEY 주입 게이트 — `_workspace/09_qa_track_close.md`). 계약 갭 기록: `_workspace/{07_work_api_gaps,08_m2m5_contract_changes}.md` |
|
||||
| 2026-07-12 | **Stitch 확보 화면 이식 완료(웹 24 + 공개 7단계플로우 + 모바일 1)** — 에이전트 7팀 병렬(폴더 소유권 분리·공유파일 통합자 단일 배선). ①도메인: SCR-22/23/25(서류·신고서류·도크, M6/M8 샘플)·SCR-26/27/29/38(옥션 3종+수주 대시보드, M15 샘플, 봉인입찰 마스킹·등록업체만 응찰 UI)·SCR-30/32/33/34(관람객·리드·EDM·스폰서십, M10/M12 샘플, PII 마스킹)·SCR-35/36/37(CMS 3종, M17 샘플) ②관리자: **A5 감사로그·A6 시스템설정 = 라이브 백엔드 실배선**(PageResponse·시크릿 마스킹), A8 룰셋(정적 v1.1 스냅샷)·A9 테넌트(샘플) ③공개: P1~P7 자체 PublicShell(비인증 /public/*·/tickets/*, PG위임 고지) ④모바일: M15 티켓지갑(mobile/app/tickets, QR placeholder) ⑤통합: App 라우트 18종+AppShell "도메인 모듈" 네비. tsc -b EXIT 0·QA PASS(minor 2 코스메틱: kx-field 접두, ★/✓ 글리프). 각 팀 API 계약 초안 = `_workspace/port_*.md`. 커밋 becbc55 |
|
||||
| 2026-07-12 | **8·9차 웨이브(100대 기능 8종) + ★SCR-HOME 메인 홈 + 테넌트 표준 + 크롤 적재** — ①**SCR-HOME**(소유자 지시: 홈 없음 지적→design.md v2.1.2 확정판·Stitch 시안(프로젝트 정리 후 생성 성공, scr_home/) 정합): `/home` 랜딩(로그인→홈 전환·워크스페이스 선택 흡수)=역할별 KPI+배너 캐러셀+**전시 포스터 쇼케이스**+WISE 월캘린더(홀센터 동적 필터)+**역할별 내 할 일**+공지/빠른작업/활동+워크스페이스 그리드+ShellFooter+`/api/home/{todos,showcase,hall-centers,summary}` ②**테넌트 표준(소유자 지시)**: ten_id='KINTEX'(V31 재명명·코드 정규화·JWT 하위호환)+**tenant_id 선두 복합 PK**(V31 전환+V27/29/30/33/34/35 신규분 표준 적용) ③크롤: kintex.com 전시 34건+포스터+상세(주최·홈페이지·품목·소개, V32) ④W8: F100 AI라우터(Claude→Ollama 폴백·AiConfig)+F051 리드AI재계산+CMS AI번역 실배선·F005/6 부스판매(V27·원자 hold)·F035 BOQ+F084 옥션수수료 정산(V28)·F028 전자결재(V29·결재함 실배선·/work/approval) ⑤W9: F041/42/43 물류4탭(V33)·F085 환불룰(refund-v1.0)+F083 세금계산서·리포트(V34·G2 게이트)·F092 Text-to-SQL(7단계 가드·PII차단)+F025 규정챗봇·F099 웹훅(HMAC 인바운드·아웃바운드 스텁)+F052 팔로업EDM 트리거(V35). 검증: compileJava·test·tsc·vite 전부 EXIT 0. 잔여 게이트: PG/국세청/실발송(G2)·공사업체 통지 이메일(Postfix 승인 대기) |
|
||||
| 2026-07-12 | **7차 웨이브(MDI+테넌시2단계+i18n 스윕2)** — ①MDI 셸: 탭 문서 바(`MdiTabBar`+`mdiStore` sessionStorage·라우트 동기화·dedup·MAX 10·키보드 접근성·다크 토큰) 순증 레이어 — 잔여: DocumentHost 상태 보존(§2.7-3)·드래그 재정렬 ②테넌시 2단계: JWT `tid` 클레임(하위호환 폴백)·`EventAccessGuard` 테넌트 fail-closed(교차 테넌트 403 — `EventTenantMapper`+격리 테스트 3종)·전역 집계(analytics/admin/sysuser) 명시 스코핑 — company·audit_log는 Phase 3 후보 ③i18n 스윕2: 코어+admin 17화면·네임스페이스 30종(4개 언어) — 잔여 P2(도메인 모듈·work 공통)·P3(공유 ui) 목록=_workspace/impl_i18n_sweep2.md. 검증: tsc·vite·compileJava·test(75개) 전부 EXIT 0 |
|
||||
| 2026-07-12 | **5·6차 웨이브(NFR+멀티테넌시) + 운영 도메인 라이브** — ①W5-1 다크테마(global.css `--dk-*`→`[data-theme=dark]` remap·시스템 선호 폴백·themeStore(localStorage)·AppShell 토글·공유 CSS 하드코딩 hex 토큰화·focus-ring/reduced-motion/스킵링크) ②W5-2 i18n(react-i18next ko/en/zh/ja·공개 P1~P7+로그인/가입/비번찾기 전 문구·LanguageSwitcher(공개셸+로그인+AppShell 배선)·fallback ko) ③W6 멀티테넌시 1단계(V26 tenant 마스터+event/hall/app_user tenant_id 순증·TenantContextFilter(X-Tenant-Id→Host→kintex)·catalog/hall 스코핑·`/api/admin/tenants` GET/POST 실배선 — 회귀 0) ④**운영 도메인 https://kintex.wise.ai.kr 라이브**(위 §2 참조). 검증: tsc·vite·compileJava·test 전부 EXIT 0 |
|
||||
| 2026-07-12 | **4차 웨이브(백로그 4트랙) + ★OTP 로그인 루프 라이브 장애 수정** — ①**OTP 장애**(사용자 보고 "로그인 후 메인화면 안 나옴"): `verifyOtp`/`enrollVerify`가 코드 검증 **전에** 챌린지를 소비 → 오입력 1회면 이후 정답도 "세션 만료" 루프. 수정=성공 시에만 소비+실패 5회 한도(`OtpChallengeStore.recordFailure`)+`OtpChallengeStoreTest` ②SCR-14 현장운영 실전환(혼잡=체크인 파생·전력=utility 부하, IoT는 "센서 미연동" 정직 표기) ③M9 정산 신설(`/api/settlement`·V24 invoice/invoice_payment·`/settlement` 화면, ST_Area×요율+VAT 서버 권위·PG위임) ④M1 홀배정+자동견적(`/api/halls`·V25 기간 컬럼 순증·충돌 409+상세·rates-v1.0 견적·`/halls` 화면) ⑤공유 레이어: AuthUser.roleCode 순증 노출(JWT `rc` 클레임)+AdminGuard 정밀화+AppShell 플레이스홀더 네비 전량 배선(행사 컨텍스트 계산 경로)+`/_styleguide` prod 제외. 검증: compileJava·test·tsc -b --force·vite build 전부 EXIT 0 |
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,45 @@
|
||||
package com.zioinfo.kintex.ai;
|
||||
|
||||
import com.zioinfo.kintex.ai.dto.AiConfigDto;
|
||||
import com.zioinfo.kintex.ai.dto.AiConfigUpdateRequest;
|
||||
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.*;
|
||||
|
||||
/**
|
||||
* F100 AI 플랫폼 설정 관리 API(백오피스). 시스템관리자(requireAdmin) 전용.
|
||||
* <ul>
|
||||
* <li>GET /api/admin/ai/config — 현재 효과 설정(키 값 미노출 · claudeKeySet 불리언만)</li>
|
||||
* <li>PUT /api/admin/ai/config — provider/모델/활성화 갱신(화이트리스트 검증, @Audited)</li>
|
||||
* </ul>
|
||||
* ★ API 키는 이 API 로 저장/조회하지 않는다(env 전용).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/ai")
|
||||
public class AiConfigController {
|
||||
|
||||
private final AiConfigService service;
|
||||
private final SystemAccessGuard guard;
|
||||
|
||||
public AiConfigController(AiConfigService service, SystemAccessGuard guard) {
|
||||
this.service = service;
|
||||
this.guard = guard;
|
||||
}
|
||||
|
||||
@GetMapping("/config")
|
||||
public ApiResponse<AiConfigDto> get(@AuthenticationPrincipal KintexPrincipal principal) {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.getConfig());
|
||||
}
|
||||
|
||||
@Audited(action = "AI_CONFIG_UPDATE", targetType = "ai_config")
|
||||
@PutMapping("/config")
|
||||
public ApiResponse<AiConfigDto> update(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@RequestBody AiConfigUpdateRequest req) {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.update(req, principal.userId()));
|
||||
}
|
||||
}
|
||||
@ -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)).
|
||||
* <p>★ 보안 불변: 이 테이블에는 <b>API 키를 저장하지 않는다</b>(키는 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<Map<String, Object>> findAllByTenant(@Param("tenantId") String tenantId);
|
||||
|
||||
default List<Map<String, Object>> 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);
|
||||
}
|
||||
}
|
||||
@ -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 우선 → 미설정 시 기본값 폴백.
|
||||
*
|
||||
* <p><b>보안</b>: 이 서비스는 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<String> ALLOWED_PROVIDERS = Set.of(PROVIDER_CLAUDE, PROVIDER_OLLAMA);
|
||||
/** 허용 Claude 모델 ID(임의 문자열 거부). */
|
||||
public static final Set<String> 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<String, String> all = allValues();
|
||||
String v = all.get(key);
|
||||
return (v != null && !v.isBlank()) ? v : null;
|
||||
}
|
||||
|
||||
private Map<String, String> allValues() {
|
||||
Map<String, String> m = new HashMap<>();
|
||||
List<Map<String, Object>> rows = mapper.findAll();
|
||||
if (rows != null) {
|
||||
for (Map<String, Object> 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;
|
||||
}
|
||||
}
|
||||
@ -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 텍스트 생성의 단일 진입점.
|
||||
*
|
||||
* <p><b>선택/폴백 정책</b>:
|
||||
* <ul>
|
||||
* <li>AI 비활성({@link AiConfigService#aiEnabled()}=false) → 즉시 degraded(provider="none"). 호출자 폴백.</li>
|
||||
* <li>provider=claude · 키 설정 · 활성 → Claude 시도. 실패(degraded)면 <b>Ollama 자동 폴백</b>.</li>
|
||||
* <li>provider=ollama (또는 claude 인데 키 미설정) → Ollama 시도.</li>
|
||||
* <li>양 경로 실패 → degraded(text=null). 호출자가 규칙기반/오류 처리(무회귀).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>환각 방지</b>: 반환 {@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();
|
||||
}
|
||||
}
|
||||
@ -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).
|
||||
*
|
||||
* <p><b>외부 호출 예외 허용</b>: 소유자 승인(2026-07-03)에 따라 {@code api.anthropic.com} 은 허용된 유일한
|
||||
* 외부 경로다. 그 외 외부 API 는 여전히 금지.
|
||||
*
|
||||
* <p><b>API 키 보안(최우선)</b>: 키는 환경변수 {@code ANTHROPIC_API_KEY} 에서만 로드하며
|
||||
* ({@code @Value("${ANTHROPIC_API_KEY:}")}), DB·코드·커밋·로그·응답·예외 메시지 어디에도 기록하지 않는다.
|
||||
* 키는 오직 HTTP 헤더 {@code x-api-key} 로만 전달된다(로그는 상태코드/예외 클래스명만).
|
||||
*
|
||||
* <p><b>실패 처리</b>: 키 미설정/타임아웃/비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<String> 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();
|
||||
}
|
||||
}
|
||||
@ -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 폴백 경로).
|
||||
*
|
||||
* <p><b>설정</b>: 베이스 URL 은 환경변수 {@code OLLAMA_URL} 에서만 로드한다({@code @Value("${OLLAMA_URL:}")}).
|
||||
* <b>미설정 시 폴백을 스킵</b>({@link #isConfigured()}=false → 항상 degraded)한다 — 개발 서버 RAM 제약상
|
||||
* Ollama 미기동 환경에서 조용히 degraded 로 흐르게 한다.
|
||||
*
|
||||
* <p><b>실패 처리</b>: 비활성/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<String> 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();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
package com.zioinfo.kintex.ai;
|
||||
|
||||
/**
|
||||
* 텍스트 생성 provider 추상화 (F100 AI 플랫폼 라우터).
|
||||
*
|
||||
* <p>온프레미스 Ollama({@link OllamaTextClient})와 외부 Claude({@link ClaudeTextClient})를 동일 계약으로 다룬다.
|
||||
* 구현체는 어떤 사유로든(비활성/실패/타임아웃/키·URL 미설정) 실패 시 {@link GenResult#degraded()}=true·
|
||||
* {@link GenResult#text()}=null 을 반환하고 <b>예외를 던지지 않는다</b>. 폴백은 호출자({@link AiTextRouter})가 책임진다.
|
||||
*
|
||||
* <p>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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.zioinfo.kintex.ai.dto;
|
||||
|
||||
/**
|
||||
* F100 AI 설정 조회 응답. <b>키 값·길이·마스킹 일절 미포함</b> — {@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) {
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
package com.zioinfo.kintex.ai.dto;
|
||||
|
||||
/**
|
||||
* F100 AI 설정 갱신 요청. <b>API 키는 받지 않는다</b>(키는 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) {
|
||||
}
|
||||
@ -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 라우터 경유).
|
||||
*
|
||||
* <ul>
|
||||
* <li>POST /api/ai/nlquery — 자연어 → 안전 SELECT 실행(<b>시스템관리자</b> 전용, 행사 횡단 데이터 접근). @Audited</li>
|
||||
* <li>POST /api/ai/rules-chat — 규정/요율 근거 챗봇(인증 사용자). 근거 없으면 정직 응답.</li>
|
||||
* </ul>
|
||||
* 두 엔드포인트 모두 산출 출처(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<NlQueryResult> 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<RulesChatResult> rulesChat(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@RequestBody RulesChatRequest req) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(rulesChatService.chat(req == null ? null : req.question()));
|
||||
}
|
||||
}
|
||||
@ -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 실행 매퍼. <b>{@link NlQuerySqlGuard} 통과 SQL 만</b> 전달받아 실행한다.
|
||||
*
|
||||
* <p>이 매퍼는 오직 SELECT 결과를 {@code LinkedHashMap}(컬럼 순서 보존) 리스트로 반환한다.
|
||||
* SQL 은 XML {@code ${sql}} 로 그대로 실행되므로, 호출자는 반드시 사전에 가드 검증을 거쳐야 한다.
|
||||
* 실제 실행은 {@code @Transactional(readOnly=true)} 하에서 이루어진다(이중 방어).
|
||||
*/
|
||||
@Mapper
|
||||
public interface NlQueryReadMapper {
|
||||
|
||||
/** 가드 통과 SELECT 실행. 결과 각 행은 컬럼 순서를 보존한 LinkedHashMap. */
|
||||
List<Map<String, Object>> runReadOnly(@Param("sql") String sql);
|
||||
}
|
||||
@ -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 로 변환·실행한다.
|
||||
*
|
||||
* <p><b>파이프라인</b>: 질문 + 화이트리스트 스키마 프롬프트 → {@link AiTextRouter}(Claude→Ollama) → SQL 추출 →
|
||||
* {@link NlQuerySqlGuard} 결정론 검증(단일 SELECT·화이트리스트·PII 차단·LIMIT 200) →
|
||||
* read-only 트랜잭션 실행 → 결과 + <b>생성 SQL 근거</b> 반환(투명성).
|
||||
*
|
||||
* <p><b>보안</b>: 프롬프트 스키마에서 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<Map<String, Object>> 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<String> 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());
|
||||
}
|
||||
}
|
||||
@ -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 을 <b>실행 전</b> 검증한다.
|
||||
*
|
||||
* <p>LLM 산출 SQL 은 신뢰할 수 없다(프롬프트 인젝션·환각·오작성). 이 가드는 결정론적(정규식 기반) 검증으로
|
||||
* 오직 <b>단일 읽기 전용 SELECT</b> 만, <b>화이트리스트 테이블</b> 에 대해서만, <b>PII 원문 컬럼 없이</b>,
|
||||
* <b>LIMIT 200 이하</b> 로만 통과시킨다. 하나라도 위반하면 {@link ErrorCode#VALIDATION} 을 던져 실행을 차단한다.
|
||||
*
|
||||
* <p>이중 방어: 여기서 통과해도 실제 실행은 read-only 트랜잭션({@link NlQueryService})에서 수행한다.
|
||||
*/
|
||||
@Component
|
||||
public class NlQuerySqlGuard {
|
||||
|
||||
/** 조회 허용 테이블(그 외 모든 테이블·시스템 카탈로그 차단). */
|
||||
public static final Set<String> ALLOWED_TABLES = Set.of(
|
||||
"event", "hall", "booth", "visitor_registration", "lead", "invoice", "auction");
|
||||
|
||||
/** PII 원문 컬럼(어떤 경우에도 SQL 에 등장 금지 — 프롬프트에서도 제외). */
|
||||
private static final Set<String> PII_COLUMNS = Set.of("phone", "email");
|
||||
/** visitor_registration·lead 참조 시 추가로 금지되는 PII 컬럼(event.name 등 비-PII 는 허용). */
|
||||
private static final Set<String> 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<String> 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<String> tables = new java.util.ArrayList<>();
|
||||
Set<String> 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();
|
||||
}
|
||||
}
|
||||
@ -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)을 코퍼스로 근거 기반 답변한다.
|
||||
*
|
||||
* <p><b>파이프라인</b>: 질문 → 키워드 매칭으로 관련 룰 항목 선별(근거) → {@link AiTextRouter}(Claude→Ollama)로
|
||||
* <b>선별된 룰만</b> 근거로 답변 생성(룰 code/값 인용 필수) → 응답에 인용 룰 동봉.
|
||||
*
|
||||
* <p><b>환각 방지(핵심)</b>:
|
||||
* <ul>
|
||||
* <li>관련 룰을 하나도 못 찾으면 → AI 호출 없이 "규정에서 찾지 못했습니다" 정직 응답(grounded=false).</li>
|
||||
* <li>룰은 찾았으나 AI 미가용 → AI 지어내기 대신 <b>찾은 룰 항목 자체를 요약</b>해 반환(degraded=true, provider="rules").</li>
|
||||
* <li>프롬프트에 "제공된 규정 항목 밖의 내용은 지어내지 마라"를 명시.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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<Item> corpus = buildCorpus();
|
||||
List<Item> matched = match(question, corpus, 6);
|
||||
|
||||
// 근거 없음 → AI 호출 없이 정직 응답(환각 차단).
|
||||
if (matched.isEmpty()) {
|
||||
return new RulesChatResult(question,
|
||||
"규정에서 관련 내용을 찾지 못했습니다. 높이·리깅·방염·바닥하중·통로·임대료·전기/네트워크 등 규정/요율 키워드로 다시 질문해 주세요.",
|
||||
List.of(), false, "none", true);
|
||||
}
|
||||
|
||||
List<RuleCitation> 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<Item> 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<Item> buildCorpus() {
|
||||
List<Item> 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<Item> items, Map<String, Object> 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<String, Object> map) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (Map.Entry<String, Object> 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<Item> match(String question, List<Item> corpus, int topN) {
|
||||
String[] tokens = norm(question).split("\\s+");
|
||||
record Scored(Item item, int score) {
|
||||
}
|
||||
List<Scored> 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<Item> 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);
|
||||
}
|
||||
}
|
||||
@ -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<String> tables,
|
||||
List<String> columns,
|
||||
List<Map<String, Object>> 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<RuleCitation> citations,
|
||||
boolean grounded,
|
||||
String provider,
|
||||
boolean degraded) {
|
||||
}
|
||||
}
|
||||
@ -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<com.zioinfo.kintex.auction.dto.AuctionDtos.BoqResult> 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")
|
||||
|
||||
@ -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<Map<String, Object>> 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<String, Object> 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<String, Object> boqUtilityAggregate(@Param("eventId") String eventId,
|
||||
@Param("boothNo") String boothNo);
|
||||
}
|
||||
|
||||
@ -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<String, Object> 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")));
|
||||
}
|
||||
|
||||
|
||||
@ -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) 자동산출 — 부스 설계 데이터(면적·둘레·부스수·유틸리티 주문)에서
|
||||
* 공사 물량 라인을 <b>규칙 기반</b>으로 집계하는 순수 함수(단위 테스트 대상, DB·스프링 미의존).
|
||||
*
|
||||
* <p><b>산식은 단순 명료(면적·주문 합산 기반)</b> — 각 라인은 산출 근거({@code basis})를 함께 표기한다.
|
||||
* 금액(단가)은 산출하지 않는다 — BOQ 는 <b>물량(수량)</b> 산출서이며, 단가·금액은 응찰 업체가 견적서로 채운다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>구조: 시스템 골조 = 바닥면적(1:1) · 벽체 파티션 = 둘레 × 높이</li>
|
||||
* <li>바닥: 바닥 마감재 = 바닥면적(1:1)</li>
|
||||
* <li>조명: LED 조명기구 = ⌈면적 / 9㎡⌉ (9㎡당 1등)</li>
|
||||
* <li>전기: 분전반·전기 인입 = 부스 수(1실당 1)</li>
|
||||
* <li>설비: 급배수·네트워크 인입 = 유틸리티 신청 건수(있을 때만)</li>
|
||||
* </ul>
|
||||
*/
|
||||
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<BoqLine> compute(double areaM2, double perimeterM, int boothCount,
|
||||
double heightM, int utilityOrderCount) {
|
||||
List<BoqLine> 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);
|
||||
}
|
||||
}
|
||||
@ -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) 자동산출 서비스 — 옥션 자료 패키지 첨부용.
|
||||
*
|
||||
* <p>부스 설계 데이터(M2 booth/layout·M4 utility_order — <b>읽기 전용</b>)에서 공사 물량을
|
||||
* 규칙 기반으로 집계한다({@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<String, Object> 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<String, Object> 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<String, Object> util = mapper.boqUtilityAggregate(eventId, boothNoFilter);
|
||||
int utilityCount = util == null ? 0 : intOr(util.get("orderCount"), 0);
|
||||
|
||||
List<BoqLine> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<BoqLine> lines, String note, String generatedAt) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 실시간 순위 행(응찰자 뷰 · 봉인). priceMasked=true 이면 price 는 null(타사 비공개).
|
||||
* alias 는 위치 라벨(업체명 아님) — 업체 식별 불가.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, "토큰이 유효하지 않거나 만료되었습니다.");
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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).
|
||||
* <ul>
|
||||
* <li>조회(인벤토리): 행사 접근 권한(참가업체는 available+본인 hold, 매니저는 전 상태).</li>
|
||||
* <li>hold·구매 확정·본인 hold 해제: 행사 멤버(참가업체 포함).</li>
|
||||
* <li>blocked 전환·판매가 override·정산 연계: 매니저 이상(주최자/홀매니저) + {@link Audited}.</li>
|
||||
* </ul>
|
||||
* 이중 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<InventoryResponse> 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<BoothSaleResult> 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<BoothSaleResult> 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<BoothSaleResult> 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<BoothSaleResult> 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<BoothSaleResult> 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<GenerateResult> settle(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@RequestParam String eventId) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.settle(eventId, principal));
|
||||
}
|
||||
}
|
||||
@ -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).
|
||||
* <p><b>★ Map 반환 @Select 별칭은 반드시 쌍따옴표(AS "x")</b> — PG 는 따옴표 없는 별칭을 소문자로 접어
|
||||
* Map 키가 null 이 된다(WORK_STATUS §7). 선택 파라미터(null)는 {@code COALESCE(CAST(#{p} AS type), 대체)}
|
||||
* 로 흡수해 PG 파라미터 타입 미결정 오류를 회피한다. ★{@code <script>} 미사용 — 비교연산 {@code <} 부팅
|
||||
* 크래시 회피(비교는 raw @Select 본문에서만 사용).
|
||||
*/
|
||||
@Mapper
|
||||
public interface BoothSalesMapper {
|
||||
|
||||
/**
|
||||
* 행사(+선택 홀)의 판매 인벤토리 — 최신 layout 버전의 booth 좌표 + booth_sale 오버레이 병합.
|
||||
* 실효 상태: 만료 hold(held_until 경과)는 available 로 강등. 면적은 ㎡(ST_Area, SRID 0=미터).
|
||||
* heldByMe 판정을 위해 held_by 를 그대로 노출(내부 식별자, 응답 DTO 에는 미포함 — 서비스에서 소거).
|
||||
*/
|
||||
@Select("""
|
||||
SELECT b.id AS "boothId",
|
||||
b.booth_no AS "boothNo",
|
||||
b.booth_type AS "boothType",
|
||||
l.hall_id AS "hallId",
|
||||
h.label AS "hallLabel",
|
||||
ROUND(ST_Area(b.geom)::numeric, 2) AS "areaM2",
|
||||
CASE
|
||||
WHEN bs.status = 'held' AND bs.held_until IS NOT NULL AND bs.held_until < now()
|
||||
THEN 'available'
|
||||
ELSE COALESCE(bs.status, 'available')
|
||||
END AS "status",
|
||||
bs.unit_price AS "unitPrice",
|
||||
bs.held_by AS "heldBy",
|
||||
to_char(bs.held_until AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "heldUntil",
|
||||
bs.sold_to_company AS "soldToCompany",
|
||||
b.assigned_company_name AS "assignedCompany"
|
||||
FROM booth b
|
||||
JOIN layout l ON l.id = b.layout_id
|
||||
JOIN hall h ON h.id = l.hall_id
|
||||
LEFT JOIN booth_sale bs
|
||||
ON bs.booth_id = b.id
|
||||
AND bs.event_id = l.event_id
|
||||
AND bs.tenant_id = #{tenantId}
|
||||
WHERE l.event_id = #{eventId}
|
||||
AND l.hall_id = COALESCE(CAST(#{hallId} AS varchar), l.hall_id)
|
||||
AND l.version = (SELECT max(l2.version) FROM layout l2
|
||||
WHERE l2.event_id = l.event_id AND l2.hall_id = l.hall_id)
|
||||
ORDER BY l.hall_id, b.booth_no
|
||||
""")
|
||||
List<Map<String, Object>> findInventory(@Param("tenantId") String tenantId,
|
||||
@Param("eventId") String eventId,
|
||||
@Param("hallId") String hallId);
|
||||
|
||||
/** 단일 판매 단위(전이 후 응답) — 인벤토리와 동일 shape, booth 1행. 없으면 null. */
|
||||
@Select("""
|
||||
SELECT b.id AS "boothId",
|
||||
b.booth_no AS "boothNo",
|
||||
b.booth_type AS "boothType",
|
||||
l.hall_id AS "hallId",
|
||||
h.label AS "hallLabel",
|
||||
ROUND(ST_Area(b.geom)::numeric, 2) AS "areaM2",
|
||||
CASE
|
||||
WHEN bs.status = 'held' AND bs.held_until IS NOT NULL AND bs.held_until < now()
|
||||
THEN 'available'
|
||||
ELSE COALESCE(bs.status, 'available')
|
||||
END AS "status",
|
||||
bs.unit_price AS "unitPrice",
|
||||
bs.held_by AS "heldBy",
|
||||
to_char(bs.held_until AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "heldUntil",
|
||||
bs.sold_to_company AS "soldToCompany",
|
||||
b.assigned_company_name AS "assignedCompany"
|
||||
FROM booth b
|
||||
JOIN layout l ON l.id = b.layout_id
|
||||
JOIN hall h ON h.id = l.hall_id
|
||||
LEFT JOIN booth_sale bs
|
||||
ON bs.booth_id = b.id
|
||||
AND bs.event_id = l.event_id
|
||||
AND bs.tenant_id = #{tenantId}
|
||||
WHERE l.event_id = #{eventId}
|
||||
AND b.id = #{boothId}
|
||||
LIMIT 1
|
||||
""")
|
||||
Map<String, Object> findUnit(@Param("tenantId") String tenantId,
|
||||
@Param("eventId") String eventId,
|
||||
@Param("boothId") String boothId);
|
||||
|
||||
/** booth 존재·판매면적 확인(hold/purchase 사전검증). 없으면 null. */
|
||||
@Select("""
|
||||
SELECT b.id AS "boothId",
|
||||
b.booth_no AS "boothNo",
|
||||
l.hall_id AS "hallId",
|
||||
ROUND(ST_Area(b.geom)::numeric, 2) AS "areaM2",
|
||||
b.assigned_company_name AS "assignedCompany"
|
||||
FROM booth b
|
||||
JOIN layout l ON l.id = b.layout_id
|
||||
WHERE l.event_id = #{eventId} AND b.id = #{boothId}
|
||||
LIMIT 1
|
||||
""")
|
||||
Map<String, Object> findBooth(@Param("eventId") String eventId, @Param("boothId") String boothId);
|
||||
|
||||
/** 전시홀 임대 요율(원/㎡) — master_data RATE.exhibitionHallPerM2(rates-v1.0). 없으면 null. */
|
||||
@Select("""
|
||||
SELECT (value_json #>> '{}')::numeric
|
||||
FROM master_data WHERE category = 'RATE' AND code = 'exhibitionHallPerM2'
|
||||
""")
|
||||
Double findRatePerM2();
|
||||
|
||||
/**
|
||||
* 부스 hold 원자 전이(참가업체 셀프 선택).
|
||||
* <p>행 없으면 INSERT(held), 있으면 available/만료 held/본인 held 일 때만 UPDATE(held·TTL 갱신).
|
||||
* sold/blocked/타인 활성 held 는 WHERE 불일치로 0행 → 서비스가 409 로 매핑.
|
||||
* (비교연산 {@code <} 는 raw @Insert 본문이라 이스케이프 불필요.)
|
||||
*/
|
||||
@Insert("""
|
||||
INSERT INTO booth_sale
|
||||
(id, tenant_id, event_id, hall_id, booth_id, booth_no, status,
|
||||
held_by, held_until, area_m2, created_by)
|
||||
VALUES
|
||||
(#{id}, #{tenantId}, #{eventId}, #{hallId}, #{boothId}, #{boothNo}, 'held',
|
||||
#{userId}, now() + make_interval(mins => #{ttlMinutes}), CAST(#{areaM2} AS numeric), #{userId})
|
||||
ON CONFLICT (event_id, booth_id) DO UPDATE
|
||||
SET status = 'held',
|
||||
held_by = #{userId},
|
||||
held_until = now() + make_interval(mins => #{ttlMinutes}),
|
||||
hall_id = EXCLUDED.hall_id,
|
||||
booth_no = EXCLUDED.booth_no,
|
||||
updated_at = now()
|
||||
WHERE booth_sale.status = 'available'
|
||||
OR (booth_sale.status = 'held'
|
||||
AND (booth_sale.held_until IS NULL OR booth_sale.held_until < now()))
|
||||
OR (booth_sale.status = 'held' AND booth_sale.held_by = #{userId})
|
||||
""")
|
||||
int holdBooth(Map<String, Object> p);
|
||||
|
||||
/**
|
||||
* 구매 확정(hold→sold) — 본인이 유효 hold(만료 전) 중일 때만 전이. 금액은 서버 재계산.
|
||||
* 0행이면 hold 만료·미보유·이미 판매 → 서비스가 409.
|
||||
*/
|
||||
@Update("""
|
||||
UPDATE booth_sale
|
||||
SET status = 'sold',
|
||||
sold_to_company = #{company},
|
||||
sold_by = #{userId},
|
||||
sold_at = now(),
|
||||
unit_price = COALESCE(unit_price, #{price}),
|
||||
held_until = NULL,
|
||||
updated_at = now()
|
||||
WHERE tenant_id = #{tenantId}
|
||||
AND event_id = #{eventId}
|
||||
AND booth_id = #{boothId}
|
||||
AND status = 'held'
|
||||
AND held_by = #{userId}
|
||||
AND (held_until IS NULL OR held_until >= now())
|
||||
""")
|
||||
int purchaseBooth(Map<String, Object> p);
|
||||
|
||||
/** 본인 hold 해제(available 복원). 타인·판매건 미영향. */
|
||||
@Update("""
|
||||
UPDATE booth_sale
|
||||
SET status = 'available', held_by = NULL, held_until = NULL, updated_at = now()
|
||||
WHERE tenant_id = #{tenantId} AND event_id = #{eventId} AND booth_id = #{boothId}
|
||||
AND status = 'held' AND held_by = #{userId}
|
||||
""")
|
||||
int releaseHold(Map<String, Object> p);
|
||||
|
||||
/**
|
||||
* 주최자 blocked 전환(available/held → blocked). sold 는 차단 불가(WHERE status <> 'sold').
|
||||
* 행이 없으면 신규 blocked 생성(booth 인벤토리에 상태를 확정). 0행이면 sold 충돌 → 409.
|
||||
*/
|
||||
@Insert("""
|
||||
INSERT INTO booth_sale
|
||||
(id, tenant_id, event_id, hall_id, booth_id, booth_no, status, blocked_reason, area_m2, created_by)
|
||||
VALUES
|
||||
(#{id}, #{tenantId}, #{eventId}, #{hallId}, #{boothId}, #{boothNo}, 'blocked',
|
||||
#{reason}, CAST(#{areaM2} AS numeric), #{userId})
|
||||
ON CONFLICT (event_id, booth_id) DO UPDATE
|
||||
SET status = 'blocked',
|
||||
blocked_reason = #{reason},
|
||||
held_by = NULL,
|
||||
held_until = NULL,
|
||||
updated_at = now()
|
||||
WHERE booth_sale.status <> 'sold'
|
||||
""")
|
||||
int blockBooth(Map<String, Object> p);
|
||||
|
||||
/** 주최자 blocked 해제(available 복원). blocked 아닌 건 미영향. */
|
||||
@Update("""
|
||||
UPDATE booth_sale
|
||||
SET status = 'available', blocked_reason = NULL, updated_at = now()
|
||||
WHERE tenant_id = #{tenantId} AND event_id = #{eventId} AND booth_id = #{boothId}
|
||||
AND status = 'blocked'
|
||||
""")
|
||||
int unblockBooth(Map<String, Object> p);
|
||||
|
||||
/**
|
||||
* 주최자 판매가 override(원, null=파생 복원). 행 없으면 available 로 생성 후 가격 설정.
|
||||
*/
|
||||
@Insert("""
|
||||
INSERT INTO booth_sale
|
||||
(id, tenant_id, event_id, hall_id, booth_id, booth_no, status, unit_price, area_m2, created_by)
|
||||
VALUES
|
||||
(#{id}, #{tenantId}, #{eventId}, #{hallId}, #{boothId}, #{boothNo}, 'available',
|
||||
#{unitPrice}, CAST(#{areaM2} AS numeric), #{userId})
|
||||
ON CONFLICT (event_id, booth_id) DO UPDATE
|
||||
SET unit_price = #{unitPrice}, updated_at = now()
|
||||
""")
|
||||
int setPrice(Map<String, Object> p);
|
||||
}
|
||||
@ -0,0 +1,321 @@
|
||||
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 com.zioinfo.kintex.settlement.dto.SettlementDtos.GenerateResult;
|
||||
import com.zioinfo.kintex.tenant.TenantContext;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* F005/F006 부스 판매 인벤토리·온라인 셀프 판매 서비스.
|
||||
*
|
||||
* <p><b>판매 상태 오버레이</b>: 기존 M2 booth(좌표, read-only) 위에 판매 상태(available|held|sold|blocked)를
|
||||
* 얹는다(booth_sale). M2 스키마/데이터는 불변 — 이 서비스는 booth 좌표를 <b>읽기만</b> 한다.
|
||||
*
|
||||
* <p><b>가격은 서버 권위</b>: 기본 = 판매면적(ST_Area) × 요율(rates-v1.0 exhibitionHallPerM2). 주최자 override 가능.
|
||||
*
|
||||
* <p><b>동시성</b>: hold/purchase 는 매퍼의 원자 UPDATE(WHERE status 조건)로 전이한다. 같은 부스 이중 hold 는
|
||||
* 409. hold 만료는 조회 시 lazy(SQL CASE)로 available 강등된다.
|
||||
*
|
||||
* <p><b>권한</b>: 인벤토리 조회·hold·구매는 행사 멤버(참가업체 포함). blocked/가격 관리·정산 연계는 매니저 이상
|
||||
* (주최자 또는 홀매니저). 참가업체 시점 인벤토리는 available(+ 본인 hold)만 노출한다.
|
||||
*
|
||||
* <p><b>M9 정산 연계</b>: 구매 확정은 booth_sale 에 권위 판매가를 기록한다. 청구서 생성은 매니저가
|
||||
* {@link #settle}(→ {@link SettlementService#generate})로 행사 참가업체별 집계를 트리거한다 —
|
||||
* settlement 공개 서비스 메서드 <b>호출/생성만</b>, 내부 수정 없음.
|
||||
*/
|
||||
@Service
|
||||
public class BoothSalesService {
|
||||
|
||||
/** hold 유지 시간(분) — 서버 고정 TTL. 경과 시 lazy available. */
|
||||
private static final int HOLD_TTL_MINUTES = 15;
|
||||
|
||||
private static final String RULESET_VERSION = "rates-v1.0";
|
||||
|
||||
private final BoothSalesMapper mapper;
|
||||
private final SettlementService settlementService;
|
||||
|
||||
public BoothSalesService(BoothSalesMapper mapper, SettlementService settlementService) {
|
||||
this.mapper = mapper;
|
||||
this.settlementService = settlementService;
|
||||
}
|
||||
|
||||
// ── 인벤토리 조회 ────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* 행사(+선택 홀)의 부스 판매 인벤토리. 참가업체 시점은 available(+본인 hold)만, 매니저는 전 상태.
|
||||
*/
|
||||
public InventoryResponse inventory(String eventId, String hallId, KintexPrincipal principal) {
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
boolean canManage = isManager(principal, eventId);
|
||||
String userId = principal == null ? null : principal.userId();
|
||||
long rate = ratePerM2();
|
||||
|
||||
List<Map<String, Object>> rows = mapper.findInventory(tenant, eventId, blankToNull(hallId));
|
||||
List<BoothSaleUnit> units = new ArrayList<>();
|
||||
int available = 0, held = 0, sold = 0, blocked = 0;
|
||||
|
||||
for (Map<String, Object> r : rows) {
|
||||
String status = str(r.get("status"));
|
||||
boolean heldByMe = userId != null && userId.equals(str(r.get("heldBy")));
|
||||
|
||||
switch (status == null ? "available" : status) {
|
||||
case "held" -> held++;
|
||||
case "sold" -> sold++;
|
||||
case "blocked" -> blocked++;
|
||||
default -> available++;
|
||||
}
|
||||
|
||||
// 참가업체 시점: available 또는 본인이 hold 중인 부스만 노출(구매 진행 UX).
|
||||
if (!canManage) {
|
||||
boolean visible = "available".equals(status) || ("held".equals(status) && heldByMe);
|
||||
if (!visible) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
units.add(toUnit(r, rate, heldByMe));
|
||||
}
|
||||
|
||||
return new InventoryResponse(eventId, blankToNull(hallId), RULESET_VERSION, rate,
|
||||
available, held, sold, blocked, canManage, HOLD_TTL_MINUTES, units);
|
||||
}
|
||||
|
||||
// ── hold (참가업체 셀프 선택) ──────────────────────────────────────────────────
|
||||
@Transactional
|
||||
public BoothSaleResult hold(HoldRequest req, KintexPrincipal principal) {
|
||||
requireMember(principal, req.eventId());
|
||||
Map<String, Object> booth = requireBooth(req.eventId(), req.boothId());
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
|
||||
Map<String, Object> p = base(tenant, req.eventId(), req.boothId(), booth, principal);
|
||||
p.put("ttlMinutes", HOLD_TTL_MINUTES);
|
||||
int n = mapper.holdBooth(p);
|
||||
if (n == 0) {
|
||||
// sold/blocked/타인 활성 hold → 이중 hold 충돌.
|
||||
throw new ApiException(ErrorCode.CONFLICT, "이미 다른 사용자가 선점했거나 판매/차단된 부스입니다.");
|
||||
}
|
||||
return result("held", req.eventId(), req.boothId(), principal);
|
||||
}
|
||||
|
||||
// ── 구매 확정 (hold→sold) ──────────────────────────────────────────────────────
|
||||
@Transactional
|
||||
public BoothSaleResult purchase(PurchaseRequest req, KintexPrincipal principal) {
|
||||
requireMember(principal, req.eventId());
|
||||
Map<String, Object> booth = requireBooth(req.eventId(), req.boothId());
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
|
||||
long price = computePrice(dbl(booth.get("areaM2")), ratePerM2());
|
||||
String company = firstNonBlank(req.company(), str(booth.get("assignedCompany")),
|
||||
principal == null ? null : principal.displayName());
|
||||
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("tenantId", tenant);
|
||||
p.put("eventId", req.eventId());
|
||||
p.put("boothId", req.boothId());
|
||||
p.put("userId", principal == null ? null : principal.userId());
|
||||
p.put("company", company);
|
||||
p.put("price", price);
|
||||
int n = mapper.purchaseBooth(p);
|
||||
if (n == 0) {
|
||||
throw new ApiException(ErrorCode.CONFLICT,
|
||||
"구매를 확정할 수 없습니다 — hold 가 만료되었거나 다른 사용자가 선점한 부스입니다.");
|
||||
}
|
||||
return result("purchased", req.eventId(), req.boothId(), principal);
|
||||
}
|
||||
|
||||
/** 본인 hold 해제(available 복원). */
|
||||
@Transactional
|
||||
public BoothSaleResult release(HoldRequest req, KintexPrincipal principal) {
|
||||
requireMember(principal, req.eventId());
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("tenantId", tenant);
|
||||
p.put("eventId", req.eventId());
|
||||
p.put("boothId", req.boothId());
|
||||
p.put("userId", principal == null ? null : principal.userId());
|
||||
mapper.releaseHold(p);
|
||||
return result("released", req.eventId(), req.boothId(), principal);
|
||||
}
|
||||
|
||||
// ── 주최자: blocked/available 전환 ─────────────────────────────────────────────
|
||||
@Transactional
|
||||
public BoothSaleResult setBlocked(BlockRequest req, KintexPrincipal principal) {
|
||||
requireManager(principal, req.eventId());
|
||||
Map<String, Object> booth = requireBooth(req.eventId(), req.boothId());
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
Map<String, Object> p = base(tenant, req.eventId(), req.boothId(), booth, principal);
|
||||
|
||||
if (req.blocked()) {
|
||||
p.put("reason", blankToNull(req.reason()));
|
||||
int n = mapper.blockBooth(p);
|
||||
if (n == 0) {
|
||||
throw new ApiException(ErrorCode.CONFLICT, "이미 판매된 부스는 차단할 수 없습니다.");
|
||||
}
|
||||
return result("blocked", req.eventId(), req.boothId(), principal);
|
||||
}
|
||||
mapper.unblockBooth(p);
|
||||
return result("unblocked", req.eventId(), req.boothId(), principal);
|
||||
}
|
||||
|
||||
// ── 주최자: 판매가 override ────────────────────────────────────────────────────
|
||||
@Transactional
|
||||
public BoothSaleResult setPrice(PriceRequest req, KintexPrincipal principal) {
|
||||
requireManager(principal, req.eventId());
|
||||
Map<String, Object> booth = requireBooth(req.eventId(), req.boothId());
|
||||
if (req.unitPrice() != null && req.unitPrice() < 0) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "판매가는 0 이상이어야 합니다.");
|
||||
}
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
Map<String, Object> p = base(tenant, req.eventId(), req.boothId(), booth, principal);
|
||||
p.put("unitPrice", req.unitPrice());
|
||||
mapper.setPrice(p);
|
||||
return result("priced", req.eventId(), req.boothId(), principal);
|
||||
}
|
||||
|
||||
// ── M9 정산 청구 생성 연계(매니저) ────────────────────────────────────────────
|
||||
/**
|
||||
* 판매 확정분에 대한 M9 청구서 집계를 트리거한다(참가업체별 임대료+유틸리티).
|
||||
* settlement 공개 서비스 메서드 호출(생성만) — 내부 수정 없음. 매니저 이상만.
|
||||
*/
|
||||
public GenerateResult settle(String eventId, KintexPrincipal principal) {
|
||||
requireManager(principal, eventId);
|
||||
return settlementService.generate(eventId, principal);
|
||||
}
|
||||
|
||||
// ── 헬퍼 ─────────────────────────────────────────────────────────────────────
|
||||
private BoothSaleResult result(String action, String eventId, String boothId, KintexPrincipal principal) {
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
Map<String, Object> r = mapper.findUnit(tenant, eventId, boothId);
|
||||
if (r == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "부스를 찾을 수 없습니다.");
|
||||
}
|
||||
boolean heldByMe = principal != null && principal.userId() != null
|
||||
&& principal.userId().equals(str(r.get("heldBy")));
|
||||
return new BoothSaleResult(action, toUnit(r, ratePerM2(), heldByMe));
|
||||
}
|
||||
|
||||
private BoothSaleUnit toUnit(Map<String, Object> r, long rate, boolean heldByMe) {
|
||||
double area = dbl(r.get("areaM2"));
|
||||
Long override = lngOrNull(r.get("unitPrice"));
|
||||
long price = override != null ? override : computePrice(area, rate);
|
||||
return new BoothSaleUnit(
|
||||
str(r.get("boothId")),
|
||||
str(r.get("boothNo")),
|
||||
str(r.get("boothType")),
|
||||
str(r.get("hallId")),
|
||||
str(r.get("hallLabel")),
|
||||
area,
|
||||
str(r.get("status")),
|
||||
price,
|
||||
heldByMe,
|
||||
str(r.get("heldUntil")),
|
||||
str(r.get("soldToCompany")));
|
||||
}
|
||||
|
||||
/** 전이 매퍼 파라미터 공통(부스 스냅샷 포함). */
|
||||
private Map<String, Object> base(String tenant, String eventId, String boothId,
|
||||
Map<String, Object> booth, KintexPrincipal principal) {
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("id", "bs-" + UUID.randomUUID().toString().substring(0, 12));
|
||||
p.put("tenantId", tenant);
|
||||
p.put("eventId", eventId);
|
||||
p.put("boothId", boothId);
|
||||
p.put("hallId", str(booth.get("hallId")));
|
||||
p.put("boothNo", str(booth.get("boothNo")));
|
||||
p.put("areaM2", dbl(booth.get("areaM2")));
|
||||
p.put("userId", principal == null ? null : principal.userId());
|
||||
return p;
|
||||
}
|
||||
|
||||
private Map<String, Object> requireBooth(String eventId, String boothId) {
|
||||
Map<String, Object> booth = mapper.findBooth(eventId, boothId);
|
||||
if (booth == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "부스를 찾을 수 없습니다.");
|
||||
}
|
||||
return booth;
|
||||
}
|
||||
|
||||
/** 판매가 = round(면적 × 요율/㎡). 요율 미로드 시 0(면적만 노출). */
|
||||
private static long computePrice(double areaM2, long ratePerM2) {
|
||||
return Math.round(areaM2 * ratePerM2);
|
||||
}
|
||||
|
||||
private long ratePerM2() {
|
||||
Double r = mapper.findRatePerM2();
|
||||
return r == null ? 0L : Math.round(r);
|
||||
}
|
||||
|
||||
private boolean isManager(KintexPrincipal principal, String eventId) {
|
||||
if (principal == null) {
|
||||
return false;
|
||||
}
|
||||
if (principal.hallManager()) {
|
||||
return true;
|
||||
}
|
||||
EventRole role = principal.roleFor(eventId);
|
||||
return role == EventRole.ORGANIZER || role == EventRole.HALL_MANAGER;
|
||||
}
|
||||
|
||||
private void requireManager(KintexPrincipal principal, String eventId) {
|
||||
if (!isManager(principal, eventId)) {
|
||||
throw new ApiException(ErrorCode.FORBIDDEN, "부스 판매 관리는 주최자/홀매니저만 수행할 수 있습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/** 행사 멤버(참가업체 포함) 확인 — 비멤버는 403. */
|
||||
private void requireMember(KintexPrincipal principal, String eventId) {
|
||||
if (principal == null) {
|
||||
throw new ApiException(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
if (!principal.hasAccess(eventId)) {
|
||||
throw new ApiException(ErrorCode.FORBIDDEN);
|
||||
}
|
||||
}
|
||||
|
||||
private static String blankToNull(String s) {
|
||||
return (s == null || s.isBlank()) ? null : s;
|
||||
}
|
||||
|
||||
private static String firstNonBlank(String... vals) {
|
||||
for (String v : vals) {
|
||||
if (v != null && !v.isBlank()) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private static Long lngOrNull(Object o) {
|
||||
if (o == null) return null;
|
||||
if (o instanceof Number n) return n.longValue();
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(o));
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
package com.zioinfo.kintex.boothsales.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* F005/F006 부스 판매 인벤토리·셀프 판매 DTO 묶음.
|
||||
* <p>금액은 모두 서버 계산 권위(원 단위, long). 민감정보·PII 미포함(표시용 참가업체명만).
|
||||
* {@code status} 는 서버가 파생한 <b>실효 상태</b>(만료 hold 는 available 로 강등된 값)이다.
|
||||
*/
|
||||
public final class BoothSalesDtos {
|
||||
|
||||
private BoothSalesDtos() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 판매 인벤토리 단위 — M2 booth(좌표) + 판매 오버레이 병합.
|
||||
*
|
||||
* @param status available|held|sold|blocked (실효 상태 — 만료 hold 는 available)
|
||||
* @param unitPrice 서버 권위 판매가(원) = override 또는 면적×요율 파생
|
||||
* @param heldByMe 현재 요청자가 hold 중인지(셀프 구매 버튼 노출용) — 서버 판정
|
||||
* @param heldUntil hold 만료 ISO 8601 또는 null
|
||||
*/
|
||||
public record BoothSaleUnit(
|
||||
String boothId,
|
||||
String boothNo,
|
||||
String boothType,
|
||||
String hallId,
|
||||
String hallLabel,
|
||||
double areaM2,
|
||||
String status,
|
||||
long unitPrice,
|
||||
boolean heldByMe,
|
||||
String heldUntil,
|
||||
String soldToCompany
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 인벤토리 조회 응답 — 단위 목록 + 상태 요약 + 요율 근거.
|
||||
* 참가업체 시점은 available 만, 주최자/매니저 시점은 전 상태 포함(canManage=true).
|
||||
*/
|
||||
public record InventoryResponse(
|
||||
String eventId,
|
||||
String hallId,
|
||||
String rulesetVersion,
|
||||
long ratePerM2,
|
||||
int available,
|
||||
int held,
|
||||
int sold,
|
||||
int blocked,
|
||||
boolean canManage,
|
||||
int holdTtlMinutes,
|
||||
List<BoothSaleUnit> units
|
||||
) {
|
||||
}
|
||||
|
||||
/** 부스 hold 요청(참가업체 셀프 선택) — TTL 은 서버 고정. */
|
||||
public record HoldRequest(
|
||||
@NotBlank String eventId,
|
||||
@NotBlank String boothId
|
||||
) {
|
||||
}
|
||||
|
||||
/** 구매 확정 요청(hold→sold) — 금액은 서버 재계산(요청 금액 무시). */
|
||||
public record PurchaseRequest(
|
||||
@NotBlank String eventId,
|
||||
@NotBlank String boothId,
|
||||
String company // 표시용 참가업체명(선택) — 미지정 시 booth.assigned_company_name
|
||||
) {
|
||||
}
|
||||
|
||||
/** 주최자 blocked/available 전환 요청. */
|
||||
public record BlockRequest(
|
||||
@NotBlank String eventId,
|
||||
@NotBlank String boothId,
|
||||
boolean blocked,
|
||||
String reason
|
||||
) {
|
||||
}
|
||||
|
||||
/** 주최자 판매가 override 요청(원, null=면적×요율 파생 복원). */
|
||||
public record PriceRequest(
|
||||
@NotBlank String eventId,
|
||||
@NotBlank String boothId,
|
||||
Long unitPrice
|
||||
) {
|
||||
}
|
||||
|
||||
/** 상태 전이 결과(hold/purchase/block/price 공통) — 갱신된 단위 반환. */
|
||||
public record BoothSaleResult(
|
||||
String action, // held|purchased|released|blocked|unblocked|priced
|
||||
BoothSaleUnit unit
|
||||
) {
|
||||
}
|
||||
}
|
||||
@ -123,15 +123,16 @@ public class CmsContentController {
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 자동 번역(초벌) — 인터페이스만. AiTextRouter 미배선 시 501 NOT_IMPLEMENTED.
|
||||
* 프론트(SCR-37)는 501을 degraded("준비 중")로 처리한다.
|
||||
* AI 자동 번역(초벌) — AiTextRouter 로 원문→대상언어 번역, trans_status='ai' 저장(매니저↑).
|
||||
* AI 미가용/실패 시 503(AI_UNAVAILABLE) — 프론트는 "일시 불가"로 처리(환각 없이 저장 안 함).
|
||||
*/
|
||||
@Audited(action = "CMS_AI_TRANSLATE", targetType = "cms_translation")
|
||||
@PostMapping("/{id}/translations/ai")
|
||||
public ApiResponse<CmsTranslationDto> aiTranslate(
|
||||
@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestParam String lang) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.aiTranslate(id, lang));
|
||||
return ApiResponse.ok(service.aiTranslate(principal, id, lang));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.*;
|
||||
import com.zioinfo.kintex.common.PageResponse;
|
||||
@ -41,14 +43,19 @@ public class CmsService {
|
||||
"image/png", "png", "image/jpeg", "jpg", "image/webp", "webp", "image/gif", "gif");
|
||||
private static final Set<String> ALLOWED_EXTS = Set.of("png", "jpg", "jpeg", "webp", "gif");
|
||||
|
||||
private static final Map<String, String> LANG_NAMES = Map.of(
|
||||
"ko", "한국어(Korean)", "en", "영어(English)", "zh", "중국어 간체(Simplified Chinese)", "ja", "일본어(Japanese)");
|
||||
|
||||
private final CmsMapper mapper;
|
||||
private final SystemAccessGuard scope;
|
||||
private final AiTextRouter aiRouter;
|
||||
private final Path uploadRoot;
|
||||
|
||||
public CmsService(CmsMapper mapper, SystemAccessGuard scope,
|
||||
public CmsService(CmsMapper mapper, SystemAccessGuard scope, AiTextRouter aiRouter,
|
||||
@Value("${kintex.upload.dir:./data/uploads}") String uploadDir) {
|
||||
this.mapper = mapper;
|
||||
this.scope = scope;
|
||||
this.aiRouter = aiRouter;
|
||||
this.uploadRoot = Paths.get(uploadDir).toAbsolutePath().normalize();
|
||||
}
|
||||
|
||||
@ -226,19 +233,118 @@ public class CmsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 자동 번역 — 인터페이스만(AiTextRouter/Claude 배선 지점). 미설정 시 501 NOT_IMPLEMENTED.
|
||||
* <p>프론트는 501을 degraded("준비 중")로 처리한다. 배선되면 이 지점에서 원문→대상언어 초벌(trans_status=ai)을 upsert.
|
||||
* AI 자동 번역(초벌) — {@link AiTextRouter}로 원문(KO)→대상언어 번역 후 trans_status='ai' 로 upsert(F100/F069).
|
||||
* 매니저 이상(주최자·관리자) 권한. AI 산출물이므로 프론트는 AiLabel 로 표기하고 사람 검수를 거친다.
|
||||
*
|
||||
* <p><b>보안</b>: 본문은 {@link HtmlSanitizer}로 sanitize 후 저장(XSS N2). <b>폴백</b>: AI 미가용/실패/파싱 실패 시
|
||||
* 규칙 대체가 불가능한 작업이므로 {@code AI_UNAVAILABLE}(503)로 정직하게 반환한다(환각 없이 저장하지 않음).
|
||||
*/
|
||||
public CmsTranslationDto aiTranslate(String contentId, String lang) {
|
||||
if (mapper.findContentById(contentId) == null) {
|
||||
@Transactional
|
||||
public CmsTranslationDto aiTranslate(KintexPrincipal principal, String contentId, String lang) {
|
||||
scope.requireManager(principal);
|
||||
Map<String, Object> content = mapper.findContentById(contentId);
|
||||
if (content == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
if (!LANGS.contains(lang == null ? "" : lang.trim())) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "지원하지 않는 언어입니다: " + lang);
|
||||
String target = lang == null ? "" : lang.trim();
|
||||
if (!LANGS.contains(target) || "ko".equals(target)) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "지원하지 않는 대상 언어입니다: " + lang);
|
||||
}
|
||||
// AiTextRouter 미배선 — 표준 501(§ AI 설정형 전환 이전).
|
||||
throw new ApiException(ErrorCode.NOT_IMPLEMENTED,
|
||||
"AI 자동 번역은 아직 배선되지 않았습니다(AiTextRouter/Claude 연동 대기).");
|
||||
String srcTitle = str(content.get("title"));
|
||||
String srcBody = str(content.get("body"));
|
||||
|
||||
AiResult ai = aiRouter.generate(buildTranslatePrompt(srcTitle, srcBody, target), 2048);
|
||||
if (!ai.usable()) {
|
||||
throw new ApiException(ErrorCode.AI_UNAVAILABLE);
|
||||
}
|
||||
String[] parsed = parseTranslation(ai.text());
|
||||
if (parsed == null) {
|
||||
throw new ApiException(ErrorCode.AI_UNAVAILABLE);
|
||||
}
|
||||
Map<String, Object> pm = new HashMap<>();
|
||||
pm.put("id", "tr-" + UUID.randomUUID().toString().substring(0, 12));
|
||||
pm.put("contentId", contentId);
|
||||
pm.put("lang", target);
|
||||
pm.put("title", parsed[0]);
|
||||
pm.put("body", HtmlSanitizer.sanitize(parsed[1]));
|
||||
pm.put("transStatus", "ai");
|
||||
mapper.upsertTranslation(pm);
|
||||
return translations(contentId).stream()
|
||||
.filter(t -> target.equals(t.lang()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ApiException(ErrorCode.INTERNAL));
|
||||
}
|
||||
|
||||
/** 번역 프롬프트 — JSON 전용 출력(제목·본문). HTML 보존·의미 왜곡 금지 지시(환각 억제). */
|
||||
private static String buildTranslatePrompt(String title, String body, String targetLang) {
|
||||
String langName = LANG_NAMES.getOrDefault(targetLang, targetLang);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("당신은 전시회 홍보 콘텐츠 전문 번역가다. 아래 한국어 콘텐츠를 ").append(langName)
|
||||
.append("로 자연스럽게 번역하라.\n");
|
||||
sb.append("규칙: 의미를 왜곡하거나 없는 내용을 추가하지 마라. HTML 태그·구조는 그대로 보존하고 태그 안 텍스트만 번역하라.\n");
|
||||
sb.append("오직 아래 JSON 형식으로만 답하라(설명·마크다운·코드펜스 금지):\n");
|
||||
sb.append("{\"title\": \"<번역된 제목>\", \"body\": \"<번역된 본문(HTML 보존)>\"}\n\n");
|
||||
sb.append("[원문 제목]\n").append(title == null ? "" : title).append("\n\n");
|
||||
sb.append("[원문 본문]\n").append(body == null ? "" : body).append('\n');
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** AI 응답(JSON)에서 [title, body] 추출. 실패 시 null. */
|
||||
private static String[] parseTranslation(String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String json = extractJsonObject(text);
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
com.fasterxml.jackson.databind.JsonNode root =
|
||||
new com.fasterxml.jackson.databind.ObjectMapper().readTree(json);
|
||||
com.fasterxml.jackson.databind.JsonNode t = root.get("title");
|
||||
com.fasterxml.jackson.databind.JsonNode b = root.get("body");
|
||||
String title = t == null || t.isNull() ? "" : t.asText("");
|
||||
String body = b == null || b.isNull() ? "" : b.asText("");
|
||||
if (title.isBlank() && body.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return new String[]{title, body};
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 텍스트에서 첫 균형 JSON 객체 추출(코드펜스·잡음 방어). */
|
||||
private static String extractJsonObject(String text) {
|
||||
int start = text.indexOf('{');
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
int depth = 0;
|
||||
boolean inStr = false;
|
||||
boolean esc = false;
|
||||
for (int i = start; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
if (inStr) {
|
||||
if (esc) {
|
||||
esc = false;
|
||||
} else if (c == '\\') {
|
||||
esc = true;
|
||||
} else if (c == '"') {
|
||||
inStr = false;
|
||||
}
|
||||
} else if (c == '"') {
|
||||
inStr = true;
|
||||
} else if (c == '{') {
|
||||
depth++;
|
||||
} else if (c == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return text.substring(start, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 미디어 라이브러리 ──────────────────────────────────────────────────────
|
||||
|
||||
@ -15,6 +15,7 @@ public enum ErrorCode {
|
||||
NOT_REGISTERED_COMPANY(HttpStatus.FORBIDDEN, "킨텍스 등록업체만 참여할 수 있습니다."),
|
||||
EMAIL_TAKEN(HttpStatus.CONFLICT, "이미 사용 중인 이메일입니다."),
|
||||
NOT_IMPLEMENTED(HttpStatus.NOT_IMPLEMENTED, "아직 구현되지 않은 기능입니다."),
|
||||
AI_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "AI 기능을 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요."),
|
||||
ACCOUNT_LOCKED(HttpStatus.LOCKED, "로그인 시도가 일시적으로 제한되었습니다."),
|
||||
OTP_REQUIRED(HttpStatus.UNAUTHORIZED, "2차 인증이 필요합니다."),
|
||||
OTP_INVALID(HttpStatus.UNAUTHORIZED, "OTP 코드가 올바르지 않습니다."),
|
||||
|
||||
@ -42,7 +42,9 @@ public class SecurityConfig {
|
||||
"/api/auth/register", "/api/auth/password/forgot",
|
||||
"/api/auth/password/reset",
|
||||
// 공개 콘텐츠(로그인 슬라이드 등) + 업로드 정적 파일
|
||||
"/api/public/**", "/uploads/**").permitAll()
|
||||
"/api/public/**", "/uploads/**",
|
||||
// F099 인바운드 웹훅 수신(인증 없음 — 토큰 + HMAC 서명 이중 검증으로 방어)
|
||||
"/api/webhooks/in/**").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.exceptionHandling(eh -> eh.authenticationEntryPoint((req, res, ex) -> {
|
||||
// 스택트레이스 미노출 — 표준 봉투로 401만 반환.
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
package com.zioinfo.kintex.home;
|
||||
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.home.dto.HomeDtos.HallCenter;
|
||||
import com.zioinfo.kintex.home.dto.HomeDtos.ShowcaseItem;
|
||||
import com.zioinfo.kintex.home.dto.HomeDtos.SummaryResponse;
|
||||
import com.zioinfo.kintex.home.dto.HomeDtos.TodoResponse;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SCR-HOME(로그인 후 메인 홈) 집계 API — 전 역할 공통(인증 필수). 읽기 전용.
|
||||
*
|
||||
* <p>영역별 독립 엔드포인트 → 프론트가 영역별 스켈레톤/에러 처리(한 영역 실패가 홈 전체를 막지 않음, design.md).
|
||||
* 모든 응답은 {@code ApiResponse<T>} 봉투. PII·서버 자격정보 미포함, 스택트레이스 미노출(전역 핸들러).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/home")
|
||||
public class HomeController {
|
||||
|
||||
private final HomeService service;
|
||||
|
||||
public HomeController(HomeService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
/** GET /api/home/todos — 역할 기반 내 할 일(기한 임박순 최대 6건 + total). */
|
||||
@GetMapping("/todos")
|
||||
public ApiResponse<TodoResponse> todos(@AuthenticationPrincipal KintexPrincipal principal) {
|
||||
return ApiResponse.ok(service.getTodos(principal));
|
||||
}
|
||||
|
||||
/** GET /api/home/showcase?tab=ongoing|thisMonth|nextMonth — 진행 중·다가오는 전시(최대 12건). */
|
||||
@GetMapping("/showcase")
|
||||
public ApiResponse<List<ShowcaseItem>> showcase(
|
||||
@RequestParam(name = "tab", required = false, defaultValue = "ongoing") String tab) {
|
||||
return ApiResponse.ok(service.getShowcase(tab));
|
||||
}
|
||||
|
||||
/** GET /api/home/hall-centers — 캘린더/리스트 세그먼트 필터의 동적 전시장 옵션(하드코딩 금지). */
|
||||
@GetMapping("/hall-centers")
|
||||
public ApiResponse<List<HallCenter>> hallCenters() {
|
||||
return ApiResponse.ok(service.getHallCenters());
|
||||
}
|
||||
|
||||
/** GET /api/home/summary — 역할별 KPI 4타일. */
|
||||
@GetMapping("/summary")
|
||||
public ApiResponse<SummaryResponse> summary(@AuthenticationPrincipal KintexPrincipal principal) {
|
||||
return ApiResponse.ok(service.getSummary(principal));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,243 @@
|
||||
package com.zioinfo.kintex.home;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* SCR-HOME 집계 매퍼 — 읽기 전용. 타 도메인 테이블(approval·required_document·auction·invoice·event·hall·
|
||||
* visitor_registration·event_member)을 직접 SELECT 하며, 타 패키지의 매퍼/서비스는 수정하지 않는다.
|
||||
*
|
||||
* <p><b>역할 자기선별</b>: 각 할 일/요약 서브쿼리는 {@code event_member} 멤버십 EXISTS 로 스스로 스코프된다.
|
||||
* 홀매니저·관리자(감독 권한, {@code broad=true})는 멤버십 게이트를 우회해 테넌트 전체를 본다.
|
||||
*
|
||||
* <p><b>테넌트</b>: {@code TenantContext.currentTenantId()}(대문자 'KINTEX', V31 정규화)로 {@code event.tenant_id}
|
||||
* 필터. 파라미터가 아닌 컨텍스트를 신뢰 원천으로 스코프한다(§8-2).
|
||||
*
|
||||
* <p><b>파싱 규칙</b>: 본 매퍼는 전부 <b>정적 @Select</b>(동적 {@code <script>} 미사용)이므로 비교연산 {@code <=,>}
|
||||
* 를 그대로 사용해도 XML 파싱 대상이 아니다(MapperAnnotationParseTest 통과). Map 반환 카멜케이스 별칭은
|
||||
* PostgreSQL 소문자 폴딩 회피를 위해 {@code AS "camelCase"} 쌍따옴표를 강제한다.
|
||||
*/
|
||||
@Mapper
|
||||
public interface HomeMapper {
|
||||
|
||||
// ── 내 할 일(todos) — 원천별 개별 조회. 서비스에서 병합·정렬·상위 6건 ───────────────
|
||||
|
||||
/** 결재 대기: 내가 현재 단계 결재자(line_status='CURRENT')인 진행 중 문서. dday 없음(후순위 정렬). */
|
||||
@Select("""
|
||||
SELECT 'approval' AS "type",
|
||||
'결재 대기' AS "title",
|
||||
a.title AS "target",
|
||||
NULL AS "dueDate",
|
||||
NULL AS "dday",
|
||||
'/work/approval' AS "route"
|
||||
FROM approval_line al
|
||||
JOIN approval a ON a.id = al.approval_id
|
||||
WHERE al.approver_id = #{userId}
|
||||
AND al.line_status = 'CURRENT'
|
||||
AND a.status IN ('SUBMITTED', 'IN_PROGRESS')
|
||||
ORDER BY a.submitted_at ASC NULLS LAST
|
||||
LIMIT 10
|
||||
""")
|
||||
List<Map<String, Object>> findApprovalTodos(@Param("userId") String userId);
|
||||
|
||||
/** 서류 마감 임박(M6): 승인 전 필수 서류 중 마감 {@code horizon}(D-7) 이내. 내가 참여한 행사(또는 broad). */
|
||||
@Select("""
|
||||
SELECT 'document' AS "type",
|
||||
'신고서류 제출' AS "title",
|
||||
rd.name AS "target",
|
||||
to_char(rd.due_date, 'YYYY-MM-DD') AS "dueDate",
|
||||
(rd.due_date - CURRENT_DATE) AS "dday",
|
||||
'/docs' AS "route"
|
||||
FROM required_document rd
|
||||
JOIN event e ON e.id = rd.event_id
|
||||
WHERE e.tenant_id = #{tenantId}
|
||||
AND rd.status NOT IN ('approved')
|
||||
AND rd.due_date IS NOT NULL
|
||||
AND rd.due_date <= CAST(#{horizon} AS date)
|
||||
AND (#{broad} OR EXISTS (
|
||||
SELECT 1 FROM event_member m
|
||||
WHERE m.event_id = e.id AND m.user_id = #{userId}))
|
||||
ORDER BY rd.due_date ASC
|
||||
LIMIT 10
|
||||
""")
|
||||
List<Map<String, Object>> findDocumentTodos(@Param("userId") String userId,
|
||||
@Param("tenantId") String tenantId,
|
||||
@Param("horizon") String horizon,
|
||||
@Param("broad") boolean broad);
|
||||
|
||||
/** 옥션 낙찰 선정 대기: 마감 경과 + 낙찰(award) 미확정. 내가 주최한 행사(또는 broad). */
|
||||
@Select("""
|
||||
SELECT 'auction' AS "type",
|
||||
'옥션 낙찰 선정' AS "title",
|
||||
a.title AS "target",
|
||||
to_char(a.deadline, 'YYYY-MM-DD') AS "dueDate",
|
||||
(a.deadline::date - CURRENT_DATE) AS "dday",
|
||||
('/auctions/' || a.id || '/award') AS "route"
|
||||
FROM auction a
|
||||
JOIN event e ON e.id = a.event_id
|
||||
WHERE e.tenant_id = #{tenantId}
|
||||
AND CURRENT_TIMESTAMP > a.deadline
|
||||
AND NOT EXISTS (SELECT 1 FROM award aw WHERE aw.auction_id = a.id)
|
||||
AND (#{broad} OR EXISTS (
|
||||
SELECT 1 FROM event_member m
|
||||
WHERE m.event_id = e.id AND m.user_id = #{userId} AND m.role_code = 'ORGANIZER'))
|
||||
ORDER BY a.deadline ASC
|
||||
LIMIT 10
|
||||
""")
|
||||
List<Map<String, Object>> findAuctionAwardTodos(@Param("userId") String userId,
|
||||
@Param("tenantId") String tenantId,
|
||||
@Param("broad") boolean broad);
|
||||
|
||||
/** 응찰 마감 임박: 마감 D-3 이내 진행 중 옥션 중, 내 소속 등록업체(CONTRACTOR)가 초대된 건. */
|
||||
@Select("""
|
||||
SELECT 'auction' AS "type",
|
||||
'응찰 마감 임박' AS "title",
|
||||
a.title AS "target",
|
||||
to_char(a.deadline, 'YYYY-MM-DD') AS "dueDate",
|
||||
(a.deadline::date - CURRENT_DATE) AS "dday",
|
||||
('/auctions/' || a.id) AS "route"
|
||||
FROM auction a
|
||||
JOIN event e ON e.id = a.event_id
|
||||
WHERE e.tenant_id = #{tenantId}
|
||||
AND a.deadline > CURRENT_TIMESTAMP
|
||||
AND a.deadline <= CURRENT_TIMESTAMP + INTERVAL '3 day'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM auction_invite ai
|
||||
JOIN event_member m ON m.company_id = ai.company_id
|
||||
WHERE ai.auction_id = a.id
|
||||
AND m.user_id = #{userId} AND m.role_code = 'CONTRACTOR')
|
||||
ORDER BY a.deadline ASC
|
||||
LIMIT 10
|
||||
""")
|
||||
List<Map<String, Object>> findAuctionBidTodos(@Param("userId") String userId,
|
||||
@Param("tenantId") String tenantId);
|
||||
|
||||
/** 미수 청구(M9): 미발행/연체 청구서. 내가 주최한 행사(또는 broad). exhibitor_name 은 표시용(민감정보 아님). */
|
||||
@Select("""
|
||||
SELECT 'settlement' AS "type",
|
||||
'미수 청구' AS "title",
|
||||
i.exhibitor_name AS "target",
|
||||
to_char(i.due_date, 'YYYY-MM-DD') AS "dueDate",
|
||||
(i.due_date - CURRENT_DATE) AS "dday",
|
||||
'/settlement' AS "route"
|
||||
FROM invoice i
|
||||
JOIN event e ON e.id = i.event_id
|
||||
WHERE e.tenant_id = #{tenantId}
|
||||
AND i.status IN ('pending', 'overdue')
|
||||
AND (#{broad} OR EXISTS (
|
||||
SELECT 1 FROM event_member m
|
||||
WHERE m.event_id = e.id AND m.user_id = #{userId} AND m.role_code = 'ORGANIZER'))
|
||||
ORDER BY i.due_date ASC NULLS LAST
|
||||
LIMIT 10
|
||||
""")
|
||||
List<Map<String, Object>> findSettlementTodos(@Param("userId") String userId,
|
||||
@Param("tenantId") String tenantId,
|
||||
@Param("broad") boolean broad);
|
||||
|
||||
// ── 쇼케이스(진행 중·다가오는 전시) ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 탭별 전시 8~12건. tab: ongoing(오늘 포함 기간) | thisMonth(당월 개장) | nextMonth(익월 개장).
|
||||
* hallLabel 은 hall_assignment(대표 홀) 우선, 없으면 V23 {@code event.hall_label} 폴백.
|
||||
* posterUrl 은 V32 크롤 적재 컬럼(NULL 이면 프론트가 그라디언트+행사명 폴백 카드 렌더).
|
||||
* status·dday 는 날짜 기준 파생. 정적 @Select 이므로 비교연산 그대로 사용.
|
||||
*/
|
||||
@Select("""
|
||||
SELECT e.id AS "id",
|
||||
e.name AS "name",
|
||||
to_char(e.start_date, 'YYYY-MM-DD') AS "startDate",
|
||||
to_char(e.end_date, 'YYYY-MM-DD') AS "endDate",
|
||||
COALESCE(h.label, e.hall_label) AS "hallLabel",
|
||||
e.poster_url AS "posterUrl",
|
||||
(e.start_date - CURRENT_DATE) AS "dday",
|
||||
CASE WHEN e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE THEN 'ongoing'
|
||||
WHEN e.start_date > CURRENT_DATE THEN 'upcoming'
|
||||
ELSE 'ended' END AS "status"
|
||||
FROM event e
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT ha.hall_id FROM hall_assignment ha
|
||||
WHERE ha.event_id = e.id
|
||||
ORDER BY ha.is_primary DESC
|
||||
LIMIT 1
|
||||
) pa ON true
|
||||
LEFT JOIN hall h ON h.id = pa.hall_id
|
||||
WHERE e.tenant_id = #{tenantId}
|
||||
AND e.start_date IS NOT NULL AND e.end_date IS NOT NULL
|
||||
AND (
|
||||
(#{tab} = 'ongoing'
|
||||
AND e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE)
|
||||
OR (#{tab} = 'thisMonth'
|
||||
AND e.start_date >= date_trunc('month', CURRENT_DATE)::date
|
||||
AND e.start_date < (date_trunc('month', CURRENT_DATE) + INTERVAL '1 month')::date)
|
||||
OR (#{tab} = 'nextMonth'
|
||||
AND e.start_date >= (date_trunc('month', CURRENT_DATE) + INTERVAL '1 month')::date
|
||||
AND e.start_date < (date_trunc('month', CURRENT_DATE) + INTERVAL '2 month')::date)
|
||||
)
|
||||
ORDER BY e.start_date ASC, e.id ASC
|
||||
LIMIT 12
|
||||
""")
|
||||
List<Map<String, Object>> findShowcase(@Param("tenantId") String tenantId,
|
||||
@Param("tab") String tab);
|
||||
|
||||
// ── 전시장(센터) 동적 목록 ────────────────────────────────────────────────────
|
||||
|
||||
/** 홀 마스터의 전시장 구분(exhibition_center) distinct·오름차순. 라벨 조립은 서비스에서 1곳. */
|
||||
@Select("""
|
||||
SELECT DISTINCT exhibition_center AS "center"
|
||||
FROM hall
|
||||
WHERE tenant_id = #{tenantId} AND exhibition_center IS NOT NULL
|
||||
ORDER BY exhibition_center
|
||||
""")
|
||||
List<Map<String, Object>> findHallCenters(@Param("tenantId") String tenantId);
|
||||
|
||||
// ── 역할별 요약 KPI(단일 행 집계) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 요약 KPI 원자 집계(단일 행). 사용자·테넌트·감독여부(broad)로 스코프. 정적 @Select.
|
||||
* 각 서브쿼리는 원천 부재/0건이어도 안전(count=0 · min=NULL).
|
||||
*/
|
||||
@Select("""
|
||||
SELECT
|
||||
(SELECT count(*) FROM event e
|
||||
WHERE e.tenant_id = #{tenantId}
|
||||
AND e.start_date <= CURRENT_DATE AND e.end_date >= CURRENT_DATE) AS "ongoingEvents",
|
||||
(SELECT count(*) FROM visitor_registration v JOIN event e ON e.id = v.event_id
|
||||
WHERE e.tenant_id = #{tenantId}
|
||||
AND v.checkin_at IS NOT NULL AND v.checkin_at::date = CURRENT_DATE) AS "todayCheckins",
|
||||
(SELECT min(e.start_date - CURRENT_DATE) FROM event e
|
||||
WHERE e.tenant_id = #{tenantId} AND e.start_date >= CURRENT_DATE) AS "nearestOpeningDday",
|
||||
(SELECT count(*) FROM approval_line al JOIN approval a ON a.id = al.approval_id
|
||||
WHERE al.approver_id = #{userId} AND al.line_status = 'CURRENT'
|
||||
AND a.status IN ('SUBMITTED', 'IN_PROGRESS')) AS "pendingApprovals",
|
||||
(SELECT count(*) FROM required_document rd JOIN event e ON e.id = rd.event_id
|
||||
WHERE e.tenant_id = #{tenantId} AND rd.status NOT IN ('approved')
|
||||
AND (#{broad} OR EXISTS (SELECT 1 FROM event_member m
|
||||
WHERE m.event_id = e.id AND m.user_id = #{userId}))) AS "docsToSubmit",
|
||||
(SELECT count(*) FROM invoice i JOIN event e ON e.id = i.event_id
|
||||
WHERE e.tenant_id = #{tenantId} AND i.status IN ('pending', 'overdue')
|
||||
AND (#{broad} OR EXISTS (SELECT 1 FROM event_member m
|
||||
WHERE m.event_id = e.id AND m.user_id = #{userId} AND m.role_code = 'ORGANIZER'))) AS "unpaidInvoices",
|
||||
(SELECT count(*) FROM auction a JOIN event e ON e.id = a.event_id
|
||||
WHERE e.tenant_id = #{tenantId} AND a.deadline > CURRENT_TIMESTAMP
|
||||
AND EXISTS (SELECT 1 FROM auction_invite ai JOIN event_member m ON m.company_id = ai.company_id
|
||||
WHERE ai.auction_id = a.id AND m.user_id = #{userId} AND m.role_code = 'CONTRACTOR')) AS "activeAuctions",
|
||||
(SELECT count(*) FROM award aw JOIN bid b ON b.id = aw.bid_id
|
||||
WHERE EXISTS (SELECT 1 FROM event_member m
|
||||
WHERE m.company_id = b.company_id AND m.user_id = #{userId} AND m.role_code = 'CONTRACTOR')) AS "awardedBooths"
|
||||
""")
|
||||
Map<String, Object> findSummaryCounts(@Param("userId") String userId,
|
||||
@Param("tenantId") String tenantId,
|
||||
@Param("broad") boolean broad);
|
||||
|
||||
/** 사용자의 행사 역할 코드 목록(중복 제거) — 주 역할 판정용. */
|
||||
@Select("""
|
||||
SELECT DISTINCT role_code AS "roleCode"
|
||||
FROM event_member
|
||||
WHERE user_id = #{userId}
|
||||
""")
|
||||
List<String> findUserEventRoles(@Param("userId") String userId);
|
||||
}
|
||||
@ -0,0 +1,233 @@
|
||||
package com.zioinfo.kintex.home;
|
||||
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.home.dto.HomeDtos.HallCenter;
|
||||
import com.zioinfo.kintex.home.dto.HomeDtos.ShowcaseItem;
|
||||
import com.zioinfo.kintex.home.dto.HomeDtos.SummaryResponse;
|
||||
import com.zioinfo.kintex.home.dto.HomeDtos.SummaryTile;
|
||||
import com.zioinfo.kintex.home.dto.HomeDtos.TodoItem;
|
||||
import com.zioinfo.kintex.home.dto.HomeDtos.TodoResponse;
|
||||
import com.zioinfo.kintex.tenant.TenantContext;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* SCR-HOME 집계 서비스 — 읽기 전용. 역할(JWT roleCode·hallManager·행사 역할) 기반으로 원천을 병합하고
|
||||
* 기한 임박순으로 정렬한다. 병합·정렬·심각도·라벨 조립 등 파생 로직은 이 서비스 한 곳에 둔다.
|
||||
*/
|
||||
@Service
|
||||
public class HomeService {
|
||||
|
||||
/** 서류 마감 임박 지평(D-7 이내). 프론트 계약(SCR-HOME 내 할 일 D-n)과 정합. */
|
||||
private static final int DOC_HORIZON_DAYS = 7;
|
||||
/** 내 할 일 표시 상한(design.md: 최대 6행 + 전체 보기). */
|
||||
private static final int TODO_LIMIT = 6;
|
||||
|
||||
private final HomeMapper mapper;
|
||||
|
||||
public HomeService(HomeMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
// ── 내 할 일 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public TodoResponse getTodos(KintexPrincipal principal) {
|
||||
String userId = principal.userId();
|
||||
String tenantId = TenantContext.currentTenantId();
|
||||
boolean broad = isBroadOversight(principal);
|
||||
String horizon = LocalDate.now().plusDays(DOC_HORIZON_DAYS).toString();
|
||||
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
rows.addAll(mapper.findApprovalTodos(userId));
|
||||
rows.addAll(mapper.findDocumentTodos(userId, tenantId, horizon, broad));
|
||||
rows.addAll(mapper.findAuctionAwardTodos(userId, tenantId, broad));
|
||||
rows.addAll(mapper.findAuctionBidTodos(userId, tenantId));
|
||||
rows.addAll(mapper.findSettlementTodos(userId, tenantId, broad));
|
||||
|
||||
List<TodoItem> items = new ArrayList<>();
|
||||
for (Map<String, Object> r : rows) {
|
||||
Integer dday = toInt(r.get("dday"));
|
||||
items.add(new TodoItem(
|
||||
str(r.get("type")),
|
||||
str(r.get("title")),
|
||||
str(r.get("target")),
|
||||
str(r.get("dueDate")),
|
||||
dday,
|
||||
severity(dday),
|
||||
str(r.get("route"))));
|
||||
}
|
||||
|
||||
// 임박순: dday 오름차순(경과·임박 우선), 기한 없는 결재는 후순위.
|
||||
items.sort(Comparator.comparing(
|
||||
(TodoItem t) -> t.dday() == null ? Integer.MAX_VALUE : t.dday()));
|
||||
|
||||
int total = items.size();
|
||||
List<TodoItem> top = items.size() > TODO_LIMIT ? items.subList(0, TODO_LIMIT) : items;
|
||||
return new TodoResponse(total, new ArrayList<>(top));
|
||||
}
|
||||
|
||||
// ── 쇼케이스 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
public List<ShowcaseItem> getShowcase(String tabParam) {
|
||||
String tab = normalizeTab(tabParam);
|
||||
String tenantId = TenantContext.currentTenantId();
|
||||
List<Map<String, Object>> rows = mapper.findShowcase(tenantId, tab);
|
||||
List<ShowcaseItem> out = new ArrayList<>();
|
||||
for (Map<String, Object> r : rows) {
|
||||
out.add(new ShowcaseItem(
|
||||
str(r.get("id")),
|
||||
str(r.get("name")),
|
||||
str(r.get("startDate")),
|
||||
str(r.get("endDate")),
|
||||
str(r.get("hallLabel")),
|
||||
str(r.get("posterUrl")),
|
||||
str(r.get("status")),
|
||||
toInt(r.get("dday"))));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 전시장(센터) 동적 목록 ────────────────────────────────────────────────────
|
||||
|
||||
public List<HallCenter> getHallCenters() {
|
||||
String tenantId = TenantContext.currentTenantId();
|
||||
List<Map<String, Object>> rows = mapper.findHallCenters(tenantId);
|
||||
List<HallCenter> out = new ArrayList<>();
|
||||
for (Map<String, Object> r : rows) {
|
||||
Integer center = toInt(r.get("center"));
|
||||
if (center == null) {
|
||||
continue;
|
||||
}
|
||||
out.add(new HallCenter(String.valueOf(center), "제" + center + "전시장"));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 역할별 요약 KPI ───────────────────────────────────────────────────────────
|
||||
|
||||
public SummaryResponse getSummary(KintexPrincipal principal) {
|
||||
String userId = principal.userId();
|
||||
String tenantId = TenantContext.currentTenantId();
|
||||
boolean broad = isBroadOversight(principal);
|
||||
Map<String, Object> c = mapper.findSummaryCounts(userId, tenantId, broad);
|
||||
String role = resolvePrimaryRole(principal);
|
||||
|
||||
List<SummaryTile> tiles = switch (role) {
|
||||
case "ADMIN" -> List.of(
|
||||
tile("pendingApprovals", "승인 대기", num(c, "pendingApprovals"), "건", "/work/approval"),
|
||||
tile("ongoingEvents", "진행 중 전시", num(c, "ongoingEvents"), "건", "/schedule"),
|
||||
tile("todayCheckins", "오늘 체크인", num(c, "todayCheckins"), "명", "/visitors"),
|
||||
tile("unpaidInvoices", "미수 청구", num(c, "unpaidInvoices"), "건", "/settlement"));
|
||||
case "EXHIBITOR" -> List.of(
|
||||
tile("docsToSubmit", "제출할 서류", num(c, "docsToSubmit"), "건", "/docs"),
|
||||
tile("unpaidInvoices", "미확인 청구", num(c, "unpaidInvoices"), "건", "/settlement"),
|
||||
ddayTile("nearestOpening", "임박 개장", toInt(c.get("nearestOpeningDday")), "/schedule"),
|
||||
tile("pendingApprovals", "미결 결재", num(c, "pendingApprovals"), "건", "/work/approval"));
|
||||
case "CONTRACTOR" -> List.of(
|
||||
tile("activeAuctions", "응찰 중 옥션", num(c, "activeAuctions"), "건", "/auctions"),
|
||||
tile("awardedBooths", "수주 부스", num(c, "awardedBooths"), "건", "/contractor/dashboard"),
|
||||
ddayTile("nearestOpening", "임박 개장", toInt(c.get("nearestOpeningDday")), "/schedule"),
|
||||
tile("pendingApprovals", "미결 결재", num(c, "pendingApprovals"), "건", "/work/approval"));
|
||||
// ORGANIZER(기본)
|
||||
default -> List.of(
|
||||
tile("ongoingEvents", "진행 중 전시", num(c, "ongoingEvents"), "건", "/schedule"),
|
||||
tile("todayCheckins", "오늘 체크인", num(c, "todayCheckins"), "명", "/visitors"),
|
||||
ddayTile("nearestOpening", "임박 개장", toInt(c.get("nearestOpeningDday")), "/schedule"),
|
||||
tile("pendingApprovals", "미결 결재", num(c, "pendingApprovals"), "건", "/work/approval"));
|
||||
};
|
||||
return new SummaryResponse(role, tiles);
|
||||
}
|
||||
|
||||
// ── 헬퍼 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 감독 권한(전 테넌트 열람) — 홀매니저 또는 전역 ADMIN/MANAGER. */
|
||||
private boolean isBroadOversight(KintexPrincipal principal) {
|
||||
if (principal.hallManager()) {
|
||||
return true;
|
||||
}
|
||||
String rc = principal.roleCode();
|
||||
return rc != null && (rc.equalsIgnoreCase("ADMIN") || rc.equalsIgnoreCase("MANAGER"));
|
||||
}
|
||||
|
||||
/** 주 역할 판정: 관리자/홀매니저 → ADMIN, 그 외 행사 역할 우선순위(ORGANIZER > CONTRACTOR > EXHIBITOR). */
|
||||
private String resolvePrimaryRole(KintexPrincipal principal) {
|
||||
if (isBroadOversight(principal)) {
|
||||
return "ADMIN";
|
||||
}
|
||||
Set<String> roles = Set.copyOf(mapper.findUserEventRoles(principal.userId()));
|
||||
if (roles.contains("ORGANIZER")) {
|
||||
return "ORGANIZER";
|
||||
}
|
||||
if (roles.contains("CONTRACTOR")) {
|
||||
return "CONTRACTOR";
|
||||
}
|
||||
if (roles.contains("EXHIBITOR")) {
|
||||
return "EXHIBITOR";
|
||||
}
|
||||
return "ORGANIZER"; // 기본 세트(design.md)
|
||||
}
|
||||
|
||||
private SummaryTile tile(String key, String label, Long value, String unit, String route) {
|
||||
return new SummaryTile(key, label, value, unit, null, "normal", route);
|
||||
}
|
||||
|
||||
/** D-day 칩 타일 — dday 없으면 값 null("—"), D-6 이내 warning·경과 overdue. */
|
||||
private SummaryTile ddayTile(String key, String label, Integer dday, String route) {
|
||||
String sev = dday == null ? "normal" : dday < 0 ? "overdue" : dday <= 6 ? "warning" : "normal";
|
||||
Object value = dday == null ? null : ("D-" + Math.max(dday, 0));
|
||||
return new SummaryTile(key, label, value, null, dday, sev, route);
|
||||
}
|
||||
|
||||
private static String severity(Integer dday) {
|
||||
if (dday == null) {
|
||||
return "normal";
|
||||
}
|
||||
if (dday < 0) {
|
||||
return "overdue";
|
||||
}
|
||||
return dday <= 3 ? "warning" : "normal";
|
||||
}
|
||||
|
||||
private static String normalizeTab(String tab) {
|
||||
if (tab == null) {
|
||||
return "ongoing";
|
||||
}
|
||||
String v = tab.trim();
|
||||
return switch (v) {
|
||||
case "thisMonth", "nextMonth", "ongoing" -> v;
|
||||
default -> "ongoing";
|
||||
};
|
||||
}
|
||||
|
||||
private static Long num(Map<String, Object> m, String key) {
|
||||
Object v = m == null ? null : m.get(key);
|
||||
if (v instanceof Number n) {
|
||||
return n.longValue();
|
||||
}
|
||||
return 0L;
|
||||
}
|
||||
|
||||
private static Integer toInt(Object v) {
|
||||
if (v == null) {
|
||||
return null;
|
||||
}
|
||||
if (v instanceof Number n) {
|
||||
return n.intValue();
|
||||
}
|
||||
try {
|
||||
return Integer.valueOf(v.toString().trim());
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object v) {
|
||||
return v == null ? null : v.toString();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package com.zioinfo.kintex.home.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* SCR-HOME(로그인 후 메인 홈) 백엔드 응답 DTO 모음 — 읽기 전용 집계(타 도메인 테이블 직접 SELECT).
|
||||
*
|
||||
* <p>계약 정본: {@code _workspace/impl_home_api.md}. 모든 응답은 {@code ApiResponse<T>} 봉투로 감싼다.
|
||||
* PII(연락처·이메일 등)·서버 자격정보는 절대 포함하지 않는다(보안 불변).
|
||||
*/
|
||||
public final class HomeDtos {
|
||||
|
||||
private HomeDtos() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 할 일(GET /api/home/todos) 응답.
|
||||
*
|
||||
* @param total 역할 기반으로 집계된 미결 작업 총 건수(헤더 배지용, 표시는 {@code items} 최대 6건)
|
||||
* @param items 기한 임박순(dday 오름차순·기한 없는 결재는 후순위) 최대 6건
|
||||
*/
|
||||
public record TodoResponse(int total, List<TodoItem> items) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 할 일 1건.
|
||||
*
|
||||
* @param type approval | document | auction | settlement | system
|
||||
* @param title 작업 라벨(예: "결재 대기", "신고서류 제출")
|
||||
* @param target 대상(문서/행사/부스/청구 식별 텍스트 — 민감정보 아님)
|
||||
* @param dueDate 마감일(YYYY-MM-DD, 없으면 null)
|
||||
* @param dday D-day(오늘 기준 잔여 일수, 음수=경과, 없으면 null)
|
||||
* @param severity normal | warning(D-3 이내) | overdue(기한 경과)
|
||||
* @param route 프론트 라우트 딥링크
|
||||
*/
|
||||
public record TodoItem(String type, String title, String target,
|
||||
String dueDate, Integer dday, String severity, String route) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 진행 중·다가오는 전시 쇼케이스 1건(GET /api/home/showcase).
|
||||
*
|
||||
* @param status ongoing | upcoming | ended(날짜 기준 파생)
|
||||
* @param dday 개장까지 잔여 일수(진행 중=0 이하, 없으면 null). {@code posterUrl} NULL 시 프론트가 폴백 카드 렌더.
|
||||
*/
|
||||
public record ShowcaseItem(String id, String name, String startDate, String endDate,
|
||||
String hallLabel, String posterUrl, String status, Integer dday) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 전시장(센터) 구분 항목(GET /api/home/hall-centers). 캘린더/리스트 세그먼트 필터의 동적 옵션.
|
||||
* 프론트는 이 목록 앞에 "전체"를 붙여 렌더한다(하드코딩 금지 — 3·4관 신설 시 자동 확장).
|
||||
*
|
||||
* @param code 센터 코드(홀 마스터 {@code exhibition_center} 정수의 문자열)
|
||||
* @param label 표시 라벨(예: "제1전시장")
|
||||
*/
|
||||
public record HallCenter(String code, String label) {
|
||||
}
|
||||
|
||||
/** 역할별 KPI 요약(GET /api/home/summary). */
|
||||
public record SummaryResponse(String role, List<SummaryTile> tiles) {
|
||||
}
|
||||
|
||||
/**
|
||||
* KPI 타일 1개.
|
||||
*
|
||||
* @param key 타일 키(프론트 i18n·아이콘 매핑)
|
||||
* @param label 표시 라벨
|
||||
* @param value 수치 값(집계 결과, null 가능 → 프론트 "—" 플레이스홀더)
|
||||
* @param unit 단위(예: "건", "명"; 없으면 null)
|
||||
* @param dday D-day 칩(임박 지표용, 없으면 null)
|
||||
* @param severity 상태색(normal | warning | overdue)
|
||||
* @param route 클릭 시 딥링크
|
||||
*/
|
||||
public record SummaryTile(String key, String label, Object value, String unit,
|
||||
Integer dday, String severity, String route) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
package com.zioinfo.kintex.logistics;
|
||||
|
||||
import com.zioinfo.kintex.auth.EventAccessGuard;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.common.audit.Audited;
|
||||
import com.zioinfo.kintex.logistics.dto.LogisticsExtDtos.*;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* M8 물류 확장 API — F041 장비 신청 · F042 렌탈 주문 · F043 선입고 트래킹 (SCR /logistics 탭 확장).
|
||||
* <ul>
|
||||
* <li>조회(보드): 행사 접근 권한. 신청·주문·선입고 등록: 행사 멤버(참가업체 포함) + {@link Audited}.</li>
|
||||
* <li>승인·주문 확정·상태 전이: 매니저 이상(주최자/홀매니저) + {@link Audited}.</li>
|
||||
* </ul>
|
||||
* 금액은 서버 계산 권위(카탈로그 단가 정본). 상태 전이 값은 서버 화이트리스트 검증(부적합 400, 미존재 404).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/events/{eventId}/logistics")
|
||||
public class LogisticsExtController {
|
||||
|
||||
private final LogisticsExtService service;
|
||||
private final EventAccessGuard guard;
|
||||
|
||||
public LogisticsExtController(LogisticsExtService service, EventAccessGuard guard) {
|
||||
this.service = service;
|
||||
this.guard = guard;
|
||||
}
|
||||
|
||||
/** GET /ext — 물류 확장 보드(카탈로그 + 신청/주문/선입고 목록 + canManage). */
|
||||
@GetMapping("/ext")
|
||||
public ApiResponse<LogisticsExtBoard> board(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.board(eventId, principal));
|
||||
}
|
||||
|
||||
// ── ① 장비 신청 ──────────────────────────────────────────────────────────
|
||||
/** POST /equipment-requests — 장비 신청 생성(멤버). 금액 서버 계산. */
|
||||
@Audited(action = "LOGI_EQUIP_REQUEST", targetType = "logistics_equipment_request")
|
||||
@PostMapping("/equipment-requests")
|
||||
public ApiResponse<EquipmentRequestView> requestEquipment(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@Valid @RequestBody EquipmentRequestBody body) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.createEquipmentRequest(eventId, body, principal));
|
||||
}
|
||||
|
||||
/** POST /equipment-requests/{id}/status — 승인/반려/처리완료(매니저 이상). */
|
||||
@Audited(action = "LOGI_EQUIP_DECIDE", targetType = "logistics_equipment_request")
|
||||
@PostMapping("/equipment-requests/{id}/status")
|
||||
public ApiResponse<EquipmentRequestView> decideEquipment(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String id,
|
||||
@Valid @RequestBody StatusDecisionBody body) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.decideEquipmentRequest(eventId, id, body, principal));
|
||||
}
|
||||
|
||||
// ── ② 렌탈 주문 ──────────────────────────────────────────────────────────
|
||||
/** POST /rental-orders — 렌탈 주문 생성(멤버). 기간·금액 서버 산출. */
|
||||
@Audited(action = "LOGI_RENTAL_ORDER", targetType = "logistics_rental_order")
|
||||
@PostMapping("/rental-orders")
|
||||
public ApiResponse<RentalOrderView> orderRental(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@Valid @RequestBody RentalOrderBody body) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.createRentalOrder(eventId, body, principal));
|
||||
}
|
||||
|
||||
/** POST /rental-orders/{id}/status — 확정/취소/납품(매니저 이상). */
|
||||
@Audited(action = "LOGI_RENTAL_DECIDE", targetType = "logistics_rental_order")
|
||||
@PostMapping("/rental-orders/{id}/status")
|
||||
public ApiResponse<RentalOrderView> decideRental(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String id,
|
||||
@Valid @RequestBody StatusDecisionBody body) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.decideRentalOrder(eventId, id, body, principal));
|
||||
}
|
||||
|
||||
// ── ③ 선입고 트래킹 ──────────────────────────────────────────────────────
|
||||
/** POST /inbound — 운송장 등록(멤버). */
|
||||
@Audited(action = "LOGI_INBOUND_REGISTER", targetType = "logistics_inbound")
|
||||
@PostMapping("/inbound")
|
||||
public ApiResponse<InboundView> registerInbound(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@Valid @RequestBody InboundBody body) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.registerInbound(eventId, body, principal));
|
||||
}
|
||||
|
||||
/** POST /inbound/{id}/status — 입고예정/창고도착/부스전달 전이(매니저 이상). */
|
||||
@Audited(action = "LOGI_INBOUND_ADVANCE", targetType = "logistics_inbound")
|
||||
@PostMapping("/inbound/{id}/status")
|
||||
public ApiResponse<InboundView> advanceInbound(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String id,
|
||||
@Valid @RequestBody InboundStatusBody body) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.advanceInbound(eventId, id, body, principal));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,200 @@
|
||||
package com.zioinfo.kintex.logistics;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* M8 물류 확장 매퍼 — 장비 마스터/신청·렌탈 카탈로그/주문·선입고. camelCase 별칭은 쌍따옴표(PG 소문자 폴딩 함정).
|
||||
* 상태 전이 UPDATE 는 WHERE status 조건으로 원자 처리(0행이면 서비스가 409/404 매핑).
|
||||
* tenant_id 는 항상 조회/삽입 조건에 포함(멀티테넌시 표준). 날짜는 to_char 로 문자열 정규화.
|
||||
*/
|
||||
@Mapper
|
||||
public interface LogisticsExtMapper {
|
||||
|
||||
// ── 장비 카탈로그 ────────────────────────────────────────────────────────
|
||||
@Select("""
|
||||
SELECT id,
|
||||
code,
|
||||
name,
|
||||
category,
|
||||
unit,
|
||||
unit_price AS "unitPrice",
|
||||
spec
|
||||
FROM logistics_equipment
|
||||
WHERE tenant_id = #{tenantId} AND active = true
|
||||
ORDER BY category, name
|
||||
""")
|
||||
List<Map<String, Object>> findEquipmentCatalog(@Param("tenantId") String tenantId);
|
||||
|
||||
@Select("""
|
||||
SELECT id, name, unit_price AS "unitPrice"
|
||||
FROM logistics_equipment
|
||||
WHERE tenant_id = #{tenantId} AND id = #{id}
|
||||
""")
|
||||
Map<String, Object> findEquipmentById(@Param("tenantId") String tenantId, @Param("id") String id);
|
||||
|
||||
// ── 장비 신청 ────────────────────────────────────────────────────────────
|
||||
@Select("""
|
||||
SELECT r.id,
|
||||
r.equipment_id AS "equipmentId",
|
||||
r.equipment_name AS "equipmentName",
|
||||
e.category,
|
||||
r.unit_price AS "unitPrice",
|
||||
r.booth_no AS "boothNo",
|
||||
r.quantity,
|
||||
to_char(r.use_date, 'YYYY-MM-DD') AS "useDate",
|
||||
r.days,
|
||||
r.amount,
|
||||
r.status,
|
||||
r.note,
|
||||
r.reject_reason AS "rejectReason",
|
||||
to_char(r.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "createdAt"
|
||||
FROM logistics_equipment_request r
|
||||
LEFT JOIN logistics_equipment e ON e.tenant_id = r.tenant_id AND e.id = r.equipment_id
|
||||
WHERE r.tenant_id = #{tenantId} AND r.event_id = #{eventId}
|
||||
ORDER BY r.created_at DESC
|
||||
""")
|
||||
List<Map<String, Object>> findEquipmentRequests(@Param("tenantId") String tenantId,
|
||||
@Param("eventId") String eventId);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO logistics_equipment_request
|
||||
(id, tenant_id, event_id, equipment_id, equipment_name, unit_price,
|
||||
booth_no, quantity, use_date, days, amount, status, note, requested_by)
|
||||
VALUES
|
||||
(#{id}, #{tenantId}, #{eventId}, #{equipmentId}, #{equipmentName}, #{unitPrice},
|
||||
#{boothNo}, #{quantity}, CAST(#{useDate} AS date), #{days}, #{amount}, 'requested', #{note}, #{userId})
|
||||
""")
|
||||
int insertEquipmentRequest(Map<String, Object> p);
|
||||
|
||||
@Update("""
|
||||
UPDATE logistics_equipment_request
|
||||
SET status = #{status},
|
||||
reject_reason = #{reason},
|
||||
decided_by = #{userId},
|
||||
decided_at = now(),
|
||||
updated_at = now()
|
||||
WHERE tenant_id = #{tenantId} AND event_id = #{eventId} AND id = #{id}
|
||||
""")
|
||||
int updateEquipmentStatus(Map<String, Object> p);
|
||||
|
||||
// ── 렌탈 카탈로그 ────────────────────────────────────────────────────────
|
||||
@Select("""
|
||||
SELECT id,
|
||||
code,
|
||||
name,
|
||||
category,
|
||||
unit,
|
||||
daily_price AS "dailyPrice",
|
||||
spec
|
||||
FROM logistics_rental_item
|
||||
WHERE tenant_id = #{tenantId} AND active = true
|
||||
ORDER BY category, name
|
||||
""")
|
||||
List<Map<String, Object>> findRentalCatalog(@Param("tenantId") String tenantId);
|
||||
|
||||
@Select("""
|
||||
SELECT id, name, daily_price AS "dailyPrice"
|
||||
FROM logistics_rental_item
|
||||
WHERE tenant_id = #{tenantId} AND id = #{id}
|
||||
""")
|
||||
Map<String, Object> findRentalItemById(@Param("tenantId") String tenantId, @Param("id") String id);
|
||||
|
||||
// ── 렌탈 주문 ────────────────────────────────────────────────────────────
|
||||
@Select("""
|
||||
SELECT o.id,
|
||||
o.item_id AS "itemId",
|
||||
o.item_name AS "itemName",
|
||||
i.category,
|
||||
o.daily_price AS "dailyPrice",
|
||||
o.booth_no AS "boothNo",
|
||||
o.quantity,
|
||||
to_char(o.start_date, 'YYYY-MM-DD') AS "startDate",
|
||||
to_char(o.end_date, 'YYYY-MM-DD') AS "endDate",
|
||||
o.days,
|
||||
o.amount,
|
||||
o.status,
|
||||
o.note,
|
||||
to_char(o.created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "createdAt"
|
||||
FROM logistics_rental_order o
|
||||
LEFT JOIN logistics_rental_item i ON i.tenant_id = o.tenant_id AND i.id = o.item_id
|
||||
WHERE o.tenant_id = #{tenantId} AND o.event_id = #{eventId}
|
||||
ORDER BY o.created_at DESC
|
||||
""")
|
||||
List<Map<String, Object>> findRentalOrders(@Param("tenantId") String tenantId,
|
||||
@Param("eventId") String eventId);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO logistics_rental_order
|
||||
(id, tenant_id, event_id, item_id, item_name, daily_price,
|
||||
booth_no, quantity, start_date, end_date, days, amount, status, note, ordered_by)
|
||||
VALUES
|
||||
(#{id}, #{tenantId}, #{eventId}, #{itemId}, #{itemName}, #{dailyPrice},
|
||||
#{boothNo}, #{quantity}, CAST(#{startDate} AS date), CAST(#{endDate} AS date),
|
||||
#{days}, #{amount}, 'ordered', #{note}, #{userId})
|
||||
""")
|
||||
int insertRentalOrder(Map<String, Object> p);
|
||||
|
||||
@Update("""
|
||||
UPDATE logistics_rental_order
|
||||
SET status = #{status},
|
||||
decided_by = #{userId},
|
||||
decided_at = now(),
|
||||
updated_at = now()
|
||||
WHERE tenant_id = #{tenantId} AND event_id = #{eventId} AND id = #{id}
|
||||
""")
|
||||
int updateRentalStatus(Map<String, Object> p);
|
||||
|
||||
// ── 선입고 트래킹 ────────────────────────────────────────────────────────
|
||||
@Select("""
|
||||
SELECT id,
|
||||
tracking_no AS "trackingNo",
|
||||
carrier,
|
||||
booth_no AS "boothNo",
|
||||
item_desc AS "itemDesc",
|
||||
quantity,
|
||||
status,
|
||||
to_char(eta_date, 'YYYY-MM-DD') AS "etaDate",
|
||||
to_char(warehouse_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "warehouseAt",
|
||||
to_char(delivered_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "deliveredAt",
|
||||
note,
|
||||
to_char(created_at, 'YYYY-MM-DD"T"HH24:MI:SS') AS "createdAt"
|
||||
FROM logistics_inbound
|
||||
WHERE tenant_id = #{tenantId} AND event_id = #{eventId}
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
List<Map<String, Object>> findInbound(@Param("tenantId") String tenantId,
|
||||
@Param("eventId") String eventId);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO logistics_inbound
|
||||
(id, tenant_id, event_id, tracking_no, carrier, booth_no, item_desc,
|
||||
quantity, status, eta_date, note, registered_by)
|
||||
VALUES
|
||||
(#{id}, #{tenantId}, #{eventId}, #{trackingNo}, #{carrier}, #{boothNo}, #{itemDesc},
|
||||
#{quantity}, 'registered', CAST(#{etaDate} AS date), #{note}, #{userId})
|
||||
""")
|
||||
int insertInbound(Map<String, Object> p);
|
||||
|
||||
/**
|
||||
* 선입고 상태 전이 — 창고 도착/부스 전달 시각을 상태에 맞춰 기록(멱등적 COALESCE).
|
||||
* (비교연산 없음 · raw @Update 본문이라 이스케이프 불필요.)
|
||||
*/
|
||||
@Update("""
|
||||
UPDATE logistics_inbound
|
||||
SET status = #{status},
|
||||
warehouse_at = CASE WHEN #{status} = 'arrived_warehouse' THEN COALESCE(warehouse_at, now()) ELSE warehouse_at END,
|
||||
delivered_at = CASE WHEN #{status} = 'delivered_booth' THEN COALESCE(delivered_at, now()) ELSE delivered_at END,
|
||||
note = COALESCE(#{note}, note),
|
||||
updated_by = #{userId},
|
||||
updated_at = now()
|
||||
WHERE tenant_id = #{tenantId} AND event_id = #{eventId} AND id = #{id}
|
||||
""")
|
||||
int updateInboundStatus(Map<String, Object> p);
|
||||
}
|
||||
@ -0,0 +1,342 @@
|
||||
package com.zioinfo.kintex.logistics;
|
||||
|
||||
import com.zioinfo.kintex.auth.EventRole;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.logistics.dto.LogisticsExtDtos.*;
|
||||
import com.zioinfo.kintex.tenant.TenantContext;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* M8 물류 확장 서비스 — F041 장비 신청 · F042 렌탈 주문 · F043 선입고 트래킹.
|
||||
*
|
||||
* <p><b>금액 서버 권위</b>: 장비 amount = 카탈로그 단가 × 수량 × 일수, 렌탈 amount = 카탈로그 일단가 × 수량 × 기간(일).
|
||||
* 단가는 <b>마스터에서 조회</b>하며 요청 값은 신뢰하지 않는다(신청/주문 시점 스냅샷 기록).
|
||||
*
|
||||
* <p><b>권한</b>: 신청·주문·선입고 등록은 행사 멤버(참가업체 포함). 승인·주문 확정·선입고 상태 전이는 매니저 이상
|
||||
* (주최자/홀매니저). 상태 전이 값은 서버 화이트리스트로 검증.
|
||||
*/
|
||||
@Service
|
||||
public class LogisticsExtService {
|
||||
|
||||
private static final Set<String> EQUIP_STATUSES = Set.of("approved", "rejected", "done");
|
||||
private static final Set<String> RENTAL_STATUSES = Set.of("confirmed", "cancelled", "delivered");
|
||||
private static final Set<String> INBOUND_STATUSES =
|
||||
Set.of("inbound_scheduled", "arrived_warehouse", "delivered_booth");
|
||||
|
||||
private final LogisticsExtMapper mapper;
|
||||
|
||||
public LogisticsExtService(LogisticsExtMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
// ── 통합 조회(탭 보드) ────────────────────────────────────────────────────
|
||||
public LogisticsExtBoard board(String eventId, KintexPrincipal principal) {
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
boolean canManage = isManager(principal, eventId);
|
||||
return new LogisticsExtBoard(
|
||||
canManage,
|
||||
mapper.findEquipmentCatalog(tenant).stream().map(LogisticsExtService::toEquipmentItem).toList(),
|
||||
mapper.findEquipmentRequests(tenant, eventId).stream().map(LogisticsExtService::toEquipmentView).toList(),
|
||||
mapper.findRentalCatalog(tenant).stream().map(LogisticsExtService::toRentalItem).toList(),
|
||||
mapper.findRentalOrders(tenant, eventId).stream().map(LogisticsExtService::toRentalView).toList(),
|
||||
mapper.findInbound(tenant, eventId).stream().map(LogisticsExtService::toInboundView).toList()
|
||||
);
|
||||
}
|
||||
|
||||
// ── ① 장비 신청 ──────────────────────────────────────────────────────────
|
||||
@Transactional
|
||||
public EquipmentRequestView createEquipmentRequest(String eventId, EquipmentRequestBody req, KintexPrincipal principal) {
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
Map<String, Object> item = mapper.findEquipmentById(tenant, req.equipmentId());
|
||||
if (item == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "선택한 장비 품목을 찾을 수 없습니다.");
|
||||
}
|
||||
int qty = Math.max(1, req.quantity());
|
||||
int days = Math.max(1, req.days());
|
||||
long unitPrice = longVal(item.get("unitPrice"));
|
||||
long amount = unitPrice * qty * days;
|
||||
|
||||
String id = "leqr-" + UUID.randomUUID();
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("id", id);
|
||||
p.put("tenantId", tenant);
|
||||
p.put("eventId", eventId);
|
||||
p.put("equipmentId", req.equipmentId());
|
||||
p.put("equipmentName", str(item.get("name")));
|
||||
p.put("unitPrice", unitPrice);
|
||||
p.put("boothNo", blankToNull(req.boothNo()));
|
||||
p.put("quantity", qty);
|
||||
p.put("useDate", parseDateOrNull(req.useDate()));
|
||||
p.put("days", days);
|
||||
p.put("amount", amount);
|
||||
p.put("note", blankToNull(req.note()));
|
||||
p.put("userId", principal == null ? null : principal.userId());
|
||||
mapper.insertEquipmentRequest(p);
|
||||
return findEquipmentView(tenant, eventId, id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public EquipmentRequestView decideEquipmentRequest(String eventId, String id, StatusDecisionBody req, KintexPrincipal principal) {
|
||||
requireManager(principal, eventId, "장비 신청 승인/처리는 매니저 이상만 수행할 수 있습니다.");
|
||||
String status = normalize(req.status());
|
||||
if (!EQUIP_STATUSES.contains(status)) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "장비 신청 상태는 approved|rejected|done 이어야 합니다.");
|
||||
}
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("tenantId", tenant);
|
||||
p.put("eventId", eventId);
|
||||
p.put("id", id);
|
||||
p.put("status", status);
|
||||
p.put("reason", "rejected".equals(status) ? blankToNull(req.reason()) : null);
|
||||
p.put("userId", principal == null ? null : principal.userId());
|
||||
if (mapper.updateEquipmentStatus(p) == 0) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "대상 장비 신청을 찾을 수 없습니다.");
|
||||
}
|
||||
return findEquipmentView(tenant, eventId, id);
|
||||
}
|
||||
|
||||
// ── ② 렌탈 주문 ──────────────────────────────────────────────────────────
|
||||
@Transactional
|
||||
public RentalOrderView createRentalOrder(String eventId, RentalOrderBody req, KintexPrincipal principal) {
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
Map<String, Object> item = mapper.findRentalItemById(tenant, req.itemId());
|
||||
if (item == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "선택한 렌탈 품목을 찾을 수 없습니다.");
|
||||
}
|
||||
int qty = Math.max(1, req.quantity());
|
||||
int days = resolveDays(req.startDate(), req.endDate(), req.days());
|
||||
long dailyPrice = longVal(item.get("dailyPrice"));
|
||||
long amount = dailyPrice * qty * days;
|
||||
|
||||
String id = "lro-" + UUID.randomUUID();
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("id", id);
|
||||
p.put("tenantId", tenant);
|
||||
p.put("eventId", eventId);
|
||||
p.put("itemId", req.itemId());
|
||||
p.put("itemName", str(item.get("name")));
|
||||
p.put("dailyPrice", dailyPrice);
|
||||
p.put("boothNo", blankToNull(req.boothNo()));
|
||||
p.put("quantity", qty);
|
||||
p.put("startDate", parseDateOrNull(req.startDate()));
|
||||
p.put("endDate", parseDateOrNull(req.endDate()));
|
||||
p.put("days", days);
|
||||
p.put("amount", amount);
|
||||
p.put("note", blankToNull(req.note()));
|
||||
p.put("userId", principal == null ? null : principal.userId());
|
||||
mapper.insertRentalOrder(p);
|
||||
return findRentalView(tenant, eventId, id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public RentalOrderView decideRentalOrder(String eventId, String id, StatusDecisionBody req, KintexPrincipal principal) {
|
||||
requireManager(principal, eventId, "렌탈 주문 확정/처리는 매니저 이상만 수행할 수 있습니다.");
|
||||
String status = normalize(req.status());
|
||||
if (!RENTAL_STATUSES.contains(status)) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "렌탈 주문 상태는 confirmed|cancelled|delivered 이어야 합니다.");
|
||||
}
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("tenantId", tenant);
|
||||
p.put("eventId", eventId);
|
||||
p.put("id", id);
|
||||
p.put("status", status);
|
||||
p.put("userId", principal == null ? null : principal.userId());
|
||||
if (mapper.updateRentalStatus(p) == 0) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "대상 렌탈 주문을 찾을 수 없습니다.");
|
||||
}
|
||||
return findRentalView(tenant, eventId, id);
|
||||
}
|
||||
|
||||
// ── ③ 선입고 트래킹 ──────────────────────────────────────────────────────
|
||||
@Transactional
|
||||
public InboundView registerInbound(String eventId, InboundBody req, KintexPrincipal principal) {
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
String id = "linb-" + UUID.randomUUID();
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("id", id);
|
||||
p.put("tenantId", tenant);
|
||||
p.put("eventId", eventId);
|
||||
p.put("trackingNo", req.trackingNo().trim());
|
||||
p.put("carrier", blankToNull(req.carrier()));
|
||||
p.put("boothNo", blankToNull(req.boothNo()));
|
||||
p.put("itemDesc", blankToNull(req.itemDesc()));
|
||||
p.put("quantity", Math.max(1, req.quantity()));
|
||||
p.put("etaDate", parseDateOrNull(req.etaDate()));
|
||||
p.put("note", blankToNull(req.note()));
|
||||
p.put("userId", principal == null ? null : principal.userId());
|
||||
mapper.insertInbound(p);
|
||||
return findInboundView(tenant, eventId, id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public InboundView advanceInbound(String eventId, String id, InboundStatusBody req, KintexPrincipal principal) {
|
||||
requireManager(principal, eventId, "선입고 상태 전이는 매니저 이상만 수행할 수 있습니다.");
|
||||
String status = normalize(req.status());
|
||||
if (!INBOUND_STATUSES.contains(status)) {
|
||||
throw new ApiException(ErrorCode.VALIDATION,
|
||||
"선입고 상태는 inbound_scheduled|arrived_warehouse|delivered_booth 이어야 합니다.");
|
||||
}
|
||||
String tenant = TenantContext.currentTenantId();
|
||||
Map<String, Object> p = new LinkedHashMap<>();
|
||||
p.put("tenantId", tenant);
|
||||
p.put("eventId", eventId);
|
||||
p.put("id", id);
|
||||
p.put("status", status);
|
||||
p.put("note", blankToNull(req.note()));
|
||||
p.put("userId", principal == null ? null : principal.userId());
|
||||
if (mapper.updateInboundStatus(p) == 0) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "대상 선입고 건을 찾을 수 없습니다.");
|
||||
}
|
||||
return findInboundView(tenant, eventId, id);
|
||||
}
|
||||
|
||||
// ── 조회 헬퍼(단건 재조회) ────────────────────────────────────────────────
|
||||
private EquipmentRequestView findEquipmentView(String tenant, String eventId, String id) {
|
||||
return mapper.findEquipmentRequests(tenant, eventId).stream()
|
||||
.filter(r -> id.equals(str(r.get("id"))))
|
||||
.map(LogisticsExtService::toEquipmentView)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "대상 장비 신청을 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
private RentalOrderView findRentalView(String tenant, String eventId, String id) {
|
||||
return mapper.findRentalOrders(tenant, eventId).stream()
|
||||
.filter(r -> id.equals(str(r.get("id"))))
|
||||
.map(LogisticsExtService::toRentalView)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "대상 렌탈 주문을 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
private InboundView findInboundView(String tenant, String eventId, String id) {
|
||||
return mapper.findInbound(tenant, eventId).stream()
|
||||
.filter(r -> id.equals(str(r.get("id"))))
|
||||
.map(LogisticsExtService::toInboundView)
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ApiException(ErrorCode.NOT_FOUND, "대상 선입고 건을 찾을 수 없습니다."));
|
||||
}
|
||||
|
||||
// ── row → DTO 매핑 ───────────────────────────────────────────────────────
|
||||
private static EquipmentItem toEquipmentItem(Map<String, Object> r) {
|
||||
return new EquipmentItem(str(r.get("id")), str(r.get("code")), str(r.get("name")),
|
||||
str(r.get("category")), str(r.get("unit")), longVal(r.get("unitPrice")), str(r.get("spec")));
|
||||
}
|
||||
|
||||
private static EquipmentRequestView toEquipmentView(Map<String, Object> r) {
|
||||
return new EquipmentRequestView(str(r.get("id")), str(r.get("equipmentId")), str(r.get("equipmentName")),
|
||||
str(r.get("category")), longVal(r.get("unitPrice")), str(r.get("boothNo")), intVal(r.get("quantity")),
|
||||
str(r.get("useDate")), intVal(r.get("days")), longVal(r.get("amount")), str(r.get("status")),
|
||||
str(r.get("note")), str(r.get("rejectReason")), str(r.get("createdAt")));
|
||||
}
|
||||
|
||||
private static RentalItem toRentalItem(Map<String, Object> r) {
|
||||
return new RentalItem(str(r.get("id")), str(r.get("code")), str(r.get("name")),
|
||||
str(r.get("category")), str(r.get("unit")), longVal(r.get("dailyPrice")), str(r.get("spec")));
|
||||
}
|
||||
|
||||
private static RentalOrderView toRentalView(Map<String, Object> r) {
|
||||
return new RentalOrderView(str(r.get("id")), str(r.get("itemId")), str(r.get("itemName")),
|
||||
str(r.get("category")), longVal(r.get("dailyPrice")), str(r.get("boothNo")), intVal(r.get("quantity")),
|
||||
str(r.get("startDate")), str(r.get("endDate")), intVal(r.get("days")), longVal(r.get("amount")),
|
||||
str(r.get("status")), str(r.get("note")), str(r.get("createdAt")));
|
||||
}
|
||||
|
||||
private static InboundView toInboundView(Map<String, Object> r) {
|
||||
return new InboundView(str(r.get("id")), str(r.get("trackingNo")), str(r.get("carrier")),
|
||||
str(r.get("boothNo")), str(r.get("itemDesc")), intVal(r.get("quantity")), str(r.get("status")),
|
||||
str(r.get("etaDate")), str(r.get("warehouseAt")), str(r.get("deliveredAt")),
|
||||
str(r.get("note")), str(r.get("createdAt")));
|
||||
}
|
||||
|
||||
// ── 권한 ─────────────────────────────────────────────────────────────────
|
||||
private boolean isManager(KintexPrincipal principal, String eventId) {
|
||||
if (principal == null) {
|
||||
return false;
|
||||
}
|
||||
if (principal.hallManager()) {
|
||||
return true;
|
||||
}
|
||||
EventRole role = principal.roleFor(eventId);
|
||||
return role == EventRole.ORGANIZER || role == EventRole.HALL_MANAGER;
|
||||
}
|
||||
|
||||
private void requireManager(KintexPrincipal principal, String eventId, String message) {
|
||||
if (!isManager(principal, eventId)) {
|
||||
throw new ApiException(ErrorCode.FORBIDDEN, message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 값 헬퍼 ──────────────────────────────────────────────────────────────
|
||||
/** 기간(일) 산출 — start/end 유효 시 (end-start+1), 아니면 요청 days(최소 1). */
|
||||
private static int resolveDays(String start, String end, int fallbackDays) {
|
||||
LocalDate s = tryParse(start);
|
||||
LocalDate e = tryParse(end);
|
||||
if (s != null && e != null && !e.isBefore(s)) {
|
||||
return (int) (ChronoUnit.DAYS.between(s, e) + 1);
|
||||
}
|
||||
return Math.max(1, fallbackDays);
|
||||
}
|
||||
|
||||
private static LocalDate tryParse(String s) {
|
||||
String n = blankToNull(s);
|
||||
if (n == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(n);
|
||||
} catch (DateTimeParseException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static String parseDateOrNull(String s) {
|
||||
LocalDate d = tryParse(s);
|
||||
return d == null ? null : d.toString();
|
||||
}
|
||||
|
||||
private static String normalize(String s) {
|
||||
return s == null ? "" : s.trim().toLowerCase();
|
||||
}
|
||||
|
||||
private static String blankToNull(String s) {
|
||||
return s == null || s.isBlank() ? null : s.trim();
|
||||
}
|
||||
|
||||
private static int intVal(Object o) {
|
||||
if (o instanceof Number n) return n.intValue();
|
||||
if (o == null) return 0;
|
||||
try {
|
||||
return Integer.parseInt(String.valueOf(o));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static long longVal(Object o) {
|
||||
if (o instanceof Number n) return n.longValue();
|
||||
if (o == null) return 0L;
|
||||
try {
|
||||
return Long.parseLong(String.valueOf(o));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,159 @@
|
||||
package com.zioinfo.kintex.logistics.dto;
|
||||
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* M8 물류 확장 DTO 묶음 — F041 장비 신청 · F042 렌탈 주문 · F043 선입고 트래킹.
|
||||
* 금액(amount)은 서버 계산 권위(카탈로그 단가 정본). 상태 전이는 매니저 이상 API 로만 수행.
|
||||
*/
|
||||
public final class LogisticsExtDtos {
|
||||
|
||||
private LogisticsExtDtos() {
|
||||
}
|
||||
|
||||
// ── ① 부대장비 신청(F041) ────────────────────────────────────────────────
|
||||
/** 장비 카탈로그 품목(마스터). */
|
||||
public record EquipmentItem(
|
||||
String id,
|
||||
String code,
|
||||
String name,
|
||||
String category,
|
||||
String unit,
|
||||
long unitPrice,
|
||||
String spec
|
||||
) {
|
||||
}
|
||||
|
||||
/** 장비 신청 생성 요청 — 금액·단가는 서버가 카탈로그에서 재계산(요청 값 무시). */
|
||||
public record EquipmentRequestBody(
|
||||
@NotBlank String equipmentId,
|
||||
String boothNo,
|
||||
@Min(1) int quantity,
|
||||
String useDate,
|
||||
@Min(1) int days,
|
||||
String note
|
||||
) {
|
||||
}
|
||||
|
||||
/** 장비 신청 항목(조회/응답). status: requested|approved|rejected|done. */
|
||||
public record EquipmentRequestView(
|
||||
String id,
|
||||
String equipmentId,
|
||||
String equipmentName,
|
||||
String category,
|
||||
long unitPrice,
|
||||
String boothNo,
|
||||
int quantity,
|
||||
String useDate,
|
||||
int days,
|
||||
long amount,
|
||||
String status,
|
||||
String note,
|
||||
String rejectReason,
|
||||
String createdAt
|
||||
) {
|
||||
}
|
||||
|
||||
// ── ② 부대물품 렌탈 주문(F042) ──────────────────────────────────────────
|
||||
/** 렌탈 카탈로그 품목. */
|
||||
public record RentalItem(
|
||||
String id,
|
||||
String code,
|
||||
String name,
|
||||
String category,
|
||||
String unit,
|
||||
long dailyPrice,
|
||||
String spec
|
||||
) {
|
||||
}
|
||||
|
||||
/** 렌탈 주문 생성 요청 — 기간(days)·금액은 서버가 산출(start/end 우선, 없으면 days). */
|
||||
public record RentalOrderBody(
|
||||
@NotBlank String itemId,
|
||||
String boothNo,
|
||||
@Min(1) int quantity,
|
||||
String startDate,
|
||||
String endDate,
|
||||
@Min(1) int days,
|
||||
String note
|
||||
) {
|
||||
}
|
||||
|
||||
/** 렌탈 주문 항목(조회/응답). status: ordered|confirmed|cancelled|delivered. */
|
||||
public record RentalOrderView(
|
||||
String id,
|
||||
String itemId,
|
||||
String itemName,
|
||||
String category,
|
||||
long dailyPrice,
|
||||
String boothNo,
|
||||
int quantity,
|
||||
String startDate,
|
||||
String endDate,
|
||||
int days,
|
||||
long amount,
|
||||
String status,
|
||||
String note,
|
||||
String createdAt
|
||||
) {
|
||||
}
|
||||
|
||||
// ── ③ 선입고 트래킹(F043) ────────────────────────────────────────────────
|
||||
/** 선입고(운송장) 등록 요청. */
|
||||
public record InboundBody(
|
||||
@NotBlank String trackingNo,
|
||||
String carrier,
|
||||
String boothNo,
|
||||
String itemDesc,
|
||||
@Min(1) int quantity,
|
||||
String etaDate,
|
||||
String note
|
||||
) {
|
||||
}
|
||||
|
||||
/** 선입고 상태 전이 요청 — status: inbound_scheduled|arrived_warehouse|delivered_booth. */
|
||||
public record InboundStatusBody(
|
||||
@NotBlank String status,
|
||||
String note
|
||||
) {
|
||||
}
|
||||
|
||||
/** 선입고 항목(조회/응답). */
|
||||
public record InboundView(
|
||||
String id,
|
||||
String trackingNo,
|
||||
String carrier,
|
||||
String boothNo,
|
||||
String itemDesc,
|
||||
int quantity,
|
||||
String status,
|
||||
String etaDate,
|
||||
String warehouseAt,
|
||||
String deliveredAt,
|
||||
String note,
|
||||
String createdAt
|
||||
) {
|
||||
}
|
||||
|
||||
// ── 공용 상태 전이 요청(장비·렌탈) ───────────────────────────────────────
|
||||
/** 매니저 상태 전이 요청. status: (장비) approved|rejected|done / (렌탈) confirmed|cancelled|delivered. */
|
||||
public record StatusDecisionBody(
|
||||
@NotBlank String status,
|
||||
String reason
|
||||
) {
|
||||
}
|
||||
|
||||
/** 탭 조회 응답 묶음 — 카탈로그 + 신청/주문/선입고 목록. canManage=매니저 시점 액션 노출용. */
|
||||
public record LogisticsExtBoard(
|
||||
boolean canManage,
|
||||
List<EquipmentItem> equipmentCatalog,
|
||||
List<EquipmentRequestView> equipmentRequests,
|
||||
List<RentalItem> rentalCatalog,
|
||||
List<RentalOrderView> rentalOrders,
|
||||
List<InboundView> inbound
|
||||
) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
package com.zioinfo.kintex.settlement;
|
||||
|
||||
/**
|
||||
* F084 옥션 수수료 산정(순수 함수 — 단위 테스트 대상).
|
||||
*
|
||||
* <p><b>수수료 = 낙찰가(부가세 별도 경쟁 금액) × 요율</b>. 요율은 룰셋(master_data RATE.auctionFeeRate, 퍼센트)
|
||||
* 참조, 없으면 기본 정률 {@link #DEFAULT_FEE_RATE_PERCENT}%. 부가세 10% 별도 부과.
|
||||
*
|
||||
* <ul>
|
||||
* <li>base = 낙찰 응찰의 {@code bid.total}(부가세 별도 소계 = 경쟁/순위 금액)</li>
|
||||
* <li>fee = round(base × ratePercent / 100)</li>
|
||||
* <li>vat = round(fee × 10%)</li>
|
||||
* <li>total = fee + vat</li>
|
||||
* </ul>
|
||||
* <p><b>룰 키</b>: {@code master_data(category='RATE', code='auctionFeeRate')} — value_json 숫자(%).
|
||||
*/
|
||||
public final class AuctionFee {
|
||||
|
||||
/** 룰셋 미설정 시 기본 수수료율(%). */
|
||||
public static final double DEFAULT_FEE_RATE_PERCENT = 5.0;
|
||||
/** 룰 키(문서화·시드 정합). */
|
||||
public static final String RATE_CATEGORY = "RATE";
|
||||
public static final String RATE_CODE = "auctionFeeRate";
|
||||
|
||||
private static final double VAT_RATE = 0.10;
|
||||
|
||||
private AuctionFee() {
|
||||
}
|
||||
|
||||
/** 수수료 산정 결과. base=낙찰가(부가세 별도), fee=수수료 공급가액, vat=부가세, total=수수료 청구 총액. */
|
||||
public record Fee(long base, double ratePercent, long fee, long vat, long total) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 수수료 산정. ratePercent 가 null/음수/0 이면 기본 요율을 사용한다(0% 무료 정책은 미지원 — 기본 적용).
|
||||
* winningTotalExVat 가 0 이하이면 수수료 0(청구 생성 없음 판정은 호출부에서 수행).
|
||||
*/
|
||||
public static Fee compute(long winningTotalExVat, Double ratePercent) {
|
||||
double rate = (ratePercent == null || ratePercent <= 0) ? DEFAULT_FEE_RATE_PERCENT : ratePercent;
|
||||
if (winningTotalExVat <= 0) {
|
||||
return new Fee(Math.max(winningTotalExVat, 0), rate, 0, 0, 0);
|
||||
}
|
||||
long fee = Math.round(winningTotalExVat * rate / 100.0);
|
||||
long vat = Math.round(fee * VAT_RATE);
|
||||
return new Fee(winningTotalExVat, rate, fee, vat, fee + vat);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,62 @@
|
||||
package com.zioinfo.kintex.settlement;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* F085 환불·취소 규정 산정(순수 함수 — 단위 테스트 대상).
|
||||
*
|
||||
* <p><b>환불율 = 취소 시점 D-일(행사 개막일까지 남은 일수) 구간별 요율</b>. 구간(brackets)은 룰셋
|
||||
* {@code rulesets/refund-v1.0.json}에서 로드하며(코드가 아닌 데이터로 버전 관리 — 규정 개정 대응),
|
||||
* 이 클래스는 구간 선택과 금액 산식만 담당한다.
|
||||
*
|
||||
* <ul>
|
||||
* <li>base = 실 납입액(invoice.paid_amount) — 되돌려줄 수 있는 실제 금액</li>
|
||||
* <li>refund = round(base × refundRatePercent / 100)</li>
|
||||
* <li>forfeit = base − refund (공제·위약액)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>D-일이 음수(행사 후)이면 환불 0. 개막일 미확정(daysBefore=null)은 호출부에서 별도 처리한다.
|
||||
*/
|
||||
public final class RefundCalc {
|
||||
|
||||
private RefundCalc() {
|
||||
}
|
||||
|
||||
/** 환불 구간 — minDaysBefore 이상 남았을 때 refundRatePercent 적용. */
|
||||
public record Bracket(int minDaysBefore, double refundRatePercent, String label) {
|
||||
}
|
||||
|
||||
/**
|
||||
* D-일에 해당하는 구간을 선택한다. 구간이 minDaysBefore 내림차순이 아니어도 정확히 동작하도록
|
||||
* (조건을 만족하는 것 중 minDaysBefore가 가장 큰 구간)을 고른다. 매칭 없으면 null.
|
||||
* daysBefore가 음수(행사 후)이면 minDaysBefore=0 구간(또는 미매칭)로 귀결.
|
||||
*/
|
||||
public static Bracket bracketFor(int daysBefore, List<Bracket> brackets) {
|
||||
if (brackets == null || brackets.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Bracket best = null;
|
||||
for (Bracket b : brackets) {
|
||||
if (daysBefore >= b.minDaysBefore()) {
|
||||
if (best == null || b.minDaysBefore() > best.minDaysBefore()) {
|
||||
best = b;
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 환불액 = round(base × ratePercent / 100). base·rate 음수는 0으로 클램프. */
|
||||
public static long refundAmount(long base, double ratePercent) {
|
||||
if (base <= 0 || ratePercent <= 0) {
|
||||
return 0L;
|
||||
}
|
||||
double rate = Math.min(ratePercent, 100.0);
|
||||
return Math.round(base * rate / 100.0);
|
||||
}
|
||||
|
||||
/** 공제(위약)액 = base − refund (음수 방지). */
|
||||
public static long forfeitAmount(long base, long refund) {
|
||||
return Math.max(0L, base - Math.max(0L, refund));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package com.zioinfo.kintex.settlement;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* F085 환불·취소 규정 룰셋 로더 — classpath {@code rulesets/refund-v1.0.json}을 로드해
|
||||
* 환불 구간(brackets)과 버전을 제공한다. 룰은 코드가 아닌 데이터로 버전 관리되므로(규정 개정 대응)
|
||||
* 부팅 시 1회 읽어 캐시한다. 금액 산식은 {@link RefundCalc}(순수 함수) 위임.
|
||||
*
|
||||
* <p>파싱 실패 시(파일 훼손 등) 빈 구간 + fail-safe 기본값을 유지한다(부팅은 계속). 이 경우 환불 산정은
|
||||
* "개막일 미확정"과 동일하게 자동 0% 처리되어 재무 수기 검토로 이관된다.
|
||||
*/
|
||||
@Component
|
||||
public class RefundPolicy {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RefundPolicy.class);
|
||||
private static final String RESOURCE = "rulesets/refund-v1.0.json";
|
||||
|
||||
private final String version;
|
||||
private final List<RefundCalc.Bracket> brackets;
|
||||
private final double afterEventRatePercent;
|
||||
private final double unknownDateRatePercent;
|
||||
|
||||
public RefundPolicy(ObjectMapper objectMapper) {
|
||||
String v = "refund-v1.0";
|
||||
List<RefundCalc.Bracket> b = new ArrayList<>();
|
||||
double afterEvent = 0.0;
|
||||
double unknown = 0.0;
|
||||
try (InputStream in = new ClassPathResource(RESOURCE).getInputStream()) {
|
||||
JsonNode root = objectMapper.readTree(in);
|
||||
v = root.path("rulesetVersion").asText(v);
|
||||
JsonNode policy = root.path("policy");
|
||||
afterEvent = policy.path("afterEventRefundRatePercent").asDouble(0.0);
|
||||
unknown = policy.path("unknownDateRefundRatePercent").asDouble(0.0);
|
||||
JsonNode arr = policy.path("brackets");
|
||||
if (arr.isArray()) {
|
||||
for (JsonNode n : arr) {
|
||||
b.add(new RefundCalc.Bracket(
|
||||
n.path("minDaysBefore").asInt(0),
|
||||
n.path("refundRatePercent").asDouble(0.0),
|
||||
n.path("label").asText("")));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("환불 룰셋 로드 실패({}): {} — fail-safe 기본값(빈 구간·0%) 적용", RESOURCE, e.getMessage());
|
||||
}
|
||||
this.version = v;
|
||||
this.brackets = Collections.unmodifiableList(b);
|
||||
this.afterEventRatePercent = afterEvent;
|
||||
this.unknownDateRatePercent = unknown;
|
||||
}
|
||||
|
||||
public String version() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public List<RefundCalc.Bracket> brackets() {
|
||||
return brackets;
|
||||
}
|
||||
|
||||
public double afterEventRatePercent() {
|
||||
return afterEventRatePercent;
|
||||
}
|
||||
|
||||
public double unknownDateRatePercent() {
|
||||
return unknownDateRatePercent;
|
||||
}
|
||||
}
|
||||
@ -80,4 +80,51 @@ public class SettlementController {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.recordPayment(id, principal, req));
|
||||
}
|
||||
|
||||
// ── F085 환불·취소 규정 ────────────────────────────────────────────────────
|
||||
/** GET /api/settlement/invoices/{id}/refund-quote?cancelDate= — 환불 산정 미리보기(매니저 이상). */
|
||||
@GetMapping("/invoices/{id}/refund-quote")
|
||||
public ApiResponse<RefundQuote> refundQuote(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestParam(required = false) String cancelDate) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.refundQuote(id, principal, cancelDate));
|
||||
}
|
||||
|
||||
/** POST /api/settlement/invoices/{id}/cancel — 청구 취소·환불 산정 기록(매니저 이상). 실 PG 환불 없음. */
|
||||
@Audited(action = "SETTLEMENT_CANCEL", targetType = "invoice")
|
||||
@PostMapping("/invoices/{id}/cancel")
|
||||
public ApiResponse<CancelResult> cancel(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody(required = false) CancelInvoiceRequest req) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.cancel(id, principal, req));
|
||||
}
|
||||
|
||||
// ── F083 세금계산서 ────────────────────────────────────────────────────────
|
||||
/** GET /api/settlement/invoices/{id}/tax-invoice — 세금계산서 조회(없으면 data=null). */
|
||||
@GetMapping("/invoices/{id}/tax-invoice")
|
||||
public ApiResponse<TaxInvoiceDto> getTaxInvoice(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.getTaxInvoice(id, principal));
|
||||
}
|
||||
|
||||
/** POST /api/settlement/invoices/{id}/tax-invoice — 세금계산서 발행(매니저 이상, 멱등). 국세청 미전송. */
|
||||
@Audited(action = "SETTLEMENT_TAX_ISSUE", targetType = "tax_invoice")
|
||||
@PostMapping("/invoices/{id}/tax-invoice")
|
||||
public ApiResponse<TaxInvoiceDto> issueTaxInvoice(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
guard.require(principal);
|
||||
return ApiResponse.ok(service.issueTaxInvoice(id, principal));
|
||||
}
|
||||
|
||||
// ── 정산 리포트 ────────────────────────────────────────────────────────────
|
||||
/** GET /api/settlement/report?eventId= — 카테고리/상태별 합계·수납율·미수 + 상세 행(CSV용). */
|
||||
@GetMapping("/report")
|
||||
public ApiResponse<SettlementReport> report(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@RequestParam String eventId) {
|
||||
guard.requireEventAccess(principal, eventId);
|
||||
return ApiResponse.ok(service.report(eventId));
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,13 +18,13 @@ import java.util.Map;
|
||||
@Mapper
|
||||
public interface SettlementMapper {
|
||||
|
||||
// ── 대시보드 집계 ─────────────────────────────────────────────────────────
|
||||
// ── 대시보드 집계 (취소분 제외 — 재무 KPI 왜곡 방지) ────────────────────────
|
||||
@Select("""
|
||||
SELECT COALESCE(sum(total), 0) AS "billed",
|
||||
COALESCE(sum(paid_amount), 0) AS "collected",
|
||||
count(*) AS "invoiceCount",
|
||||
count(*) FILTER (WHERE status = 'overdue') AS "overdueCount"
|
||||
FROM invoice WHERE event_id = #{eventId}
|
||||
FROM invoice WHERE event_id = #{eventId} AND status <> 'cancelled'
|
||||
""")
|
||||
Map<String, Object> dashboardTotals(@Param("eventId") String eventId);
|
||||
|
||||
@ -52,6 +52,7 @@ public interface SettlementMapper {
|
||||
to_char(i.issued_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "issuedAt",
|
||||
to_char(i.paid_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "paidAt",
|
||||
i.note,
|
||||
COALESCE(i.category, 'rental') AS "category",
|
||||
i.line_items::text AS "lineItems"
|
||||
FROM invoice i
|
||||
""";
|
||||
@ -180,4 +181,138 @@ public interface SettlementMapper {
|
||||
/** 행사 청구서 번호 채번용 — 현재 최대 순번(YYYY 접두 무관 단순 시퀀스). */
|
||||
@Select("SELECT count(*) FROM invoice WHERE event_id = #{eventId}")
|
||||
int countInvoicesForSeq(@Param("eventId") String eventId);
|
||||
|
||||
// ── F084 옥션 수수료 청구 연동 ────────────────────────────────────────────
|
||||
/** 옥션 수수료율(%) — master_data RATE.auctionFeeRate. 없으면 null(→ 기본 5%). */
|
||||
@Select("""
|
||||
SELECT (value_json #>> '{}')::numeric
|
||||
FROM master_data WHERE category = 'RATE' AND code = 'auctionFeeRate'
|
||||
""")
|
||||
Double findAuctionFeeRate();
|
||||
|
||||
/** 멱등 판정 — 같은 낙찰(source_ref)로 이미 생성된 수수료 청구가 있는지. */
|
||||
@Select("SELECT count(*) FROM invoice WHERE source_ref = #{sourceRef}")
|
||||
int countInvoiceBySourceRef(@Param("sourceRef") String sourceRef);
|
||||
|
||||
/**
|
||||
* 옥션 수수료 청구서 삽입(멱등) — category='auction_fee', source_ref=낙찰 id.
|
||||
* source_ref 부분 유니크 인덱스(uq_invoice_source_ref)로 중복 생성 원천 차단.
|
||||
*/
|
||||
@Insert("""
|
||||
INSERT INTO invoice (id, event_id, exhibitor_name, invoice_no, category, source_ref,
|
||||
subtotal, vat, total, status, line_items, note, created_by)
|
||||
VALUES (#{id}, #{eventId}, #{exhibitorName}, #{invoiceNo}, 'auction_fee', #{sourceRef},
|
||||
#{subtotal}, #{vat}, #{total}, 'pending', CAST(#{lineItems} AS jsonb), #{note}, #{createdBy})
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
int insertAuctionFeeInvoice(Map<String, Object> p);
|
||||
|
||||
// ── F085 환불·취소 ────────────────────────────────────────────────────────
|
||||
/** 행사 개막일(YYYY-MM-DD) — 취소 시점 D-일 산정 원천. 없으면 null. */
|
||||
@Select("SELECT to_char(start_date, 'YYYY-MM-DD') FROM event WHERE id = #{eventId}")
|
||||
String findEventStartDate(@Param("eventId") String eventId);
|
||||
|
||||
@Select("SELECT name FROM event WHERE id = #{eventId}")
|
||||
String findEventName(@Param("eventId") String eventId);
|
||||
|
||||
/** 취소 전이 — pending/invoiced/overdue/paid → cancelled. 이미 취소면 미영향(멱등). */
|
||||
@Update("""
|
||||
UPDATE invoice
|
||||
SET status = 'cancelled', cancelled_at = now(), cancel_reason = #{reason}, updated_at = now()
|
||||
WHERE id = #{id} AND status <> 'cancelled'
|
||||
""")
|
||||
int cancelInvoice(@Param("id") String id, @Param("reason") String reason);
|
||||
|
||||
@Insert("""
|
||||
INSERT INTO invoice_refund (id, invoice_id, event_id, base_amount, refund_rate_percent,
|
||||
refund_amount, forfeit_amount, days_before_event, bracket_label,
|
||||
reason, ruleset_version, created_by)
|
||||
VALUES (#{id}, #{invoiceId}, #{eventId}, #{baseAmount}, #{refundRatePercent},
|
||||
#{refundAmount}, #{forfeitAmount}, #{daysBeforeEvent}, #{bracketLabel},
|
||||
#{reason}, #{rulesetVersion}, #{createdBy})
|
||||
""")
|
||||
int insertRefund(Map<String, Object> p);
|
||||
|
||||
// ── F083 세금계산서 ────────────────────────────────────────────────────────
|
||||
@Select("""
|
||||
SELECT id,
|
||||
invoice_id AS "invoiceId",
|
||||
event_id AS "eventId",
|
||||
tax_invoice_no AS "taxInvoiceNo",
|
||||
supplier_name AS "supplierName",
|
||||
supplier_biz_no AS "supplierBizNo",
|
||||
supplier_ceo AS "supplierCeo",
|
||||
supplier_addr AS "supplierAddr",
|
||||
buyer_name AS "buyerName",
|
||||
supply_amount AS "supplyAmount",
|
||||
vat, total,
|
||||
item_summary AS "itemSummary",
|
||||
status,
|
||||
to_char(issued_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "issuedAt"
|
||||
FROM tax_invoice WHERE invoice_id = #{invoiceId}
|
||||
""")
|
||||
Map<String, Object> findTaxInvoiceByInvoice(@Param("invoiceId") String invoiceId);
|
||||
|
||||
@Select("SELECT count(*) FROM tax_invoice WHERE event_id = #{eventId}")
|
||||
int countTaxInvoiceForSeq(@Param("eventId") String eventId);
|
||||
|
||||
/** 세금계산서 발행(멱등) — 청구서당 1건(UNIQUE invoice_id). 재발행 시 미생성. */
|
||||
@Insert("""
|
||||
INSERT INTO tax_invoice (id, invoice_id, event_id, tax_invoice_no,
|
||||
supplier_name, supplier_biz_no, supplier_ceo, supplier_addr,
|
||||
buyer_name, supply_amount, vat, total, item_summary, issued_by)
|
||||
VALUES (#{id}, #{invoiceId}, #{eventId}, #{taxInvoiceNo},
|
||||
#{supplierName}, #{supplierBizNo}, #{supplierCeo}, #{supplierAddr},
|
||||
#{buyerName}, #{supplyAmount}, #{vat}, #{total}, #{itemSummary}, #{issuedBy})
|
||||
ON CONFLICT (invoice_id) DO NOTHING
|
||||
""")
|
||||
int insertTaxInvoice(Map<String, Object> p);
|
||||
|
||||
// ── 정산 리포트 (취소분은 재무 합계에서 제외) ────────────────────────────────
|
||||
@Select("""
|
||||
SELECT COALESCE(sum(total), 0) AS "billed",
|
||||
COALESCE(sum(paid_amount), 0) AS "collected",
|
||||
COALESCE(sum(total - paid_amount), 0) AS "outstanding",
|
||||
count(*) AS "invoiceCount"
|
||||
FROM invoice WHERE event_id = #{eventId} AND status <> 'cancelled'
|
||||
""")
|
||||
Map<String, Object> reportTotals(@Param("eventId") String eventId);
|
||||
|
||||
@Select("""
|
||||
SELECT COALESCE(category, 'rental') AS "key",
|
||||
count(*) AS "invoiceCount",
|
||||
COALESCE(sum(total), 0) AS "billed",
|
||||
COALESCE(sum(paid_amount), 0) AS "collected",
|
||||
COALESCE(sum(total - paid_amount), 0) AS "outstanding"
|
||||
FROM invoice WHERE event_id = #{eventId} AND status <> 'cancelled'
|
||||
GROUP BY COALESCE(category, 'rental')
|
||||
ORDER BY "billed" DESC
|
||||
""")
|
||||
List<Map<String, Object>> reportByCategory(@Param("eventId") String eventId);
|
||||
|
||||
@Select("""
|
||||
SELECT status AS "key",
|
||||
count(*) AS "invoiceCount",
|
||||
COALESCE(sum(total), 0) AS "billed",
|
||||
COALESCE(sum(paid_amount), 0) AS "collected",
|
||||
COALESCE(sum(total - paid_amount), 0) AS "outstanding"
|
||||
FROM invoice WHERE event_id = #{eventId} AND status <> 'cancelled'
|
||||
GROUP BY status
|
||||
ORDER BY status
|
||||
""")
|
||||
List<Map<String, Object>> reportByStatus(@Param("eventId") String eventId);
|
||||
|
||||
@Select("""
|
||||
SELECT invoice_no AS "invoiceNo",
|
||||
exhibitor_name AS "exhibitorName",
|
||||
COALESCE(category,'rental') AS "category",
|
||||
status AS "status",
|
||||
subtotal, vat, total,
|
||||
paid_amount AS "paidAmount",
|
||||
(total - paid_amount) AS "outstanding",
|
||||
to_char(due_date,'YYYY-MM-DD') AS "dueDate"
|
||||
FROM invoice WHERE event_id = #{eventId}
|
||||
ORDER BY invoice_no
|
||||
""")
|
||||
List<Map<String, Object>> reportRows(@Param("eventId") String eventId);
|
||||
}
|
||||
|
||||
@ -4,12 +4,18 @@ import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zioinfo.kintex.auth.EventRole;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.PageResponse;
|
||||
import com.zioinfo.kintex.common.audit.AuditLogService;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.settlement.dto.SettlementDtos.*;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@ -33,13 +39,30 @@ public class SettlementService {
|
||||
private static final double VAT_RATE = 0.10;
|
||||
private static final String PAYMENT_NOTE =
|
||||
"결제는 외부 PG(전자결제)로 위임됩니다. 본 화면은 청구·수납 현황 관리 전용이며 카드정보는 저장하지 않습니다.";
|
||||
/** F085 환불 PG 미집행 고지(G2 재무 게이트). */
|
||||
private static final String REFUND_PG_NOTE =
|
||||
"환불액은 규정 기준 서버 산정·기록 값이며, 실제 환불(전자결제/PG 역거래)은 킨텍스 재무 확정 후 집행됩니다.";
|
||||
/** F083 세금계산서 국세청 미전송 고지(G2 재무 게이트). */
|
||||
private static final String TAX_ISSUE_NOTE =
|
||||
"본 세금계산서는 시스템 내 문서 데이터이며 국세청 전자세금계산서 전송은 킨텍스 재무 프로세스로 별도 처리됩니다.";
|
||||
|
||||
// ── 공급자(킨텍스) 상수 — 재무 확정 전 플레이스홀더(G2). 민감정보 아님(공개 정보/플레이스홀더). ──
|
||||
private static final String SUPPLIER_NAME = "킨텍스(KINTEX, 한국국제전시장)";
|
||||
private static final String SUPPLIER_BIZ_NO = "000-00-00000"; // 재무 확정 전 플레이스홀더
|
||||
private static final String SUPPLIER_CEO = "대표이사"; // 재무 확정 전 플레이스홀더
|
||||
private static final String SUPPLIER_ADDR = "경기도 고양시 일산서구 킨텍스로 217-60";
|
||||
|
||||
private static final ObjectMapper JSON = new ObjectMapper();
|
||||
private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_LOCAL_DATE;
|
||||
|
||||
private final SettlementMapper mapper;
|
||||
private final AuditLogService audit;
|
||||
private final RefundPolicy refundPolicy;
|
||||
|
||||
public SettlementService(SettlementMapper mapper) {
|
||||
public SettlementService(SettlementMapper mapper, AuditLogService audit, RefundPolicy refundPolicy) {
|
||||
this.mapper = mapper;
|
||||
this.audit = audit;
|
||||
this.refundPolicy = refundPolicy;
|
||||
}
|
||||
|
||||
// ── 대시보드 ──────────────────────────────────────────────────────────────
|
||||
@ -107,7 +130,7 @@ public class SettlementService {
|
||||
str(r.get("exhibitorName")), subtotal, vat, total, paid,
|
||||
Math.max(0, total - paid), str(r.get("status")),
|
||||
str(r.get("dueDate")), str(r.get("issuedAt")), str(r.get("paidAt")),
|
||||
str(r.get("note")), lines, payments, canManage);
|
||||
str(r.get("note")), categoryOf(r.get("category")), lines, payments, canManage);
|
||||
}
|
||||
|
||||
// ── 발행(pending → invoiced) ──────────────────────────────────────────────
|
||||
@ -217,7 +240,7 @@ public class SettlementService {
|
||||
if (n > 0) {
|
||||
created++;
|
||||
out.add(new InvoiceSummary(str(p.get("id")), eventId, invoiceNo, name,
|
||||
subtotal, vat, total, 0, total, "pending", null, null));
|
||||
subtotal, vat, total, 0, total, "pending", null, null, "rental"));
|
||||
} else {
|
||||
skipped++;
|
||||
seq--; // 충돌로 미생성 — 번호 되돌림
|
||||
@ -227,6 +250,321 @@ public class SettlementService {
|
||||
return new GenerateResult(eventId, created, skipped, out);
|
||||
}
|
||||
|
||||
// ── F084 옥션 수수료 청구 연동 ────────────────────────────────────────────
|
||||
/**
|
||||
* 낙찰 확정 시 옥션 수수료를 계산해 정산 청구서(category=auction_fee)를 생성한다.
|
||||
*
|
||||
* <p><b>멱등</b>: 같은 낙찰(awardId=source_ref)로는 1건만 — 재호출/재시도 시 중복 생성하지 않는다
|
||||
* (source_ref 부분 유니크 인덱스 + 사전 카운트). 수수료 0(요율/낙찰가 0)이면 청구를 생성하지 않는다.
|
||||
* 감사({@code AUCTION_FEE_INVOICE})는 프로그램적으로 기록한다(민감정보 미포함).
|
||||
*
|
||||
* @param winningTotalExVat 낙찰 응찰의 부가세 별도 경쟁 금액(bid.total).
|
||||
* @return 생성 결과(생성 여부·수수료 금액). 업무 흐름을 막지 않도록 예외를 던지지 않는다.
|
||||
*/
|
||||
@Transactional
|
||||
public AuctionFeeResult createAuctionFeeInvoice(String eventId, String awardId, String auctionId,
|
||||
String auctionTitle, String companyName,
|
||||
long winningTotalExVat, KintexPrincipal actor) {
|
||||
AuctionFee.Fee fee = AuctionFee.compute(winningTotalExVat, mapper.findAuctionFeeRate());
|
||||
if (fee.fee() <= 0) {
|
||||
return new AuctionFeeResult(false, null, null, 0, 0, 0, fee.ratePercent(), "수수료 0(요율/낙찰가)");
|
||||
}
|
||||
if (nullIfBlank(awardId) == null || mapper.countInvoiceBySourceRef(awardId) > 0) {
|
||||
return new AuctionFeeResult(false, null, null, fee.fee(), fee.vat(), fee.total(),
|
||||
fee.ratePercent(), "이미 생성된 수수료 청구(멱등)");
|
||||
}
|
||||
|
||||
String coName = companyName == null ? "낙찰업체" : companyName;
|
||||
String exhibitorName = clip(coName + " · 옥션수수료(" + auctionId + ")", 200);
|
||||
String invoiceNo = clip("FEE-" + auctionId, 40);
|
||||
String rateLabel = fmtRate(fee.ratePercent());
|
||||
|
||||
List<Map<String, Object>> lines = new ArrayList<>();
|
||||
lines.add(lineItem("AUCTION_FEE",
|
||||
"옥션 낙찰 수수료 (낙찰가 " + fee.base() + "원 · 요율 " + rateLabel + "%)",
|
||||
1, fee.fee(), fee.fee()));
|
||||
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
p.put("id", "inv-" + UUID.randomUUID().toString().substring(0, 12));
|
||||
p.put("eventId", eventId);
|
||||
p.put("exhibitorName", exhibitorName);
|
||||
p.put("invoiceNo", invoiceNo);
|
||||
p.put("sourceRef", awardId);
|
||||
p.put("subtotal", fee.fee());
|
||||
p.put("vat", fee.vat());
|
||||
p.put("total", fee.total());
|
||||
p.put("lineItems", toJson(lines));
|
||||
p.put("note", clip("옥션 낙찰 수수료 자동 청구 — " + (auctionTitle == null ? auctionId : auctionTitle), 500));
|
||||
p.put("createdBy", actor == null ? "system" : actor.userId());
|
||||
int n = mapper.insertAuctionFeeInvoice(p);
|
||||
if (n == 0) {
|
||||
// 경합/멱등 충돌 — 이미 생성됨으로 간주.
|
||||
return new AuctionFeeResult(false, null, invoiceNo, fee.fee(), fee.vat(), fee.total(),
|
||||
fee.ratePercent(), "이미 생성된 수수료 청구(멱등)");
|
||||
}
|
||||
audit.record(
|
||||
actor == null ? "system" : actor.userId(),
|
||||
actor == null ? null : actor.displayName(),
|
||||
"AUCTION_FEE_INVOICE", "invoice", str(p.get("id")), eventId,
|
||||
"옥션 수수료 청구 생성 " + invoiceNo + " · 요율 " + rateLabel + "% · 총액 " + fee.total() + "원",
|
||||
"SUCCESS");
|
||||
return new AuctionFeeResult(true, str(p.get("id")), invoiceNo,
|
||||
fee.fee(), fee.vat(), fee.total(), fee.ratePercent(), null);
|
||||
}
|
||||
|
||||
// ── F085 환불·취소 규정 ──────────────────────────────────────────────────
|
||||
/** 환불 산정 미리보기(취소 확정 전) — 매니저 이상. cancelDate 미지정 시 오늘 기준. */
|
||||
public RefundQuote refundQuote(String id, KintexPrincipal principal, String cancelDate) {
|
||||
Map<String, Object> r = mapper.findInvoice(id);
|
||||
if (r == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
requireManager(principal, str(r.get("eventId")));
|
||||
return computeRefundQuote(r, cancelDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 청구 취소 — 환불액 서버 산정 + 상태 cancelled + 환불 레코드 기록. 실 PG 환불 없음(G2 게이트).
|
||||
* 이미 취소된 청구는 409. 환불 근거(구간·요율·기준액)를 함께 반환한다.
|
||||
*/
|
||||
@Transactional
|
||||
public CancelResult cancel(String id, KintexPrincipal principal, CancelInvoiceRequest req) {
|
||||
Map<String, Object> r = mapper.findInvoice(id);
|
||||
if (r == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
String eventId = str(r.get("eventId"));
|
||||
requireManager(principal, eventId);
|
||||
if ("cancelled".equals(str(r.get("status")))) {
|
||||
throw new ApiException(ErrorCode.CONFLICT); // 이미 취소된 청구
|
||||
}
|
||||
String cancelDate = req == null ? null : req.cancelDate();
|
||||
String reason = clip(req == null ? null : nullIfBlank(req.reason()), 500);
|
||||
RefundQuote q = computeRefundQuote(r, cancelDate);
|
||||
|
||||
int n = mapper.cancelInvoice(id, reason);
|
||||
if (n == 0) {
|
||||
throw new ApiException(ErrorCode.CONFLICT); // 경합으로 이미 취소됨
|
||||
}
|
||||
|
||||
Map<String, Object> ref = new HashMap<>();
|
||||
ref.put("id", "rfnd-" + UUID.randomUUID().toString().substring(0, 12));
|
||||
ref.put("invoiceId", id);
|
||||
ref.put("eventId", eventId);
|
||||
ref.put("baseAmount", q.baseAmount());
|
||||
ref.put("refundRatePercent", q.refundRatePercent());
|
||||
ref.put("refundAmount", q.refundAmount());
|
||||
ref.put("forfeitAmount", q.forfeitAmount());
|
||||
ref.put("daysBeforeEvent", q.daysBeforeEvent());
|
||||
ref.put("bracketLabel", clip(q.bracketLabel(), 80));
|
||||
ref.put("reason", reason);
|
||||
ref.put("rulesetVersion", refundPolicy.version());
|
||||
ref.put("createdBy", principal == null ? "system" : principal.userId());
|
||||
mapper.insertRefund(ref);
|
||||
|
||||
audit.record(
|
||||
principal == null ? "system" : principal.userId(),
|
||||
principal == null ? null : principal.displayName(),
|
||||
"SETTLEMENT_REFUND", "invoice", id, eventId,
|
||||
"청구 취소 환불 산정 " + str(r.get("invoiceNo")) + " · 요율 " + fmtRate(q.refundRatePercent())
|
||||
+ "% · 환불 " + q.refundAmount() + "원 · 공제 " + q.forfeitAmount() + "원",
|
||||
"SUCCESS");
|
||||
|
||||
return new CancelResult(getDetail(id, principal), q);
|
||||
}
|
||||
|
||||
private RefundQuote computeRefundQuote(Map<String, Object> inv, String cancelDate) {
|
||||
String invoiceId = str(inv.get("id"));
|
||||
String invoiceNo = str(inv.get("invoiceNo"));
|
||||
String status = str(inv.get("status"));
|
||||
String eventId = str(inv.get("eventId"));
|
||||
long total = lng(inv.get("total"));
|
||||
long base = lng(inv.get("paidAmount")); // 되돌려줄 수 있는 실 납입액 기준
|
||||
|
||||
String startStr = mapper.findEventStartDate(eventId);
|
||||
LocalDate cancel = parseDateOrToday(cancelDate);
|
||||
|
||||
Integer daysBefore = null;
|
||||
boolean resolved;
|
||||
double rate;
|
||||
String label;
|
||||
if (startStr != null) {
|
||||
LocalDate start = LocalDate.parse(startStr);
|
||||
int d = (int) ChronoUnit.DAYS.between(cancel, start);
|
||||
daysBefore = d;
|
||||
if (d < 0) {
|
||||
rate = refundPolicy.afterEventRatePercent();
|
||||
label = "행사 종료 후";
|
||||
resolved = true;
|
||||
} else {
|
||||
RefundCalc.Bracket b = RefundCalc.bracketFor(d, refundPolicy.brackets());
|
||||
if (b != null) {
|
||||
rate = b.refundRatePercent();
|
||||
label = b.label();
|
||||
resolved = true;
|
||||
} else {
|
||||
rate = refundPolicy.unknownDateRatePercent();
|
||||
label = "구간 미매칭 — 재무 검토";
|
||||
resolved = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
rate = refundPolicy.unknownDateRatePercent();
|
||||
label = "개막일 미확정 — 재무 검토";
|
||||
resolved = false;
|
||||
}
|
||||
long refund = RefundCalc.refundAmount(base, rate);
|
||||
long forfeit = RefundCalc.forfeitAmount(base, refund);
|
||||
return new RefundQuote(invoiceId, invoiceNo, status, total, base, rate, refund, forfeit,
|
||||
daysBefore, label, startStr, resolved, refundPolicy.version(), REFUND_PG_NOTE);
|
||||
}
|
||||
|
||||
// ── F083 세금계산서 ──────────────────────────────────────────────────────
|
||||
/** 세금계산서 조회(있으면 반환, 없으면 null 계약 — 컨트롤러에서 처리). */
|
||||
public TaxInvoiceDto getTaxInvoice(String id, KintexPrincipal principal) {
|
||||
Map<String, Object> inv = mapper.findInvoice(id);
|
||||
if (inv == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
Map<String, Object> t = mapper.findTaxInvoiceByInvoice(id);
|
||||
return t == null ? null : toTaxInvoice(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* 세금계산서 발행(문서 데이터 생성) — 매니저 이상. 청구서당 1건(멱등). 발행 전(pending)·취소 청구는 409.
|
||||
* 국세청 전송 없음(G2 게이트). 공급자=킨텍스 상수, 공급받는자=참가업체 표시명, 금액=청구 스냅샷.
|
||||
*/
|
||||
@Transactional
|
||||
public TaxInvoiceDto issueTaxInvoice(String id, KintexPrincipal principal) {
|
||||
Map<String, Object> inv = mapper.findInvoice(id);
|
||||
if (inv == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
String eventId = str(inv.get("eventId"));
|
||||
requireManager(principal, eventId);
|
||||
String status = str(inv.get("status"));
|
||||
if ("pending".equals(status) || "cancelled".equals(status)) {
|
||||
throw new ApiException(ErrorCode.CONFLICT); // 발행 전 초안 또는 취소 청구는 세금계산서 발행 불가
|
||||
}
|
||||
Map<String, Object> existing = mapper.findTaxInvoiceByInvoice(id);
|
||||
if (existing != null) {
|
||||
return toTaxInvoice(existing); // 멱등 — 이미 발행됨
|
||||
}
|
||||
|
||||
long supply = lng(inv.get("subtotal"));
|
||||
long vat = lng(inv.get("vat"));
|
||||
long total = lng(inv.get("total"));
|
||||
String cat = categoryOf(inv.get("category"));
|
||||
String itemSummary = "auction_fee".equals(cat)
|
||||
? "옥션 낙찰 수수료"
|
||||
: "부스 임대료 및 부대비용(유틸리티 등)";
|
||||
int seq = mapper.countTaxInvoiceForSeq(eventId) + 1;
|
||||
String taxNo = clip("TAX-" + eventSuffix(eventId) + "-" + String.format("%04d", seq), 40);
|
||||
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
String taxId = "tax-" + UUID.randomUUID().toString().substring(0, 12);
|
||||
p.put("id", taxId);
|
||||
p.put("invoiceId", id);
|
||||
p.put("eventId", eventId);
|
||||
p.put("taxInvoiceNo", taxNo);
|
||||
p.put("supplierName", SUPPLIER_NAME);
|
||||
p.put("supplierBizNo", SUPPLIER_BIZ_NO);
|
||||
p.put("supplierCeo", SUPPLIER_CEO);
|
||||
p.put("supplierAddr", SUPPLIER_ADDR);
|
||||
p.put("buyerName", clip(str(inv.get("exhibitorName")), 200));
|
||||
p.put("supplyAmount", supply);
|
||||
p.put("vat", vat);
|
||||
p.put("total", total);
|
||||
p.put("itemSummary", itemSummary);
|
||||
p.put("issuedBy", principal == null ? "system" : principal.userId());
|
||||
mapper.insertTaxInvoice(p);
|
||||
|
||||
Map<String, Object> t = mapper.findTaxInvoiceByInvoice(id);
|
||||
return t == null ? null : toTaxInvoice(t);
|
||||
}
|
||||
|
||||
private TaxInvoiceDto toTaxInvoice(Map<String, Object> t) {
|
||||
return new TaxInvoiceDto(
|
||||
str(t.get("id")), str(t.get("invoiceId")), str(t.get("eventId")),
|
||||
str(t.get("taxInvoiceNo")), str(t.get("supplierName")), str(t.get("supplierBizNo")),
|
||||
str(t.get("supplierCeo")), str(t.get("supplierAddr")), str(t.get("buyerName")),
|
||||
lng(t.get("supplyAmount")), lng(t.get("vat")), lng(t.get("total")),
|
||||
str(t.get("itemSummary")), str(t.get("status")), str(t.get("issuedAt")), TAX_ISSUE_NOTE);
|
||||
}
|
||||
|
||||
// ── 정산 리포트 ──────────────────────────────────────────────────────────
|
||||
/** 행사 정산 리포트 — 카테고리/상태별 합계·수납율·미수 + 상세 행(CSV). 취소분은 합계에서 제외. */
|
||||
public SettlementReport report(String eventId) {
|
||||
Map<String, Object> tot = mapper.reportTotals(eventId);
|
||||
long billed = lng(tot == null ? null : tot.get("billed"));
|
||||
long collected = lng(tot == null ? null : tot.get("collected"));
|
||||
long outstanding = lng(tot == null ? null : tot.get("outstanding"));
|
||||
long count = lng(tot == null ? null : tot.get("invoiceCount"));
|
||||
double rate = billed > 0 ? round1((collected * 100.0) / billed) : 0.0;
|
||||
|
||||
List<ReportGroup> byCategory = toGroups(mapper.reportByCategory(eventId));
|
||||
List<ReportGroup> byStatus = toGroups(mapper.reportByStatus(eventId));
|
||||
|
||||
List<ReportRow> rows = new ArrayList<>();
|
||||
List<Map<String, Object>> rr = mapper.reportRows(eventId);
|
||||
if (rr != null) {
|
||||
for (Map<String, Object> r : rr) {
|
||||
rows.add(new ReportRow(
|
||||
str(r.get("invoiceNo")), str(r.get("exhibitorName")), categoryOf(r.get("category")),
|
||||
str(r.get("status")), lng(r.get("subtotal")), lng(r.get("vat")), lng(r.get("total")),
|
||||
lng(r.get("paidAmount")), lng(r.get("outstanding")), str(r.get("dueDate"))));
|
||||
}
|
||||
}
|
||||
String name = mapper.findEventName(eventId);
|
||||
String generatedAt = OffsetDateTime.now(ZoneOffset.UTC)
|
||||
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"));
|
||||
return new SettlementReport(eventId, name, generatedAt, billed, collected, outstanding,
|
||||
rate, count, byCategory, byStatus, rows);
|
||||
}
|
||||
|
||||
private List<ReportGroup> toGroups(List<Map<String, Object>> rows) {
|
||||
List<ReportGroup> out = new ArrayList<>();
|
||||
if (rows == null) {
|
||||
return out;
|
||||
}
|
||||
for (Map<String, Object> r : rows) {
|
||||
long billed = lng(r.get("billed"));
|
||||
long collected = lng(r.get("collected"));
|
||||
double rate = billed > 0 ? round1((collected * 100.0) / billed) : 0.0;
|
||||
out.add(new ReportGroup(str(r.get("key")), lng(r.get("invoiceCount")),
|
||||
billed, collected, lng(r.get("outstanding")), rate));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private static LocalDate parseDateOrToday(String s) {
|
||||
if (s == null || s.isBlank()) {
|
||||
return LocalDate.now(ZoneOffset.UTC);
|
||||
}
|
||||
try {
|
||||
return LocalDate.parse(s.trim(), ISO_DATE);
|
||||
} catch (Exception e) {
|
||||
return LocalDate.now(ZoneOffset.UTC);
|
||||
}
|
||||
}
|
||||
|
||||
private static String eventSuffix(String eventId) {
|
||||
String suffix = eventId == null ? "EVT" : eventId.replaceAll("[^A-Za-z0-9]", "").toUpperCase();
|
||||
if (suffix.length() > 8) {
|
||||
suffix = suffix.substring(suffix.length() - 8);
|
||||
}
|
||||
return suffix;
|
||||
}
|
||||
|
||||
private static String clip(String s, int max) {
|
||||
if (s == null) return null;
|
||||
return s.length() <= max ? s : s.substring(0, max);
|
||||
}
|
||||
|
||||
private static String fmtRate(double v) {
|
||||
return v == Math.floor(v) ? String.valueOf((long) v) : String.valueOf(Math.round(v * 100.0) / 100.0);
|
||||
}
|
||||
|
||||
// ── 헬퍼 ──────────────────────────────────────────────────────────────────
|
||||
private InvoiceSummary toSummary(Map<String, Object> r) {
|
||||
long total = lng(r.get("total"));
|
||||
@ -235,7 +573,12 @@ public class SettlementService {
|
||||
str(r.get("id")), str(r.get("eventId")), str(r.get("invoiceNo")),
|
||||
str(r.get("exhibitorName")), lng(r.get("subtotal")), lng(r.get("vat")),
|
||||
total, paid, Math.max(0, total - paid), str(r.get("status")),
|
||||
str(r.get("dueDate")), str(r.get("issuedAt")));
|
||||
str(r.get("dueDate")), str(r.get("issuedAt")), categoryOf(r.get("category")));
|
||||
}
|
||||
|
||||
private static String categoryOf(Object o) {
|
||||
String c = str(o);
|
||||
return c == null || c.isBlank() ? "rental" : c;
|
||||
}
|
||||
|
||||
/** 매니저 이상(주최자 또는 킨텍스 홀매니저)만 청구·수납 조작 가능. */
|
||||
|
||||
@ -48,7 +48,8 @@ public final class SettlementDtos {
|
||||
long outstanding,
|
||||
String status,
|
||||
String dueDate, // ISO 날짜(YYYY-MM-DD) 또는 null
|
||||
String issuedAt // ISO 8601 또는 null
|
||||
String issuedAt, // ISO 8601 또는 null
|
||||
String category // rental(임대·유틸) | auction_fee(옥션 수수료) — 유형 구분(F084)
|
||||
) {
|
||||
}
|
||||
|
||||
@ -68,6 +69,7 @@ public final class SettlementDtos {
|
||||
String issuedAt,
|
||||
String paidAt,
|
||||
String note,
|
||||
String category, // rental | auction_fee (F084)
|
||||
List<LineItem> lineItems,
|
||||
List<PaymentRecord> payments,
|
||||
boolean canManage // 매니저 이상(수납/발행 버튼 노출용) — 서버 판정
|
||||
@ -111,4 +113,123 @@ public final class SettlementDtos {
|
||||
List<InvoiceSummary> invoices
|
||||
) {
|
||||
}
|
||||
|
||||
/** F084 옥션 수수료 청구 생성 결과(낙찰 연동). created=false 이면 skipReason 사유(멱등/0원). */
|
||||
public record AuctionFeeResult(
|
||||
boolean created,
|
||||
String invoiceId,
|
||||
String invoiceNo,
|
||||
long fee,
|
||||
long vat,
|
||||
long total,
|
||||
double ratePercent,
|
||||
String skipReason
|
||||
) {
|
||||
}
|
||||
|
||||
// ── F085 환불·취소 규정 ────────────────────────────────────────────────────
|
||||
/**
|
||||
* 환불 산정 미리보기(취소 확정 전) 또는 확정 결과의 근거. 금액은 서버 계산 권위(원 단위).
|
||||
* <p>기준액(baseAmount) = 실 납입액(paid_amount). refundAmount = base × rate/100, forfeit = base − refund.
|
||||
* daysBeforeEvent = 취소 시점 D-일(음수=행사 후, null=개막일 미확정). rulesetVersion = refund-v1.0.
|
||||
*/
|
||||
public record RefundQuote(
|
||||
String invoiceId,
|
||||
String invoiceNo,
|
||||
String status, // 현재 청구 상태(cancelled 여부 판단용)
|
||||
long total, // 청구 총액(참고)
|
||||
long baseAmount, // 환불 산정 기준(실 납입액)
|
||||
double refundRatePercent,
|
||||
long refundAmount,
|
||||
long forfeitAmount, // 공제·위약액 = base − refund
|
||||
Integer daysBeforeEvent,
|
||||
String bracketLabel, // 적용 구간 라벨(예: "D-30 ~ D-59")
|
||||
String eventStartDate, // 행사 개막일(YYYY-MM-DD) 또는 null
|
||||
boolean dateResolved, // 개막일로 자동 산정 가능 여부(false=수기 검토)
|
||||
String rulesetVersion,
|
||||
String pgNote // PG 미집행 고지(G2 게이트)
|
||||
) {
|
||||
}
|
||||
|
||||
/** 청구 취소 요청 — reason(선택), cancelDate(선택 ISO YYYY-MM-DD, 기본=오늘). */
|
||||
public record CancelInvoiceRequest(
|
||||
String reason,
|
||||
String cancelDate
|
||||
) {
|
||||
}
|
||||
|
||||
/** 청구 취소 결과 — 갱신된 청구 상세 + 환불 근거. */
|
||||
public record CancelResult(
|
||||
InvoiceDetail invoice,
|
||||
RefundQuote refund
|
||||
) {
|
||||
}
|
||||
|
||||
// ── F083 세금계산서 ────────────────────────────────────────────────────────
|
||||
/**
|
||||
* 세금계산서 데이터 문서(국세청 미전송 — 문서 데이터까지, G2 게이트).
|
||||
* 공급자(supplier*)는 킨텍스 상수, 공급받는자(buyer)는 참가업체 표시명. 금액은 청구서 스냅샷.
|
||||
*/
|
||||
public record TaxInvoiceDto(
|
||||
String id,
|
||||
String invoiceId,
|
||||
String eventId,
|
||||
String taxInvoiceNo,
|
||||
String supplierName,
|
||||
String supplierBizNo,
|
||||
String supplierCeo,
|
||||
String supplierAddr,
|
||||
String buyerName,
|
||||
long supplyAmount,
|
||||
long vat,
|
||||
long total,
|
||||
String itemSummary,
|
||||
String status, // issued(문서 발행) | void
|
||||
String issuedAt,
|
||||
String issueNote // 국세청 전송 없음 고지(G2)
|
||||
) {
|
||||
}
|
||||
|
||||
// ── 정산 리포트 ────────────────────────────────────────────────────────────
|
||||
/** 정산 리포트 — 카테고리별 합계·수납율·미수 + 상세 행(CSV용). 취소 청구는 합계에서 제외. */
|
||||
public record SettlementReport(
|
||||
String eventId,
|
||||
String eventName,
|
||||
String generatedAt,
|
||||
long totalBilled,
|
||||
long totalCollected,
|
||||
long totalOutstanding,
|
||||
double collectionRate,
|
||||
long invoiceCount,
|
||||
List<ReportGroup> byCategory,
|
||||
List<ReportGroup> byStatus,
|
||||
List<ReportRow> rows
|
||||
) {
|
||||
}
|
||||
|
||||
/** 리포트 그룹 합계(카테고리/상태별). */
|
||||
public record ReportGroup(
|
||||
String key, // category 또는 status 값
|
||||
long invoiceCount,
|
||||
long billed,
|
||||
long collected,
|
||||
long outstanding,
|
||||
double collectionRate
|
||||
) {
|
||||
}
|
||||
|
||||
/** 리포트 상세 행(CSV 다운로드 원천). */
|
||||
public record ReportRow(
|
||||
String invoiceNo,
|
||||
String exhibitorName,
|
||||
String category,
|
||||
String status,
|
||||
long subtotal,
|
||||
long vat,
|
||||
long total,
|
||||
long paidAmount,
|
||||
long outstanding,
|
||||
String dueDate
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,28 +5,43 @@ package com.zioinfo.kintex.tenant;
|
||||
* <p>{@link TenantContextFilter}가 요청 처리 전에 해소한 {@code tenant_id}를 주입하고, 처리 후 정리한다.
|
||||
* 서비스/매퍼는 클라이언트 파라미터가 아닌 <b>이 컨텍스트</b>를 신뢰 원천으로 스코프한다(위조 차단, §8-2).
|
||||
*
|
||||
* <p>미설정(비 HTTP·테스트 등) 시 {@link #DEFAULT_TENANT}('kintex' = 테넌트 #1)로 폴백 →
|
||||
* <p>미설정(비 HTTP·테스트 등) 시 {@link #DEFAULT_TENANT}('KINTEX' = 테넌트 #1)로 폴백 →
|
||||
* 단일 테넌트 동작과 완전히 동일(회귀 0). 2단계에서 fail-closed 강제 전환은 설계 노트 참조.
|
||||
*
|
||||
* <p>표준(2026-07-12): 테넌트 코드는 대문자('KINTEX'). 해석·비교는 {@link #normalize(String)}로
|
||||
* 대문자 정규화 → 기존 발급 JWT(tid='kintex')·X-Tenant-Id 소문자 입력이 계속 동작한다(하위호환).
|
||||
*/
|
||||
public final class TenantContext {
|
||||
|
||||
/** 기준 테넌트 #1(KINTEX). 스키마 DEFAULT 'kintex'와 일치. */
|
||||
public static final String DEFAULT_TENANT = "kintex";
|
||||
/** 기준 테넌트 #1(KINTEX). 스키마 DEFAULT 'KINTEX'·복합 PK 선두 컬럼과 일치. */
|
||||
public static final String DEFAULT_TENANT = "KINTEX";
|
||||
|
||||
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
|
||||
|
||||
private TenantContext() {
|
||||
}
|
||||
|
||||
/** 해소된 테넌트 주입(필터 전용). */
|
||||
public static void set(String tenantId) {
|
||||
HOLDER.set(tenantId);
|
||||
/**
|
||||
* 테넌트 코드 정규화 — trim + 대문자. null/공백이면 null.
|
||||
* 표준 코드가 대문자이므로 소문자 입력('kintex')·레거시 JWT를 표준 코드로 매칭한다.
|
||||
*/
|
||||
public static String normalize(String tenantId) {
|
||||
if (tenantId == null) {
|
||||
return null;
|
||||
}
|
||||
String v = tenantId.trim();
|
||||
return v.isEmpty() ? null : v.toUpperCase();
|
||||
}
|
||||
|
||||
/** 현재 테넌트 — 미설정 시 기준 테넌트('kintex')로 폴백(회귀 0). */
|
||||
/** 해소된 테넌트 주입(필터 전용) — 대문자 정규화 저장. */
|
||||
public static void set(String tenantId) {
|
||||
HOLDER.set(normalize(tenantId));
|
||||
}
|
||||
|
||||
/** 현재 테넌트 — 미설정 시 기준 테넌트('KINTEX')로 폴백(회귀 0). 항상 정규화된 값. */
|
||||
public static String currentTenantId() {
|
||||
String v = HOLDER.get();
|
||||
return (v == null || v.isBlank()) ? DEFAULT_TENANT : v;
|
||||
String v = normalize(HOLDER.get());
|
||||
return v == null ? DEFAULT_TENANT : v;
|
||||
}
|
||||
|
||||
/** 명시적으로 설정된 값(폴백 없음) — null 이면 미해소. 진단용. */
|
||||
|
||||
@ -25,16 +25,17 @@ public class TenantResolver {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** 알려진 테넌트인지(캐시 미스 시 1회 재적재). */
|
||||
/** 알려진 테넌트인지(캐시 미스 시 1회 재적재). 대문자 정규화 매칭 — 소문자 X-Tenant-Id('kintex') 하위호환. */
|
||||
public boolean exists(String tenantId) {
|
||||
if (tenantId == null || tenantId.isBlank()) {
|
||||
String key = TenantContext.normalize(tenantId);
|
||||
if (key == null) {
|
||||
return false;
|
||||
}
|
||||
if (idSet.containsKey(tenantId)) {
|
||||
if (idSet.containsKey(key)) {
|
||||
return true;
|
||||
}
|
||||
reload();
|
||||
return idSet.containsKey(tenantId);
|
||||
return idSet.containsKey(key);
|
||||
}
|
||||
|
||||
/** Host 헤더(도메인)로 테넌트 id 해소 — 없으면 null. */
|
||||
|
||||
@ -122,6 +122,19 @@ public class VisitorController {
|
||||
return ApiResponse.ok(service.collectLead(eventId, req));
|
||||
}
|
||||
|
||||
/**
|
||||
* 리드 AI 재계산(F051) — 매니저 이상(주최자·홀매니저). AiTextRouter 로 비-PII 신호를 요약해 스코어·근거를
|
||||
* 재산출하고, AI 실패 시 기존 규칙 스코어를 유지한다(ai_generated 정직 표기).
|
||||
*/
|
||||
@Audited(action = "LEAD_RESCORE", targetType = "lead")
|
||||
@PostMapping("/api/events/{eventId}/leads/{leadId}/rescore")
|
||||
public ApiResponse<LeadRescoreResult> rescoreLead(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String leadId) {
|
||||
guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER);
|
||||
return ApiResponse.ok(service.rescoreLead(eventId, leadId));
|
||||
}
|
||||
|
||||
/** 리드 CSV 내보내기(마스킹 필드만). UTF-8 BOM 포함(엑셀 한글 호환). */
|
||||
@Audited(action = "LEAD_EXPORT_CSV", targetType = "lead")
|
||||
@GetMapping("/api/events/{eventId}/leads/export")
|
||||
|
||||
@ -273,6 +273,33 @@ public interface VisitorMapper {
|
||||
@Param("aiReasonsJson") String aiReasonsJson, @Param("note") String note,
|
||||
@Param("agreePrivacy") boolean agreePrivacy);
|
||||
|
||||
// ── 리드 AI 재계산(SCR-32, F051) ──
|
||||
|
||||
/**
|
||||
* 단일 리드의 <b>비-PII</b> 신호만 조회(AI 재계산 입력). name/phone/email 은 절대 SELECT 하지 않는다.
|
||||
* activity·ai_reasons 는 ::text 로 반환해 서비스가 프롬프트 컨텍스트로 사용한다.
|
||||
*/
|
||||
@Select("""
|
||||
SELECT role, company, product, interest, score,
|
||||
activity::text AS "activity",
|
||||
ai_reasons::text AS "aiReasons"
|
||||
FROM lead
|
||||
WHERE event_id = #{eventId} AND id = #{id}
|
||||
""")
|
||||
Map<String, Object> findLeadForRescore(@Param("eventId") String eventId, @Param("id") String id);
|
||||
|
||||
/** 리드 스코어·근거·AI 생성 플래그 갱신(재계산 결과 반영). 갱신 행 수 반환. */
|
||||
@Update("""
|
||||
UPDATE lead
|
||||
SET score = #{score},
|
||||
ai_reasons = CAST(#{aiReasonsJson} AS jsonb),
|
||||
ai_generated = #{aiGenerated}
|
||||
WHERE event_id = #{eventId} AND id = #{id}
|
||||
""")
|
||||
int updateLeadScore(@Param("eventId") String eventId, @Param("id") String id,
|
||||
@Param("score") int score, @Param("aiReasonsJson") String aiReasonsJson,
|
||||
@Param("aiGenerated") boolean aiGenerated);
|
||||
|
||||
/** CSV 내보내기용 리드 조회 — 마스킹 필드만(원문 미반환). booth 선택 필터. */
|
||||
@Select("""
|
||||
<script>
|
||||
|
||||
@ -1,6 +1,9 @@
|
||||
package com.zioinfo.kintex.visitor;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.zioinfo.kintex.ai.AiTextRouter;
|
||||
import com.zioinfo.kintex.ai.AiTextRouter.AiResult;
|
||||
import com.zioinfo.kintex.common.PageResponse;
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
@ -33,10 +36,12 @@ public class VisitorService {
|
||||
|
||||
private final VisitorMapper mapper;
|
||||
private final LeadScorer leadScorer;
|
||||
private final AiTextRouter aiRouter;
|
||||
|
||||
public VisitorService(VisitorMapper mapper, LeadScorer leadScorer) {
|
||||
public VisitorService(VisitorMapper mapper, LeadScorer leadScorer, AiTextRouter aiRouter) {
|
||||
this.mapper = mapper;
|
||||
this.leadScorer = leadScorer;
|
||||
this.aiRouter = aiRouter;
|
||||
}
|
||||
|
||||
// ── SCR-30 요약 ──
|
||||
@ -291,6 +296,143 @@ public class VisitorService {
|
||||
return new LeadCollectResult(id, sr.score(), sr.reasons(), sr.source());
|
||||
}
|
||||
|
||||
// ── SCR-32 리드 AI 재계산(F051) ──
|
||||
|
||||
/**
|
||||
* 리드 스코어를 AI({@link AiTextRouter})로 재계산한다. <b>비-PII 신호만</b>(직급·소속·관심제품·관심도·활동 로그)을
|
||||
* 프롬프트로 사용하며 이름/연락처/이메일은 절대 전송하지 않는다. AI 는 JSON {score,reasons} 로만 응답하도록 지시한다.
|
||||
*
|
||||
* <p><b>폴백(무회귀)</b>: AI 미가용/실패/파싱 실패 시 <b>기존 규칙 스코어를 그대로 유지</b>하고
|
||||
* {@code ai_generated=false}·source="rule" 로 정직하게 표기한다(환각 방지). 스코어를 임의로 낮추지 않는다.
|
||||
*/
|
||||
public LeadRescoreResult rescoreLead(String eventId, String leadId) {
|
||||
Map<String, Object> r = mapper.findLeadForRescore(eventId, leadId);
|
||||
if (r == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "리드를 찾을 수 없습니다.");
|
||||
}
|
||||
int currentScore = clamp(intVal(r.get("score")), 0, 100);
|
||||
int interest = clamp(intVal(r.get("interest")), 1, 5);
|
||||
List<String> existingReasons = parseStrings(r.get("aiReasons"));
|
||||
|
||||
String prompt = buildRescorePrompt(r, interest);
|
||||
AiResult ai = aiRouter.generate(prompt, 512);
|
||||
|
||||
if (ai.usable()) {
|
||||
Parsed parsed = parseRescore(ai.text());
|
||||
if (parsed != null) {
|
||||
int score = clamp(parsed.score(), 0, 100);
|
||||
List<String> reasons = parsed.reasons().isEmpty() ? existingReasons : parsed.reasons();
|
||||
String reasonsJson = toJson(reasons);
|
||||
mapper.updateLeadScore(eventId, leadId, score, reasonsJson, true);
|
||||
return new LeadRescoreResult(leadId, score, reasons, "ai", true, false, ai.provider());
|
||||
}
|
||||
}
|
||||
// 폴백: 기존 규칙 스코어 유지(스코어·근거 불변), AI 생성 아님으로 표기.
|
||||
mapper.updateLeadScore(eventId, leadId, currentScore, toJson(existingReasons), false);
|
||||
return new LeadRescoreResult(leadId, currentScore, existingReasons, "rule", false, true, ai.provider());
|
||||
}
|
||||
|
||||
/** 재계산 프롬프트 — 비-PII 신호만 + JSON 전용 출력 지시(결정론·환각 억제). */
|
||||
private static String buildRescorePrompt(Map<String, Object> r, int interest) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("당신은 B2B 전시회 리드 스코어링 분석가다. 아래 리드 신호만으로 구매 전환 가능성을 0~100 정수로 평가하라.\n");
|
||||
sb.append("개인 식별정보(이름/연락처/이메일)는 주어지지 않으며, 추측하지 마라. 신호에 없는 사실을 지어내지 마라.\n\n");
|
||||
sb.append("[리드 신호]\n");
|
||||
sb.append("- 직급/역할: ").append(nz(str(r.get("role")))).append('\n');
|
||||
sb.append("- 소속: ").append(nz(str(r.get("company")))).append('\n');
|
||||
sb.append("- 관심 제품: ").append(nz(str(r.get("product")))).append('\n');
|
||||
sb.append("- 상담원 관심도(1~5): ").append(interest).append('\n');
|
||||
sb.append("- 활동 로그(JSON): ").append(nz(str(r.get("activity")))).append('\n');
|
||||
sb.append("- 기존 근거(JSON): ").append(nz(str(r.get("aiReasons")))).append('\n');
|
||||
sb.append("\n오직 아래 JSON 형식으로만 답하라(설명·마크다운·코드펜스 금지):\n");
|
||||
sb.append("{\"score\": <0~100 정수>, \"reasons\": [\"근거1\", \"근거2\"]}\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** AI 응답(JSON)에서 score·reasons 추출. 파싱 실패 시 null(→ 규칙 폴백). */
|
||||
private Parsed parseRescore(String text) {
|
||||
if (text == null || text.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String json = extractJsonObject(text);
|
||||
if (json == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
JsonNode root = JSON.readTree(json);
|
||||
JsonNode scoreNode = root.get("score");
|
||||
if (scoreNode == null || !scoreNode.isNumber()) {
|
||||
return null;
|
||||
}
|
||||
int score = scoreNode.asInt();
|
||||
List<String> reasons = new ArrayList<>();
|
||||
JsonNode rs = root.get("reasons");
|
||||
if (rs != null && rs.isArray()) {
|
||||
for (JsonNode n : rs) {
|
||||
String v = n.asText(null);
|
||||
if (v != null && !v.isBlank()) {
|
||||
reasons.add(v.trim());
|
||||
}
|
||||
if (reasons.size() >= 6) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return new Parsed(score, reasons);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 텍스트에서 첫 번째 균형 잡힌 JSON 객체를 추출(코드펜스·전후 잡음 방어). */
|
||||
private static String extractJsonObject(String text) {
|
||||
int start = text.indexOf('{');
|
||||
if (start < 0) {
|
||||
return null;
|
||||
}
|
||||
int depth = 0;
|
||||
boolean inStr = false;
|
||||
boolean esc = false;
|
||||
for (int i = start; i < text.length(); i++) {
|
||||
char c = text.charAt(i);
|
||||
if (inStr) {
|
||||
if (esc) {
|
||||
esc = false;
|
||||
} else if (c == '\\') {
|
||||
esc = true;
|
||||
} else if (c == '"') {
|
||||
inStr = false;
|
||||
}
|
||||
} else if (c == '"') {
|
||||
inStr = true;
|
||||
} else if (c == '{') {
|
||||
depth++;
|
||||
} else if (c == '}') {
|
||||
depth--;
|
||||
if (depth == 0) {
|
||||
return text.substring(start, i + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String toJson(List<String> list) {
|
||||
try {
|
||||
return JSON.writeValueAsString(list == null ? List.of() : list);
|
||||
} catch (Exception e) {
|
||||
return "[]";
|
||||
}
|
||||
}
|
||||
|
||||
private static String nz(String s) {
|
||||
return s == null ? "(없음)" : s;
|
||||
}
|
||||
|
||||
/** 재계산 파싱 결과(내부). */
|
||||
private record Parsed(int score, List<String> reasons) {
|
||||
}
|
||||
|
||||
/** 리드 CSV(마스킹 필드만). RFC4180 유사 이스케이프. UTF-8 BOM 은 컨트롤러가 부여. */
|
||||
public String leadsCsv(String eventId, String boothId) {
|
||||
String bid = (boothId == null || boothId.isBlank()) ? null : boothId;
|
||||
|
||||
@ -87,4 +87,20 @@ public final class VisitorDtos {
|
||||
|
||||
public record LeadCollectResult(String leadId, int score, List<String> aiReasons, String scoreSource) {
|
||||
}
|
||||
|
||||
/**
|
||||
* SCR-32 리드 AI 재계산 결과(F051). {@code scoreSource}="ai"(Claude/Ollama) | "rule"(폴백).
|
||||
* {@code aiGenerated}·{@code degraded}·{@code provider} 로 산출 출처를 정직하게 표기한다(환각 방지·라벨링 근거).
|
||||
*
|
||||
* @param leadId 리드 ID
|
||||
* @param score 0~100 스코어(AI 성공 시 AI 값, 실패 시 기존 규칙 스코어 유지)
|
||||
* @param aiReasons 근거 문구
|
||||
* @param scoreSource "ai" | "rule"
|
||||
* @param aiGenerated AI 산출 여부(폴백이면 false)
|
||||
* @param degraded AI 경로 실패로 폴백했는지
|
||||
* @param provider 실제 응답 provider("claude"|"ollama"|"none")
|
||||
*/
|
||||
public record LeadRescoreResult(String leadId, int score, List<String> aiReasons,
|
||||
String scoreSource, boolean aiGenerated, boolean degraded, String provider) {
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
package com.zioinfo.kintex.webhook;
|
||||
|
||||
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 com.zioinfo.kintex.webhook.dto.WebhookDtos.*;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* F099 웹훅 게이트웨이 관리 API — 시스템관리자(홀매니저/ADMIN)만 접근({@link SystemAccessGuard#requireAdmin}).
|
||||
* 아웃바운드 구독 CRUD + 인바운드 엔드포인트 등록 + 발송 큐(스텁)/수신 로그 조회.
|
||||
* <b>후속 웨이브: /screens/admin 웹훅 구독 관리 화면(계약 문서 참조)</b>.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin/webhooks")
|
||||
public class WebhookAdminController {
|
||||
|
||||
private final WebhookService service;
|
||||
private final SystemAccessGuard guard;
|
||||
|
||||
public WebhookAdminController(WebhookService service, SystemAccessGuard guard) {
|
||||
this.service = service;
|
||||
this.guard = guard;
|
||||
}
|
||||
|
||||
// ── 아웃바운드 구독 ──
|
||||
@GetMapping("/subscriptions")
|
||||
public ApiResponse<List<SubscriptionDto>> subscriptions(@AuthenticationPrincipal KintexPrincipal principal) {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.listSubscriptions());
|
||||
}
|
||||
|
||||
@Audited(action = "WEBHOOK_SUB_CREATE", targetType = "webhook_subscription")
|
||||
@PostMapping("/subscriptions")
|
||||
public ApiResponse<SubscriptionDto> createSubscription(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@RequestBody SubscriptionCreateRequest req) {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.createSubscription(req, principal.userId()));
|
||||
}
|
||||
|
||||
@Audited(action = "WEBHOOK_SUB_UPDATE", targetType = "webhook_subscription")
|
||||
@PutMapping("/subscriptions/{id}")
|
||||
public ApiResponse<SubscriptionDto> updateSubscription(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody SubscriptionUpdateRequest req) {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.updateSubscription(id, req));
|
||||
}
|
||||
|
||||
@Audited(action = "WEBHOOK_SUB_DELETE", targetType = "webhook_subscription")
|
||||
@DeleteMapping("/subscriptions/{id}")
|
||||
public ApiResponse<Void> deleteSubscription(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
guard.requireAdmin(principal);
|
||||
service.deleteSubscription(id);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
// ── 아웃바운드 발송 큐(스텁 조회) ──
|
||||
@GetMapping("/deliveries")
|
||||
public ApiResponse<List<DeliveryDto>> deliveries(@AuthenticationPrincipal KintexPrincipal principal) {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.listDeliveries());
|
||||
}
|
||||
|
||||
// ── 인바운드 엔드포인트 ──
|
||||
@GetMapping("/inbound")
|
||||
public ApiResponse<List<InboundEndpointDto>> inbound(@AuthenticationPrincipal KintexPrincipal principal) {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.listInboundEndpoints());
|
||||
}
|
||||
|
||||
@Audited(action = "WEBHOOK_INBOUND_CREATE", targetType = "webhook_inbound_endpoint")
|
||||
@PostMapping("/inbound")
|
||||
public ApiResponse<InboundEndpointDto> createInbound(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@RequestBody InboundEndpointCreateRequest req) {
|
||||
guard.requireAdmin(principal);
|
||||
return ApiResponse.ok(service.createInboundEndpoint(req, principal.userId()));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
package com.zioinfo.kintex.webhook;
|
||||
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.webhook.dto.WebhookDtos.InboundReceipt;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* F099 인바운드 웹훅 수신(공개 경로, 인증 없음 — {@code permitAll}).
|
||||
* <p><b>보안</b>: 인증 토큰이 없는 대신 ① 경로 {@code {token}}(추측 불가 랜덤 식별자) 존재·활성 검증
|
||||
* ② {@code X-Kintex-Signature} HMAC-SHA256 서명 검증(등록 시크릿 기반) <b>이중 검증</b>으로 방어한다.
|
||||
* 원문 바디({@code @RequestBody String})로 HMAC 을 계산(파싱 전 raw 필요).
|
||||
* <p><b>Rate 고려</b>: 공개 permitAll 경로이므로 실운영 배포 시 앞단 프록시(nginx)에서 토큰/IP 단위
|
||||
* rate limit 을 두는 것을 전제로 한다(애플리케이션은 무효 서명 트래픽을 저장하지 않아 적재 DoS 를 완화).
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/webhooks/in")
|
||||
public class WebhookInboundController {
|
||||
|
||||
private final WebhookService service;
|
||||
|
||||
public WebhookInboundController(WebhookService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/webhooks/in/{token} — 토큰 + HMAC 서명 검증 후 이벤트 저장.
|
||||
* 검증 실패는 401(존재·사유 미노출). 성공은 200 + accepted receipt.
|
||||
*/
|
||||
@PostMapping("/{token}")
|
||||
public ResponseEntity<ApiResponse<InboundReceipt>> receive(
|
||||
@PathVariable String token,
|
||||
@RequestBody(required = false) String rawBody,
|
||||
@RequestHeader(value = "X-Kintex-Signature", required = false) String signature,
|
||||
@RequestHeader(value = "X-Kintex-Event", required = false) String eventType) {
|
||||
InboundReceipt receipt = service.receiveInbound(token, rawBody, signature, eventType);
|
||||
return ResponseEntity.ok(ApiResponse.ok(receipt));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
package com.zioinfo.kintex.webhook;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* F099 웹훅 게이트웨이 매퍼. Map 반환 @Select 는 별칭에 쌍따옴표(AS "x")로 카멜케이스 키 보존.
|
||||
* <b>secret_hash·secret_enc 는 어떤 조회 SELECT 목록에도 포함하지 않는다</b>(응답 유출 차단) —
|
||||
* 예외: 인바운드 HMAC 검증 전용 {@link #findInboundSecretEnc}(내부 검증 경로만).
|
||||
*/
|
||||
@Mapper
|
||||
public interface WebhookMapper {
|
||||
|
||||
// ── 아웃바운드 구독 CRUD ──
|
||||
@Insert("""
|
||||
INSERT INTO webhook_subscription
|
||||
(tenant_id, id, event_type, callback_url, secret_hash, description, active, created_by)
|
||||
VALUES ('KINTEX', #{id}, #{eventType}, #{callbackUrl}, #{secretHash}, #{description}, true, #{createdBy})
|
||||
""")
|
||||
int insertSubscription(@Param("id") String id,
|
||||
@Param("eventType") String eventType,
|
||||
@Param("callbackUrl") String callbackUrl,
|
||||
@Param("secretHash") String secretHash,
|
||||
@Param("description") String description,
|
||||
@Param("createdBy") String createdBy);
|
||||
|
||||
/** secret_hash 는 응답 목록에서 제외. */
|
||||
@Select("""
|
||||
SELECT id, event_type AS "eventType", callback_url AS "callbackUrl",
|
||||
description, active, created_at AS "createdAt", updated_at AS "updatedAt"
|
||||
FROM webhook_subscription
|
||||
WHERE tenant_id = 'KINTEX'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
""")
|
||||
List<Map<String, Object>> findSubscriptions();
|
||||
|
||||
@Select("""
|
||||
SELECT id, event_type AS "eventType", callback_url AS "callbackUrl",
|
||||
description, active, created_at AS "createdAt", updated_at AS "updatedAt"
|
||||
FROM webhook_subscription
|
||||
WHERE tenant_id = 'KINTEX' AND id = #{id}
|
||||
""")
|
||||
Map<String, Object> findSubscription(@Param("id") String id);
|
||||
|
||||
/**
|
||||
* 부분 수정 — null 필드는 COALESCE 로 미변경. secret_hash 는 blank 이면 미변경.
|
||||
* (comparison 연산자·< 미사용 — script 규칙 준수)
|
||||
*/
|
||||
@Update("""
|
||||
UPDATE webhook_subscription SET
|
||||
callback_url = COALESCE(#{callbackUrl}, callback_url),
|
||||
description = COALESCE(#{description}, description),
|
||||
active = COALESCE(#{active}, active),
|
||||
secret_hash = CASE WHEN #{secretHash} IS NULL OR #{secretHash} = '' THEN secret_hash ELSE #{secretHash} END,
|
||||
updated_at = now()
|
||||
WHERE tenant_id = 'KINTEX' AND id = #{id}
|
||||
""")
|
||||
int updateSubscription(@Param("id") String id,
|
||||
@Param("callbackUrl") String callbackUrl,
|
||||
@Param("secretHash") String secretHash,
|
||||
@Param("description") String description,
|
||||
@Param("active") Boolean active);
|
||||
|
||||
@org.apache.ibatis.annotations.Delete(
|
||||
"DELETE FROM webhook_subscription WHERE tenant_id = 'KINTEX' AND id = #{id}")
|
||||
int deleteSubscription(@Param("id") String id);
|
||||
|
||||
// ── 아웃바운드 발송 큐(스텁) ──
|
||||
@Insert("""
|
||||
INSERT INTO webhook_delivery
|
||||
(tenant_id, id, subscription_id, event_type, payload_json, status, note)
|
||||
VALUES ('KINTEX', #{id}, #{subscriptionId}, #{eventType}, #{payloadJson}, 'disabled', #{note})
|
||||
""")
|
||||
int insertDelivery(@Param("id") String id,
|
||||
@Param("subscriptionId") String subscriptionId,
|
||||
@Param("eventType") String eventType,
|
||||
@Param("payloadJson") String payloadJson,
|
||||
@Param("note") String note);
|
||||
|
||||
@Select("""
|
||||
SELECT id, subscription_id AS "subscriptionId", event_type AS "eventType",
|
||||
status, note, created_at AS "createdAt"
|
||||
FROM webhook_delivery
|
||||
WHERE tenant_id = 'KINTEX'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
""")
|
||||
List<Map<String, Object>> findDeliveries();
|
||||
|
||||
// ── 인바운드 엔드포인트 ──
|
||||
@Insert("""
|
||||
INSERT INTO webhook_inbound_endpoint
|
||||
(tenant_id, id, token, secret_enc, source_label, active, created_by)
|
||||
VALUES ('KINTEX', #{id}, #{token}, #{secretEnc}, #{sourceLabel}, true, #{createdBy})
|
||||
""")
|
||||
int insertInboundEndpoint(@Param("id") String id,
|
||||
@Param("token") String token,
|
||||
@Param("secretEnc") String secretEnc,
|
||||
@Param("sourceLabel") String sourceLabel,
|
||||
@Param("createdBy") String createdBy);
|
||||
|
||||
/** secret_enc 는 응답 목록에서 제외. */
|
||||
@Select("""
|
||||
SELECT id, token, source_label AS "sourceLabel", active, created_at AS "createdAt"
|
||||
FROM webhook_inbound_endpoint
|
||||
WHERE tenant_id = 'KINTEX'
|
||||
ORDER BY created_at DESC, id DESC
|
||||
""")
|
||||
List<Map<String, Object>> findInboundEndpoints();
|
||||
|
||||
/** 인바운드 검증 전용 — active 토큰의 봉인 시크릿(내부 경로만, 응답 절대 금지). */
|
||||
@Select("""
|
||||
SELECT secret_enc AS "secretEnc"
|
||||
FROM webhook_inbound_endpoint
|
||||
WHERE token = #{token} AND active = true
|
||||
""")
|
||||
Map<String, Object> findInboundSecretEnc(@Param("token") String token);
|
||||
|
||||
// ── 인바운드 이벤트 로그 ──
|
||||
@Insert("""
|
||||
INSERT INTO webhook_inbound_event
|
||||
(tenant_id, id, endpoint_token, event_type, payload_json, signature_valid)
|
||||
VALUES ('KINTEX', #{id}, #{token}, #{eventType}, #{payloadJson}, #{signatureValid})
|
||||
""")
|
||||
int insertInboundEvent(@Param("id") String id,
|
||||
@Param("token") String token,
|
||||
@Param("eventType") String eventType,
|
||||
@Param("payloadJson") String payloadJson,
|
||||
@Param("signatureValid") boolean signatureValid);
|
||||
}
|
||||
@ -0,0 +1,230 @@
|
||||
package com.zioinfo.kintex.webhook;
|
||||
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.security.SecretCipher;
|
||||
import com.zioinfo.kintex.webhook.dto.WebhookDtos.*;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* F099 웹훅 게이트웨이 서비스.
|
||||
* <p><b>아웃바운드는 스텁</b> — {@link #enqueueOutbound} 는 발송 큐 레코드(status=disabled)만 기록하고
|
||||
* 실 HTTP 콜은 수행하지 않는다(외부 API 금지 원칙 · 리스크 G4). <b>인바운드만 실동작</b> — 토큰 조회 +
|
||||
* HMAC-SHA256 서명 검증 후 저장({@link #receiveInbound}).
|
||||
* <p>시크릿 원문/해시/봉인값은 어떤 응답에도 노출하지 않는다.
|
||||
*/
|
||||
@Service
|
||||
public class WebhookService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(WebhookService.class);
|
||||
|
||||
/** 등록 허용 이벤트 유형(계약 고정 집합 — 임의 유형 등록 차단). */
|
||||
public static final Set<String> KNOWN_EVENT_TYPES = Set.of(
|
||||
"lead.hot", "invoice.paid", "auction.awarded", "booth.sold", "campaign.done");
|
||||
|
||||
private final WebhookMapper mapper;
|
||||
private final WebhookSignature signature;
|
||||
private final SecretCipher cipher;
|
||||
|
||||
public WebhookService(WebhookMapper mapper, WebhookSignature signature, SecretCipher cipher) {
|
||||
this.mapper = mapper;
|
||||
this.signature = signature;
|
||||
this.cipher = cipher;
|
||||
}
|
||||
|
||||
// ── 아웃바운드 구독 CRUD ──
|
||||
public List<SubscriptionDto> listSubscriptions() {
|
||||
return toSubscriptionList(mapper.findSubscriptions());
|
||||
}
|
||||
|
||||
public SubscriptionDto createSubscription(SubscriptionCreateRequest req, String actorId) {
|
||||
String eventType = requireKnownType(req == null ? null : req.eventType());
|
||||
String url = trimOrNull(req.callbackUrl());
|
||||
String secret = trimOrNull(req.secret());
|
||||
if (url == null) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "콜백 URL을 입력해 주세요.");
|
||||
}
|
||||
if (secret == null) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "서명 시크릿을 입력해 주세요.");
|
||||
}
|
||||
String id = "wh-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
// 원문은 즉시 SHA-256 해시 후 폐기(발송 disabled → 재계산 불요).
|
||||
mapper.insertSubscription(id, eventType, url, signature.hashSecret(secret),
|
||||
trimOrNull(req.description()), actorId);
|
||||
return toSubscription(mapper.findSubscription(id));
|
||||
}
|
||||
|
||||
public SubscriptionDto updateSubscription(String id, SubscriptionUpdateRequest req) {
|
||||
if (mapper.findSubscription(id) == null) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 구독입니다.");
|
||||
}
|
||||
String secretHash = null;
|
||||
String secret = req == null ? null : trimOrNull(req.secret());
|
||||
if (secret != null) {
|
||||
secretHash = signature.hashSecret(secret);
|
||||
}
|
||||
mapper.updateSubscription(id,
|
||||
req == null ? null : trimOrNull(req.callbackUrl()),
|
||||
secretHash,
|
||||
req == null ? null : trimOrNull(req.description()),
|
||||
req == null ? null : req.active());
|
||||
return toSubscription(mapper.findSubscription(id));
|
||||
}
|
||||
|
||||
public void deleteSubscription(String id) {
|
||||
if (mapper.deleteSubscription(id) == 0) {
|
||||
throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 구독입니다.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 아웃바운드 발송 트리거 — <b>스텁</b>. 실 HTTP 콜 대신 발송 큐 레코드(status=disabled)만 기록한다.
|
||||
* 다른 모듈이 도메인 이벤트 발생 시 호출할 수 있는 통합 지점(현재 자동 배선 없음 · 후속 웨이브).
|
||||
*/
|
||||
public int enqueueOutbound(String eventType, String subscriptionId, String payloadJson) {
|
||||
String id = "wd-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
return mapper.insertDelivery(id, subscriptionId, eventType,
|
||||
payloadJson == null ? "{}" : payloadJson,
|
||||
"outbound disabled(stub) — no external HTTP call");
|
||||
}
|
||||
|
||||
public List<DeliveryDto> listDeliveries() {
|
||||
List<Map<String, Object>> rows = mapper.findDeliveries();
|
||||
List<DeliveryDto> out = new ArrayList<>(rows == null ? 0 : rows.size());
|
||||
if (rows != null) {
|
||||
for (Map<String, Object> r : rows) {
|
||||
out.add(new DeliveryDto(str(r.get("id")), str(r.get("subscriptionId")),
|
||||
str(r.get("eventType")), str(r.get("status")), str(r.get("note")),
|
||||
odt(r.get("createdAt"))));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── 인바운드 엔드포인트 ──
|
||||
public List<InboundEndpointDto> listInboundEndpoints() {
|
||||
List<Map<String, Object>> rows = mapper.findInboundEndpoints();
|
||||
List<InboundEndpointDto> out = new ArrayList<>(rows == null ? 0 : rows.size());
|
||||
if (rows != null) {
|
||||
for (Map<String, Object> r : rows) {
|
||||
out.add(new InboundEndpointDto(str(r.get("id")), str(r.get("token")),
|
||||
str(r.get("sourceLabel")), bool(r.get("active")), odt(r.get("createdAt"))));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public InboundEndpointDto createInboundEndpoint(InboundEndpointCreateRequest req, String actorId) {
|
||||
String secret = req == null ? null : trimOrNull(req.secret());
|
||||
if (secret == null) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "HMAC 시크릿을 입력해 주세요.");
|
||||
}
|
||||
String id = "wi-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
// 토큰은 추측 불가 랜덤(공개 경로 식별자). 시크릿은 봉인 저장(HMAC 검증에 원문 필요).
|
||||
String token = "in_" + UUID.randomUUID().toString().replace("-", "");
|
||||
mapper.insertInboundEndpoint(id, token, cipher.seal(secret),
|
||||
trimOrNull(req.sourceLabel()), actorId);
|
||||
// 응답에 token 은 포함(공개 식별자), 시크릿은 미포함.
|
||||
return new InboundEndpointDto(id, token, req.sourceLabel(), true, OffsetDateTime.now());
|
||||
}
|
||||
|
||||
/**
|
||||
* 인바운드 수신 — 토큰 유효성 + HMAC-SHA256 서명 이중 검증 후 이벤트를 저장한다.
|
||||
* <p>토큰 미존재/비활성 또는 서명 불일치는 모두 401 로 귀결(존재 여부·사유 미노출 — 정보 최소화).
|
||||
* 검증 통과 시에만 저장하고 accepted receipt 를 반환한다.
|
||||
*
|
||||
* @param token 경로 토큰(공개 식별자)
|
||||
* @param rawBody 원문 바디(HMAC 계산 대상 — 파싱 전 raw)
|
||||
* @param providedSig 발신자 제공 서명 헤더값(sha256=hex 또는 hex)
|
||||
* @param declaredType 페이로드 declared 이벤트 유형(검증 후 저장, 신뢰 최소)
|
||||
*/
|
||||
public InboundReceipt receiveInbound(String token, String rawBody, String providedSig, String declaredType) {
|
||||
Map<String, Object> row = mapper.findInboundSecretEnc(token);
|
||||
if (row == null) {
|
||||
// 토큰 미존재/비활성 — 존재 여부 노출 없이 401.
|
||||
throw new ApiException(ErrorCode.UNAUTHORIZED, "웹훅 인증 실패");
|
||||
}
|
||||
String secret = cipher.open(str(row.get("secretEnc")));
|
||||
boolean valid = signature.verify(secret, rawBody == null ? "" : rawBody, providedSig);
|
||||
if (!valid) {
|
||||
// 서명 불일치도 저장하지 않고 401(무효 트래픽 미적재 — 로그 스팸/DoS 완화).
|
||||
log.warn("Inbound webhook signature rejected token={}", mask(token));
|
||||
throw new ApiException(ErrorCode.UNAUTHORIZED, "웹훅 인증 실패");
|
||||
}
|
||||
String id = "wie-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
mapper.insertInboundEvent(id, token, trimOrNull(declaredType),
|
||||
rawBody == null ? "{}" : rawBody, true);
|
||||
return new InboundReceipt(true, id);
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
private String requireKnownType(String type) {
|
||||
String t = trimOrNull(type);
|
||||
if (t == null || !KNOWN_EVENT_TYPES.contains(t)) {
|
||||
throw new ApiException(ErrorCode.VALIDATION,
|
||||
"지원하지 않는 이벤트 유형입니다(허용: " + String.join(", ", KNOWN_EVENT_TYPES) + ").");
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
private List<SubscriptionDto> toSubscriptionList(List<Map<String, Object>> rows) {
|
||||
List<SubscriptionDto> out = new ArrayList<>(rows == null ? 0 : rows.size());
|
||||
if (rows != null) {
|
||||
for (Map<String, Object> r : rows) {
|
||||
out.add(toSubscription(r));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private SubscriptionDto toSubscription(Map<String, Object> r) {
|
||||
if (r == null) {
|
||||
return null;
|
||||
}
|
||||
return new SubscriptionDto(str(r.get("id")), str(r.get("eventType")), str(r.get("callbackUrl")),
|
||||
str(r.get("description")), bool(r.get("active")),
|
||||
odt(r.get("createdAt")), odt(r.get("updatedAt")));
|
||||
}
|
||||
|
||||
private static String mask(String s) {
|
||||
if (s == null || s.length() <= 6) {
|
||||
return "***";
|
||||
}
|
||||
return s.substring(0, 4) + "***";
|
||||
}
|
||||
|
||||
private static String trimOrNull(String s) {
|
||||
if (s == null) {
|
||||
return null;
|
||||
}
|
||||
String t = s.trim();
|
||||
return t.isEmpty() ? null : t;
|
||||
}
|
||||
|
||||
private static String str(Object o) {
|
||||
return o == null ? null : String.valueOf(o);
|
||||
}
|
||||
|
||||
private static boolean bool(Object o) {
|
||||
return o instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(o));
|
||||
}
|
||||
|
||||
private static OffsetDateTime odt(Object o) {
|
||||
if (o instanceof OffsetDateTime t) {
|
||||
return t;
|
||||
}
|
||||
if (o instanceof java.time.Instant i) {
|
||||
return i.atOffset(java.time.ZoneOffset.UTC);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
package com.zioinfo.kintex.webhook;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/**
|
||||
* 웹훅 서명 유틸(F099).
|
||||
* <ul>
|
||||
* <li><b>인바운드 검증</b>: {@code HMAC-SHA256(secret, rawBody)} 을 hex 로 계산하여 발신자 헤더값과
|
||||
* 상수시간 비교({@link #verify}). 헤더는 {@code sha256=<hex>} 또는 bare hex 를 허용.</li>
|
||||
* <li><b>아웃바운드 구독 시크릿</b>: 발송이 disabled(스텁)이므로 원문 재계산이 불필요 → {@code SHA-256(secret)}
|
||||
* hex 해시로 저장({@link #hashSecret}). 원문은 저장·응답 어디에도 남기지 않는다.</li>
|
||||
* </ul>
|
||||
* 순수 함수(상태 없음) — 서명·검증 단위테스트 대상.
|
||||
*/
|
||||
@Component
|
||||
public class WebhookSignature {
|
||||
|
||||
private static final String HMAC_ALG = "HmacSHA256";
|
||||
private static final String PREFIX = "sha256=";
|
||||
|
||||
/** {@code HMAC-SHA256(secret, body)} 를 소문자 hex 로. */
|
||||
public String sign(String secret, String body) {
|
||||
if (secret == null || body == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
Mac mac = Mac.getInstance(HMAC_ALG);
|
||||
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALG));
|
||||
return toHex(mac.doFinal(body.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
// 알고리즘/키 오류는 검증 실패로 귀결(예외 상세 미전파).
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발신자 제공 서명이 {@code secret+body} 로 계산한 HMAC 과 일치하는지 상수시간 비교.
|
||||
* {@code sha256=} 접두어는 허용, 대소문자 무시. 값이 비었으면 false.
|
||||
*/
|
||||
public boolean verify(String secret, String body, String provided) {
|
||||
if (secret == null || secret.isBlank() || provided == null || provided.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
String given = provided.trim();
|
||||
if (given.regionMatches(true, 0, PREFIX, 0, PREFIX.length())) {
|
||||
given = given.substring(PREFIX.length());
|
||||
}
|
||||
String expected = sign(secret, body);
|
||||
if (expected.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
// 상수시간 비교(길이 불일치도 상수시간으로 흡수).
|
||||
return MessageDigest.isEqual(
|
||||
expected.getBytes(StandardCharsets.UTF_8),
|
||||
given.toLowerCase().getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
/** 아웃바운드 구독 시크릿 저장용 {@code SHA-256(secret)} hex(원문 미저장). */
|
||||
public String hashSecret(String secret) {
|
||||
if (secret == null) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
byte[] d = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8));
|
||||
return toHex(d);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("SHA-256 미지원 환경");
|
||||
}
|
||||
}
|
||||
|
||||
private static String toHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
|
||||
sb.append(Character.forDigit(b & 0xF, 16));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
package com.zioinfo.kintex.webhook.automation;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* F052 리드 팔로업 EDM 자동 트리거 매퍼.
|
||||
* <p>visitor {@code lead} 테이블은 <b>읽기만</b>, marketing {@code edm_campaign} 테이블은 <b>insert 만</b>(패키지 파일 미수정).
|
||||
* 멱등 로그 {@code edm_followup_log} 만 본 모듈이 소유한다.
|
||||
*/
|
||||
@Mapper
|
||||
public interface FollowupMapper {
|
||||
|
||||
/**
|
||||
* 아직 팔로업 로그가 없는 hot 리드(score >= 80)를 조회. (LEFT JOIN NULL 로 미처리분만 · 재스윕 중복 방지)
|
||||
* PII(name/phone/email) 는 SELECT 하지 않는다 — company/product/followup_draft 만(본문 초안 재사용).
|
||||
*/
|
||||
@Select("""
|
||||
SELECT l.id AS "leadId", l.event_id AS "eventId", l.company AS "company",
|
||||
l.product AS "product", l.score AS "score", l.followup_draft AS "bodyDraft"
|
||||
FROM lead l
|
||||
LEFT JOIN edm_followup_log f
|
||||
ON f.event_id = l.event_id AND f.lead_id = l.id
|
||||
WHERE l.score >= 80 AND f.id IS NULL
|
||||
ORDER BY l.score DESC, l.id
|
||||
LIMIT #{limit}
|
||||
""")
|
||||
List<Map<String, Object>> findUnprocessedHotLeads(@Param("limit") int limit);
|
||||
|
||||
/**
|
||||
* 멱등 로그 예약 — {@code UNIQUE(event_id, lead_id)} 충돌 시 미삽입(0 반환).
|
||||
* 1 반환 시에만 캠페인 초안 생성(중복 생성 차단 핵심).
|
||||
*/
|
||||
@Insert("""
|
||||
INSERT INTO edm_followup_log (tenant_id, id, event_id, lead_id, hot_score, body_draft)
|
||||
VALUES ('KINTEX', #{id}, #{eventId}, #{leadId}, #{score}, #{bodyDraft})
|
||||
ON CONFLICT (event_id, lead_id) DO NOTHING
|
||||
""")
|
||||
int reserveFollowup(@Param("id") String id,
|
||||
@Param("eventId") String eventId,
|
||||
@Param("leadId") String leadId,
|
||||
@Param("score") int score,
|
||||
@Param("bodyDraft") String bodyDraft);
|
||||
|
||||
/** marketing edm_campaign 에 draft 캠페인 1건 insert(발송 없음 · 기존 컬럼만 사용). */
|
||||
@Insert("""
|
||||
INSERT INTO edm_campaign (id, event_id, name, status, audience, meta, open_rate, click_rate)
|
||||
VALUES (#{id}, #{eventId}, #{name}, 'draft', 1, #{meta}, '-', '-')
|
||||
""")
|
||||
int insertFollowupCampaign(@Param("id") String id,
|
||||
@Param("eventId") String eventId,
|
||||
@Param("name") String name,
|
||||
@Param("meta") String meta);
|
||||
|
||||
@Update("""
|
||||
UPDATE edm_followup_log SET campaign_id = #{campaignId}
|
||||
WHERE tenant_id = 'KINTEX' AND id = #{id}
|
||||
""")
|
||||
int linkCampaign(@Param("id") String id, @Param("campaignId") String campaignId);
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
package com.zioinfo.kintex.webhook.automation;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* F052 리드 팔로업 EDM 자동 트리거 — hot 리드(score>=80) 발생/재계산 시 팔로업 캠페인 초안(status=draft)을
|
||||
* 자동 생성한다. <b>발송은 하지 않는다</b>(초안만 — 기존 마케팅 화면에서 조회됨).
|
||||
*
|
||||
* <p>구현: {@code @Scheduled} 5분 주기 스윕. 아직 로그가 없는 hot 리드만 골라 리드 1건당 캠페인 초안 1건 생성.
|
||||
* 멱등 키 {@code edm_followup_log UNIQUE(event_id, lead_id)} 로 중복 생성을 차단한다(로그 예약이 성공한 경우에만
|
||||
* 캠페인 insert). 리드/캠페인 테이블은 각각 read/insert 만 사용(visitor/marketing 패키지 파일 미수정).
|
||||
*/
|
||||
@Service
|
||||
public class LeadFollowupAutomation {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LeadFollowupAutomation.class);
|
||||
|
||||
/** 한 스윕당 처리 상한(폭주 방지). */
|
||||
private static final int SWEEP_LIMIT = 200;
|
||||
|
||||
private final FollowupMapper mapper;
|
||||
|
||||
public LeadFollowupAutomation(FollowupMapper mapper) {
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
/** 5분 주기 스윕(기동 60초 후 최초 실행). */
|
||||
@Scheduled(initialDelay = 60_000, fixedDelay = 300_000)
|
||||
public void scheduledSweep() {
|
||||
try {
|
||||
int created = sweepOnce();
|
||||
if (created > 0) {
|
||||
log.info("F052 팔로업 EDM 초안 자동 생성 {}건", created);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// 스케줄러는 조용히 실패 흡수(다음 주기 재시도) — 상세는 로그만.
|
||||
log.warn("F052 팔로업 스윕 실패: {}", e.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 1회 스윕 — 미처리 hot 리드마다 로그 예약 후(멱등) 성공분에 한해 캠페인 초안을 생성한다.
|
||||
* @return 이번 스윕에서 새로 생성한 캠페인 초안 수
|
||||
*/
|
||||
@Transactional
|
||||
public int sweepOnce() {
|
||||
List<Map<String, Object>> leads = mapper.findUnprocessedHotLeads(SWEEP_LIMIT);
|
||||
if (leads == null || leads.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int created = 0;
|
||||
for (Map<String, Object> l : leads) {
|
||||
String eventId = str(l.get("eventId"));
|
||||
String leadId = str(l.get("leadId"));
|
||||
if (eventId == null || leadId == null) {
|
||||
continue;
|
||||
}
|
||||
int score = intVal(l.get("score"));
|
||||
String bodyDraft = str(l.get("bodyDraft"));
|
||||
String logId = "fl-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
// 멱등 예약 — 이미 처리된 리드면 0(다른 스윕/인스턴스와의 경합도 흡수).
|
||||
if (mapper.reserveFollowup(logId, eventId, leadId, score, bodyDraft) == 0) {
|
||||
continue;
|
||||
}
|
||||
String campaignId = "cp-fl-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12);
|
||||
mapper.insertFollowupCampaign(campaignId, eventId, buildName(l),
|
||||
"hot 리드 자동 팔로업 (score " + score + ")");
|
||||
mapper.linkCampaign(logId, campaignId);
|
||||
created++;
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
private static String buildName(Map<String, Object> lead) {
|
||||
String company = str(lead.get("company"));
|
||||
String product = str(lead.get("product"));
|
||||
StringBuilder sb = new StringBuilder("[팔로업] ");
|
||||
sb.append(company == null || company.isBlank() ? "핫 리드" : company);
|
||||
if (product != null && !product.isBlank()) {
|
||||
sb.append(" · ").append(product);
|
||||
}
|
||||
return sb.length() > 290 ? sb.substring(0, 290) : sb.toString();
|
||||
}
|
||||
|
||||
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 Integer.parseInt(String.valueOf(o));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package com.zioinfo.kintex.webhook.automation;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
|
||||
/**
|
||||
* F052 팔로업 자동 트리거용 스케줄링 활성화. {@link LeadFollowupAutomation#scheduledSweep()} 를
|
||||
* 주기 실행한다. 애플리케이션 전역 {@code @Scheduled} 를 켜지만 현재 스윕 빈만 스케줄 대상이다.
|
||||
*/
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
public class WebhookSchedulingConfig {
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
package com.zioinfo.kintex.webhook.dto;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* F099 웹훅 게이트웨이 DTO 모음.
|
||||
* <p><b>보안 불변</b>: 시크릿 원문·해시·봉인값은 어떤 응답 DTO 에도 담지 않는다(등록 시 1회만 클라이언트가 보유).
|
||||
* 아웃바운드 발송은 스텁(disabled) — 실 HTTP 콜은 수행하지 않는다.
|
||||
*/
|
||||
public final class WebhookDtos {
|
||||
|
||||
private WebhookDtos() {
|
||||
}
|
||||
|
||||
// ── 아웃바운드 구독(관리자 CRUD) ──
|
||||
/** 구독 응답 — secret 은 제외(등록 시에만 요청자가 원문 보유). */
|
||||
public record SubscriptionDto(String id, String eventType, String callbackUrl,
|
||||
String description, boolean active,
|
||||
OffsetDateTime createdAt, OffsetDateTime updatedAt) {
|
||||
}
|
||||
|
||||
/** 구독 생성 요청 — secret 원문은 요청 본문으로만 받고 즉시 해시(SHA-256)하여 저장. */
|
||||
public record SubscriptionCreateRequest(String eventType, String callbackUrl,
|
||||
String secret, String description) {
|
||||
}
|
||||
|
||||
/** 구독 수정 요청 — null 필드는 미변경. secret 제공 시에만 해시 재저장. */
|
||||
public record SubscriptionUpdateRequest(String callbackUrl, String secret,
|
||||
String description, Boolean active) {
|
||||
}
|
||||
|
||||
// ── 아웃바운드 발송 큐(스텁) ──
|
||||
public record DeliveryDto(String id, String subscriptionId, String eventType,
|
||||
String status, String note, OffsetDateTime createdAt) {
|
||||
}
|
||||
|
||||
// ── 인바운드 수신 엔드포인트 설정(관리자) ──
|
||||
public record InboundEndpointDto(String id, String token, String sourceLabel,
|
||||
boolean active, OffsetDateTime createdAt) {
|
||||
}
|
||||
|
||||
public record InboundEndpointCreateRequest(String sourceLabel, String secret) {
|
||||
}
|
||||
|
||||
// ── 인바운드 수신 결과(수신 엔드포인트 응답) ──
|
||||
/** 수신 결과 — 상세 사유는 노출하지 않고 accepted 여부만(정보 최소 노출). */
|
||||
public record InboundReceipt(boolean accepted, String receiptId) {
|
||||
}
|
||||
|
||||
// ── 인바운드 이벤트 로그 조회(관리자) ──
|
||||
public record InboundEventDto(String id, String endpointToken, String eventType,
|
||||
boolean signatureValid, OffsetDateTime receivedAt) {
|
||||
}
|
||||
|
||||
/** 구독 관리 화면 종합(후속 웨이브 화면용 계약). */
|
||||
public record WebhookAdminView(List<SubscriptionDto> subscriptions,
|
||||
List<InboundEndpointDto> inboundEndpoints) {
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
package com.zioinfo.kintex.work.approval;
|
||||
|
||||
import com.zioinfo.kintex.auth.EventAccessGuard;
|
||||
import com.zioinfo.kintex.auth.KintexPrincipal;
|
||||
import com.zioinfo.kintex.common.ApiResponse;
|
||||
import com.zioinfo.kintex.common.PageResponse;
|
||||
import com.zioinfo.kintex.common.audit.Audited;
|
||||
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.ApprovalListDto;
|
||||
import com.zioinfo.kintex.work.approval.dto.ApprovalDtos.ApprovalSaveRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* §5B 공통 전자결재 API (/api/work/approvals). F028.
|
||||
* 조회=인증, 승인/반려=현재 단계 결재선 당사자(서비스에서 403), 기안/상신/회수/재상신=기안자.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/work/approvals")
|
||||
public class ApprovalController {
|
||||
|
||||
private final ApprovalService service;
|
||||
private final EventAccessGuard authGuard;
|
||||
|
||||
public ApprovalController(ApprovalService service, EventAccessGuard authGuard) {
|
||||
this.service = service;
|
||||
this.authGuard = authGuard;
|
||||
}
|
||||
|
||||
/** 결재함 목록. box=DRAFT|DRAFTED|PENDING|DONE. */
|
||||
@GetMapping
|
||||
public ApiResponse<PageResponse<ApprovalListDto>> list(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@RequestParam(defaultValue = "DRAFTED") String box,
|
||||
@RequestParam(required = false) String keyword,
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
authGuard.require(principal);
|
||||
return ApiResponse.ok(service.list(principal, box, keyword, page, size));
|
||||
}
|
||||
|
||||
/** 문서 상세(결재선·이력·액션 플래그). */
|
||||
@GetMapping("/{id}")
|
||||
public ApiResponse<ApprovalDetailDto> get(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
authGuard.require(principal);
|
||||
return ApiResponse.ok(service.get(principal, id));
|
||||
}
|
||||
|
||||
/** 기안(DRAFT 생성). */
|
||||
@Audited(action = "APPROVAL_CREATE", targetType = "approval")
|
||||
@PostMapping
|
||||
public ApiResponse<ApprovalDetailDto> create(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@Valid @RequestBody ApprovalSaveRequest req) {
|
||||
authGuard.require(principal);
|
||||
return ApiResponse.ok(service.create(principal, req));
|
||||
}
|
||||
|
||||
/** 상신(DRAFT→SUBMITTED). */
|
||||
@Audited(action = "APPROVAL_SUBMIT", targetType = "approval")
|
||||
@PostMapping("/{id}/submit")
|
||||
public ApiResponse<ApprovalDetailDto> submit(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
authGuard.require(principal);
|
||||
return ApiResponse.ok(service.submit(principal, id));
|
||||
}
|
||||
|
||||
/** 승인(현재 단계 결재자). */
|
||||
@Audited(action = "APPROVAL_APPROVE", targetType = "approval")
|
||||
@PostMapping("/{id}/approve")
|
||||
public ApiResponse<ApprovalDetailDto> approve(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody(required = false) ApprovalActionRequest req) {
|
||||
authGuard.require(principal);
|
||||
return ApiResponse.ok(service.approve(principal, id, req));
|
||||
}
|
||||
|
||||
/** 반려(사유 필수). */
|
||||
@Audited(action = "APPROVAL_REJECT", targetType = "approval")
|
||||
@PostMapping("/{id}/reject")
|
||||
public ApiResponse<ApprovalDetailDto> reject(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody(required = false) ApprovalActionRequest req) {
|
||||
authGuard.require(principal);
|
||||
return ApiResponse.ok(service.reject(principal, id, req));
|
||||
}
|
||||
|
||||
/** 재상신(REJECTED→SUBMITTED, version+1). */
|
||||
@Audited(action = "APPROVAL_RESUBMIT", targetType = "approval")
|
||||
@PostMapping("/{id}/resubmit")
|
||||
public ApiResponse<ApprovalDetailDto> resubmit(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody(required = false) ApprovalSaveRequest req) {
|
||||
authGuard.require(principal);
|
||||
return ApiResponse.ok(service.resubmit(principal, id, req));
|
||||
}
|
||||
|
||||
/** 회수(기안자 취소). */
|
||||
@Audited(action = "APPROVAL_CANCEL", targetType = "approval")
|
||||
@PostMapping("/{id}/cancel")
|
||||
public ApiResponse<ApprovalDetailDto> cancel(@AuthenticationPrincipal KintexPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
authGuard.require(principal);
|
||||
return ApiResponse.ok(service.cancel(principal, id));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,183 @@
|
||||
package com.zioinfo.kintex.work.approval;
|
||||
|
||||
import org.apache.ibatis.annotations.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 전자결재 매퍼(F028). 결재 문서·결재선·이력 CRUD 및 결재함 조회.
|
||||
* ★ <script> 내 비교연산은 escape(>) 사용 — MapperAnnotationParseTest 회귀 방지.
|
||||
*/
|
||||
@Mapper
|
||||
public interface ApprovalMapper {
|
||||
|
||||
// ─────────────────────────────── 결재 문서
|
||||
@Insert("""
|
||||
INSERT INTO approval (id, tenant_id, event_id, doc_type, title, content, ref_type, ref_id,
|
||||
drafter_id, drafter_name, status, cur_step, version)
|
||||
VALUES (#{id}, COALESCE(#{tenantId},'kintex'), #{eventId}, COALESCE(#{docType},'GENERAL'),
|
||||
#{title}, #{content}, #{refType}, #{refId}, #{drafterId}, #{drafterName},
|
||||
'DRAFT', 0, 1)
|
||||
""")
|
||||
int insert(Map<String, Object> p);
|
||||
|
||||
@Select("""
|
||||
SELECT id, tenant_id AS "tenantId", event_id AS "eventId", doc_type AS "docType",
|
||||
title, content, ref_type AS "refType", ref_id AS "refId",
|
||||
drafter_id AS "drafterId", drafter_name AS "drafterName",
|
||||
status, cur_step AS "curStep", version,
|
||||
to_char(submitted_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "submittedAt",
|
||||
to_char(completed_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "completedAt",
|
||||
to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt"
|
||||
FROM approval WHERE id = #{id}
|
||||
""")
|
||||
Map<String, Object> findById(@Param("id") String id);
|
||||
|
||||
@Update("""
|
||||
<script>
|
||||
UPDATE approval
|
||||
<set>
|
||||
<if test="title != null">title=#{title},</if>
|
||||
<if test="content != null">content=#{content},</if>
|
||||
<if test="docType != null">doc_type=#{docType},</if>
|
||||
<if test="refType != null">ref_type=#{refType},</if>
|
||||
<if test="refId != null">ref_id=#{refId},</if>
|
||||
<if test="status != null">status=#{status},</if>
|
||||
<if test="curStep != null">cur_step=#{curStep},</if>
|
||||
<if test="version != null">version=#{version},</if>
|
||||
<if test="submittedAt != null">submitted_at=now(),</if>
|
||||
<if test="completedAt != null">completed_at=now(),</if>
|
||||
<if test="clearCompleted != null">completed_at=NULL,</if>
|
||||
updated_at=now()
|
||||
</set>
|
||||
WHERE id = #{id}
|
||||
</script>
|
||||
""")
|
||||
int updateDoc(Map<String, Object> p);
|
||||
|
||||
// ─────────────────────────────── 결재함 조회(box: DRAFT/DRAFTED/PENDING/DONE)
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT a.id, a.doc_type AS "docType", a.title, a.status,
|
||||
a.drafter_id AS "drafterId", a.drafter_name AS "drafterName",
|
||||
a.cur_step AS "curStep", a.version,
|
||||
a.event_id AS "eventId", a.ref_type AS "refType", a.ref_id AS "refId",
|
||||
(SELECT count(*) FROM approval_line l WHERE l.approval_id = a.id) AS "totalSteps",
|
||||
to_char(a.submitted_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "submittedAt",
|
||||
to_char(a.created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt"
|
||||
FROM approval a
|
||||
<where>
|
||||
a.tenant_id = #{tenantId}
|
||||
<choose>
|
||||
<when test="box == 'DRAFT'">
|
||||
AND a.drafter_id = #{me} AND a.status = 'DRAFT'
|
||||
</when>
|
||||
<when test="box == 'DRAFTED'">
|
||||
AND a.drafter_id = #{me}
|
||||
</when>
|
||||
<when test="box == 'PENDING'">
|
||||
AND a.status IN ('SUBMITTED','IN_PROGRESS')
|
||||
AND EXISTS (SELECT 1 FROM approval_line l
|
||||
WHERE l.approval_id = a.id AND l.approver_id = #{me}
|
||||
AND l.line_status = 'CURRENT')
|
||||
</when>
|
||||
<when test="box == 'DONE'">
|
||||
AND EXISTS (SELECT 1 FROM approval_line l
|
||||
WHERE l.approval_id = a.id AND l.approver_id = #{me}
|
||||
AND l.line_status IN ('APPROVED','REJECTED'))
|
||||
</when>
|
||||
</choose>
|
||||
<if test="keyword != null and keyword != ''">AND a.title ILIKE '%'||#{keyword}||'%'</if>
|
||||
</where>
|
||||
ORDER BY a.updated_at DESC LIMIT #{size} OFFSET #{offset}
|
||||
</script>
|
||||
""")
|
||||
List<Map<String, Object>> searchBox(Map<String, Object> q);
|
||||
|
||||
@Select("""
|
||||
<script>
|
||||
SELECT count(*)
|
||||
FROM approval a
|
||||
<where>
|
||||
a.tenant_id = #{tenantId}
|
||||
<choose>
|
||||
<when test="box == 'DRAFT'">
|
||||
AND a.drafter_id = #{me} AND a.status = 'DRAFT'
|
||||
</when>
|
||||
<when test="box == 'DRAFTED'">
|
||||
AND a.drafter_id = #{me}
|
||||
</when>
|
||||
<when test="box == 'PENDING'">
|
||||
AND a.status IN ('SUBMITTED','IN_PROGRESS')
|
||||
AND EXISTS (SELECT 1 FROM approval_line l
|
||||
WHERE l.approval_id = a.id AND l.approver_id = #{me}
|
||||
AND l.line_status = 'CURRENT')
|
||||
</when>
|
||||
<when test="box == 'DONE'">
|
||||
AND EXISTS (SELECT 1 FROM approval_line l
|
||||
WHERE l.approval_id = a.id AND l.approver_id = #{me}
|
||||
AND l.line_status IN ('APPROVED','REJECTED'))
|
||||
</when>
|
||||
</choose>
|
||||
<if test="keyword != null and keyword != ''">AND a.title ILIKE '%'||#{keyword}||'%'</if>
|
||||
</where>
|
||||
</script>
|
||||
""")
|
||||
long countBox(Map<String, Object> 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<String, Object> 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<Map<String, Object>> findLines(@Param("approvalId") String approvalId);
|
||||
|
||||
@Update("""
|
||||
<script>
|
||||
UPDATE approval_line
|
||||
<set>
|
||||
line_status=#{lineStatus},
|
||||
<if test="comment != null">comment=#{comment},</if>
|
||||
<if test="acted != null">acted_at=now(),</if>
|
||||
</set>
|
||||
WHERE id = #{id}
|
||||
</script>
|
||||
""")
|
||||
int updateLine(Map<String, Object> 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<String, Object> 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<Map<String, Object>> findHistory(@Param("approvalId") String approvalId);
|
||||
}
|
||||
@ -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 이식).
|
||||
*
|
||||
* <p>직렬 다단계(step 1→N 순차) 결재. 상태 전이:
|
||||
* DRAFT →(submit)→ SUBMITTED →(approve 중간)→ IN_PROGRESS →(approve 마지막)→ APPROVED,
|
||||
* (reject)→ REJECTED →(resubmit, version+1)→ SUBMITTED, (cancel)→ CANCELED.
|
||||
* 승인/반려는 <b>현재 단계 결재선 당사자</b>만 가능(그 외 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<ApprovalListDto> 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<String, Object> 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<Map<String, Object>> 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<String, Object> a = load(id);
|
||||
assertTenant(principal, a);
|
||||
List<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> a = load(id);
|
||||
assertTenant(principal, a);
|
||||
assertInFlight(a);
|
||||
Map<String, Object> current = requireCurrentApprover(principal, id);
|
||||
String comment = req == null ? null : req.comment();
|
||||
|
||||
Map<String, Object> 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<String, Object> next = nextWaiting(mapper.findLines(id), intVal(current.get("stepNo")));
|
||||
if (next != null) {
|
||||
Map<String, Object> nl = new HashMap<>();
|
||||
nl.put("id", str(next.get("id")));
|
||||
nl.put("lineStatus", "CURRENT");
|
||||
mapper.updateLine(nl);
|
||||
Map<String, Object> 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<String, Object> a = load(id);
|
||||
assertTenant(principal, a);
|
||||
assertInFlight(a);
|
||||
Map<String, Object> current = requireCurrentApprover(principal, id);
|
||||
String comment = req == null ? null : req.comment();
|
||||
if (comment == null || comment.isBlank()) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "반려 사유(의견)를 입력해야 합니다.");
|
||||
}
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> a = load(id);
|
||||
assertTenant(principal, a);
|
||||
assertDrafter(principal, a);
|
||||
if (!"REJECTED".equals(status(a))) {
|
||||
throw new ApiException(ErrorCode.CONFLICT, "반려(REJECTED) 상태만 재상신할 수 있습니다.");
|
||||
}
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> lines = mapper.findLines(id);
|
||||
if (lines.isEmpty()) {
|
||||
throw new ApiException(ErrorCode.VALIDATION, "결재선을 1명 이상 지정해야 합니다.");
|
||||
}
|
||||
mapper.resetLines(id);
|
||||
Map<String, Object> first = lines.get(0);
|
||||
Map<String, Object> fl = new HashMap<>();
|
||||
fl.put("id", str(first.get("id")));
|
||||
fl.put("lineStatus", "CURRENT");
|
||||
mapper.updateLine(fl);
|
||||
|
||||
Map<String, Object> 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<String, Object> a = load(id);
|
||||
notify(str(first.get("approverId")), a, "[결재 요청] " + str(a.get("title")),
|
||||
"결재할 문서가 도착했습니다.", id);
|
||||
}
|
||||
|
||||
private void complete(Map<String, Object> a, String id) {
|
||||
Map<String, Object> 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<ApprovalLineSaveRequest> lineReqs) {
|
||||
mapper.deleteLines(approvalId);
|
||||
if (lineReqs == null || lineReqs.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
List<ApprovalLineSaveRequest> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> nextWaiting(List<Map<String, Object>> 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<String, Object> requireCurrentApprover(KintexPrincipal principal, String id) {
|
||||
Map<String, Object> 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<String, Object> a) {
|
||||
if (!principal.userId().equals(str(a.get("drafterId")))) {
|
||||
throw new ApiException(ErrorCode.FORBIDDEN, "기안자만 수행할 수 있습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void assertInFlight(Map<String, Object> a) {
|
||||
String st = status(a);
|
||||
if (!"SUBMITTED".equals(st) && !"IN_PROGRESS".equals(st)) {
|
||||
throw new ApiException(ErrorCode.CONFLICT, "진행중인 문서만 결재할 수 있습니다.");
|
||||
}
|
||||
}
|
||||
|
||||
private void assertTenant(KintexPrincipal principal, Map<String, Object> 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<String, Object> a, List<Map<String, Object>> 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<String, Object> load(String id) {
|
||||
Map<String, Object> 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<String, Object> 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<String, Object> a,
|
||||
List<Map<String, Object>> lineRows, List<Map<String, Object>> histRows) {
|
||||
String me = principal.userId();
|
||||
String st = str(a.get("status"));
|
||||
List<ApprovalLineDto> 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<ApprovalHistoryDto> 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<String, Object> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<ApprovalLineDto> lines, List<ApprovalHistoryDto> 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<ApprovalLineSaveRequest> lines) {
|
||||
}
|
||||
|
||||
/** 승인/반려 액션 요청(의견). 반려는 comment 필수(서비스 검증). */
|
||||
public record ApprovalActionRequest(String comment) {
|
||||
}
|
||||
}
|
||||
@ -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 만료';
|
||||
@ -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;
|
||||
@ -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);
|
||||
@ -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;
|
||||
@ -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 <t>(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''.';
|
||||
@ -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-<seq>', 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';
|
||||
@ -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;
|
||||
@ -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 게이트)';
|
||||
@ -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 가 미봉인('') 엔드포인트에 한해 채운다. 여기선 레코드 미삽입(안전).
|
||||
@ -0,0 +1,15 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<!--
|
||||
NlQueryReadMapper — F092 자연어 조회 read-only 실행.
|
||||
★${sql}은 원문 치환이므로, 반드시 NlQuerySqlGuard 통과 SQL만 전달한다(단일 SELECT·화이트리스트·PII 차단·LIMIT 200).
|
||||
결과는 java.util.LinkedHashMap 으로 매핑해 컬럼(select 절) 순서를 보존한다.
|
||||
-->
|
||||
<mapper namespace="com.zioinfo.kintex.ai.nlquery.NlQueryReadMapper">
|
||||
|
||||
<select id="runReadOnly" resultType="java.util.LinkedHashMap">
|
||||
${sql}
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
23
src/backend/src/main/resources/rulesets/refund-v1.0.json
Normal file
23
src/backend/src/main/resources/rulesets/refund-v1.0.json
Normal file
@ -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": []
|
||||
}
|
||||
@ -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(" ");
|
||||
}
|
||||
}
|
||||
@ -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<BoqLine> 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<BoqLine> lines = BoqCalc.compute(100.0, 40.0, 1, 3.5, 0);
|
||||
assertEquals(12L, lines.get(3).qty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void omitsUtilityLineWhenNoOrders() {
|
||||
List<BoqLine> 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<BoqLine> 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<BoqLine> 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
|
||||
}
|
||||
}
|
||||
@ -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()); // 미설정 폴백
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> row(String boothId, String status, Long unitPrice, String heldBy, double area) {
|
||||
Map<String, Object> 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<Map<String, Object>> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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());
|
||||
}
|
||||
}
|
||||
@ -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<String, Object> 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\":\"<p>Body</p><script>x()</script>\"}", "claude", false));
|
||||
Map<String, Object> trRow = new HashMap<>();
|
||||
trRow.put("contentId", "cms-1");
|
||||
trRow.put("lang", "en");
|
||||
trRow.put("title", "Title");
|
||||
trRow.put("body", "<p>Body</p>");
|
||||
trRow.put("transStatus", "ai");
|
||||
when(mapper.findTranslations("cms-1")).thenReturn(java.util.List.of(trRow));
|
||||
service.aiTranslate(editor, "cms-1", "en");
|
||||
ArgumentCaptor<Map<String, Object>> 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("<p>Body</p>"), "허용 태그 보존");
|
||||
assertFalse(body.toLowerCase().contains("<script"), "script 제거");
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
package com.zioinfo.kintex.settlement;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* F084 옥션 수수료 산정 단위테스트 — 요율(룰셋/기본 5%)·부가세 10%·총액 산식 고정.
|
||||
*/
|
||||
class AuctionFeeTest {
|
||||
|
||||
@Test
|
||||
void appliesRulesetRate() {
|
||||
// 낙찰가 8,400,000 × 5% = 420,000, VAT 42,000, total 462,000
|
||||
AuctionFee.Fee f = AuctionFee.compute(8_400_000L, 5.0);
|
||||
assertEquals(8_400_000L, f.base());
|
||||
assertEquals(5.0, f.ratePercent());
|
||||
assertEquals(420_000L, f.fee());
|
||||
assertEquals(42_000L, f.vat());
|
||||
assertEquals(462_000L, f.total());
|
||||
}
|
||||
|
||||
@Test
|
||||
void fallsBackToDefaultRateWhenNullOrNonPositive() {
|
||||
AuctionFee.Fee f1 = AuctionFee.compute(10_000_000L, null);
|
||||
assertEquals(AuctionFee.DEFAULT_FEE_RATE_PERCENT, f1.ratePercent());
|
||||
assertEquals(500_000L, f1.fee()); // 5%
|
||||
assertEquals(550_000L, f1.total());
|
||||
|
||||
AuctionFee.Fee f2 = AuctionFee.compute(10_000_000L, 0.0);
|
||||
assertEquals(AuctionFee.DEFAULT_FEE_RATE_PERCENT, f2.ratePercent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void customRateRoundsHalfUp() {
|
||||
// 3,333,333 × 3% = 99,999.99 → round 100,000
|
||||
AuctionFee.Fee f = AuctionFee.compute(3_333_333L, 3.0);
|
||||
assertEquals(100_000L, f.fee());
|
||||
assertEquals(10_000L, f.vat());
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroWhenNonPositiveWinningTotal() {
|
||||
AuctionFee.Fee f = AuctionFee.compute(0L, 5.0);
|
||||
assertEquals(0L, f.fee());
|
||||
assertEquals(0L, f.vat());
|
||||
assertEquals(0L, f.total());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,74 @@
|
||||
package com.zioinfo.kintex.settlement;
|
||||
|
||||
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.assertNull;
|
||||
|
||||
/**
|
||||
* F085 환불 산정 단위테스트 — D-일 구간 선택·환불액·공제액 산식 고정(refund-v1.0 기준 구간).
|
||||
*/
|
||||
class RefundCalcTest {
|
||||
|
||||
private static final List<RefundCalc.Bracket> BRACKETS = List.of(
|
||||
new RefundCalc.Bracket(90, 100, "D-90 이전"),
|
||||
new RefundCalc.Bracket(60, 90, "D-60 ~ D-89"),
|
||||
new RefundCalc.Bracket(30, 70, "D-30 ~ D-59"),
|
||||
new RefundCalc.Bracket(14, 50, "D-14 ~ D-29"),
|
||||
new RefundCalc.Bracket(7, 30, "D-7 ~ D-13"),
|
||||
new RefundCalc.Bracket(0, 0, "D-6 이내"));
|
||||
|
||||
@Test
|
||||
void selectsBracketByDaysBefore() {
|
||||
assertEquals(100, RefundCalc.bracketFor(120, BRACKETS).refundRatePercent());
|
||||
assertEquals(100, RefundCalc.bracketFor(90, BRACKETS).refundRatePercent()); // 경계 포함
|
||||
assertEquals(90, RefundCalc.bracketFor(89, BRACKETS).refundRatePercent());
|
||||
assertEquals(70, RefundCalc.bracketFor(30, BRACKETS).refundRatePercent()); // 데모: e-2026-smf D-30
|
||||
assertEquals(50, RefundCalc.bracketFor(20, BRACKETS).refundRatePercent());
|
||||
assertEquals(30, RefundCalc.bracketFor(7, BRACKETS).refundRatePercent());
|
||||
assertEquals(0, RefundCalc.bracketFor(3, BRACKETS).refundRatePercent());
|
||||
assertEquals(0, RefundCalc.bracketFor(0, BRACKETS).refundRatePercent());
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterEventHasNoMatchingPositiveBracketButLandsAtZero() {
|
||||
// 음수(행사 후)는 minDaysBefore=0 구간도 만족하지 못하므로 미매칭(호출부에서 afterEvent 0% 처리)
|
||||
assertNull(RefundCalc.bracketFor(-1, BRACKETS));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refundAmountRoundsHalfUp() {
|
||||
// base 3,960,000 × 70% = 2,772,000
|
||||
assertEquals(2_772_000L, RefundCalc.refundAmount(3_960_000L, 70));
|
||||
// 반올림(half-up): 3 × 50% = 1.5 → 2
|
||||
assertEquals(2L, RefundCalc.refundAmount(3L, 50));
|
||||
// 1,000,001 × 33.33% = 333,300.33 → 333,300
|
||||
assertEquals(333_300L, RefundCalc.refundAmount(1_000_001L, 33.33));
|
||||
}
|
||||
|
||||
@Test
|
||||
void refundZeroWhenNoBaseOrZeroRate() {
|
||||
assertEquals(0L, RefundCalc.refundAmount(0L, 100));
|
||||
assertEquals(0L, RefundCalc.refundAmount(1_000_000L, 0));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rateClampedTo100() {
|
||||
assertEquals(1_000_000L, RefundCalc.refundAmount(1_000_000L, 150));
|
||||
}
|
||||
|
||||
@Test
|
||||
void forfeitIsBaseMinusRefundNonNegative() {
|
||||
long base = 3_960_000L;
|
||||
long refund = RefundCalc.refundAmount(base, 70);
|
||||
assertEquals(base - refund, RefundCalc.forfeitAmount(base, refund));
|
||||
assertEquals(0L, RefundCalc.forfeitAmount(100L, 200L)); // 환불>기준(방어) → 0
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyBracketsReturnNull() {
|
||||
assertNull(RefundCalc.bracketFor(50, List.of()));
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
package com.zioinfo.kintex.visitor;
|
||||
|
||||
import com.zioinfo.kintex.ai.AiTextRouter;
|
||||
import com.zioinfo.kintex.visitor.dto.VisitorDtos.CheckinRequest;
|
||||
import com.zioinfo.kintex.visitor.dto.VisitorDtos.CheckinResult;
|
||||
import com.zioinfo.kintex.visitor.scoring.RuleBasedLeadScorer;
|
||||
@ -36,7 +37,7 @@ class VisitorCheckinServiceTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mapper = mock(VisitorMapper.class);
|
||||
service = new VisitorService(mapper, new RuleBasedLeadScorer());
|
||||
service = new VisitorService(mapper, new RuleBasedLeadScorer(), mock(AiTextRouter.class));
|
||||
}
|
||||
|
||||
private static Map<String, Object> row(String state, String checkinAt) {
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
package com.zioinfo.kintex.webhook;
|
||||
|
||||
import com.zioinfo.kintex.webhook.automation.FollowupMapper;
|
||||
import com.zioinfo.kintex.webhook.automation.LeadFollowupAutomation;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* F052 팔로업 EDM 자동 트리거 멱등성 단위테스트(FollowupMapper 모킹).
|
||||
*/
|
||||
class LeadFollowupAutomationTest {
|
||||
|
||||
private Map<String, Object> lead(String id, String eventId, int score) {
|
||||
return Map.of("leadId", id, "eventId", eventId, "score", score,
|
||||
"company", "(주)테크솔루션", "product", "협동로봇", "bodyDraft", "draft body");
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsOneCampaignPerReservedHotLead() {
|
||||
FollowupMapper mapper = mock(FollowupMapper.class);
|
||||
when(mapper.findUnprocessedHotLeads(anyInt()))
|
||||
.thenReturn(List.of(lead("ld-1", "e-1", 87), lead("ld-2", "e-1", 82)));
|
||||
// 둘 다 예약 성공(신규).
|
||||
when(mapper.reserveFollowup(anyString(), anyString(), anyString(), anyInt(), any()))
|
||||
.thenReturn(1);
|
||||
|
||||
LeadFollowupAutomation auto = new LeadFollowupAutomation(mapper);
|
||||
int created = auto.sweepOnce();
|
||||
|
||||
assertEquals(2, created, "hot 리드 2건 → 캠페인 초안 2건");
|
||||
verify(mapper, times(2)).insertFollowupCampaign(anyString(), anyString(), anyString(), anyString());
|
||||
verify(mapper, times(2)).linkCampaign(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void idempotentSkipsAlreadyProcessedLead() {
|
||||
FollowupMapper mapper = mock(FollowupMapper.class);
|
||||
when(mapper.findUnprocessedHotLeads(anyInt()))
|
||||
.thenReturn(List.of(lead("ld-1", "e-1", 90)));
|
||||
// 이미 처리됨 — 로그 예약 충돌(0) → 캠페인 생성 금지.
|
||||
when(mapper.reserveFollowup(anyString(), eq("e-1"), eq("ld-1"), anyInt(), any()))
|
||||
.thenReturn(0);
|
||||
|
||||
LeadFollowupAutomation auto = new LeadFollowupAutomation(mapper);
|
||||
int created = auto.sweepOnce();
|
||||
|
||||
assertEquals(0, created, "예약 충돌 시 캠페인 미생성(멱등)");
|
||||
verify(mapper, never()).insertFollowupCampaign(anyString(), anyString(), anyString(), anyString());
|
||||
verify(mapper, never()).linkCampaign(anyString(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void noCandidatesProducesNoCampaign() {
|
||||
FollowupMapper mapper = mock(FollowupMapper.class);
|
||||
when(mapper.findUnprocessedHotLeads(anyInt())).thenReturn(List.of());
|
||||
|
||||
LeadFollowupAutomation auto = new LeadFollowupAutomation(mapper);
|
||||
assertEquals(0, auto.sweepOnce());
|
||||
verify(mapper, never()).reserveFollowup(anyString(), anyString(), anyString(), anyInt(), any());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
package com.zioinfo.kintex.webhook;
|
||||
|
||||
import com.zioinfo.kintex.common.error.ApiException;
|
||||
import com.zioinfo.kintex.common.error.ErrorCode;
|
||||
import com.zioinfo.kintex.security.SecretCipher;
|
||||
import com.zioinfo.kintex.security.SecurityPolicyProperties;
|
||||
import com.zioinfo.kintex.webhook.dto.WebhookDtos.InboundReceipt;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
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 static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* F099 인바운드 수신 서비스 — 토큰 + HMAC 이중 검증 흐름 단위테스트.
|
||||
*/
|
||||
class WebhookInboundServiceTest {
|
||||
|
||||
private final WebhookSignature signature = new WebhookSignature();
|
||||
private final SecretCipher cipher = new SecretCipher(new SecurityPolicyProperties());
|
||||
|
||||
private WebhookService service(WebhookMapper mapper) {
|
||||
return new WebhookService(mapper, signature, cipher);
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsValidSignatureAndStores() {
|
||||
WebhookMapper mapper = mock(WebhookMapper.class);
|
||||
String secret = "whsec_inbound_test";
|
||||
// 저장된 봉인 시크릿 반환.
|
||||
when(mapper.findInboundSecretEnc("tok-1"))
|
||||
.thenReturn(Map.of("secretEnc", cipher.seal(secret)));
|
||||
|
||||
String body = "{\"type\":\"invoice.paid\"}";
|
||||
String validSig = signature.sign(secret, body);
|
||||
|
||||
InboundReceipt receipt = service(mapper).receiveInbound("tok-1", body, validSig, "invoice.paid");
|
||||
|
||||
assertTrue(receipt.accepted());
|
||||
verify(mapper, times(1)).insertInboundEvent(anyString(), eq("tok-1"),
|
||||
eq("invoice.paid"), eq(body), eq(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidSignatureWithoutStoring() {
|
||||
WebhookMapper mapper = mock(WebhookMapper.class);
|
||||
when(mapper.findInboundSecretEnc("tok-1"))
|
||||
.thenReturn(Map.of("secretEnc", cipher.seal("real-secret")));
|
||||
|
||||
ApiException ex = assertThrows(ApiException.class,
|
||||
() -> service(mapper).receiveInbound("tok-1", "{}", "deadbeef", "x"));
|
||||
assertEquals(ErrorCode.UNAUTHORIZED, ex.getCode());
|
||||
// 무효 서명은 적재하지 않는다(DoS/스팸 완화).
|
||||
verify(mapper, never()).insertInboundEvent(anyString(), anyString(), anyString(), anyString(), anyBoolean());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnknownToken() {
|
||||
WebhookMapper mapper = mock(WebhookMapper.class);
|
||||
when(mapper.findInboundSecretEnc(anyString())).thenReturn(null);
|
||||
|
||||
ApiException ex = assertThrows(ApiException.class,
|
||||
() -> service(mapper).receiveInbound("nope", "{}", "sig", "x"));
|
||||
assertEquals(ErrorCode.UNAUTHORIZED, ex.getCode());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
package com.zioinfo.kintex.webhook;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* F099 인바운드 웹훅 HMAC-SHA256 서명 서명/검증 단위테스트.
|
||||
*/
|
||||
class WebhookSignatureTest {
|
||||
|
||||
private final WebhookSignature sig = new WebhookSignature();
|
||||
|
||||
@Test
|
||||
void signThenVerifyRoundTrips() {
|
||||
String secret = "whsec_kintex_test_1234567890";
|
||||
String body = "{\"type\":\"lead.hot\",\"leadId\":\"ld-1\"}";
|
||||
String signed = sig.sign(secret, body);
|
||||
assertTrue(sig.verify(secret, body, signed), "정상 서명 검증 통과");
|
||||
assertTrue(sig.verify(secret, body, "sha256=" + signed), "sha256= 접두어 허용");
|
||||
assertTrue(sig.verify(secret, body, signed.toUpperCase()), "대문자 hex 허용");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsTamperedBody() {
|
||||
String secret = "whsec_kintex_test_1234567890";
|
||||
String signed = sig.sign(secret, "{\"amount\":100}");
|
||||
assertFalse(sig.verify(secret, "{\"amount\":999}", signed), "본문 변조 서명 거부");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsWrongSecret() {
|
||||
String body = "{\"type\":\"invoice.paid\"}";
|
||||
String signed = sig.sign("real-secret", body);
|
||||
assertFalse(sig.verify("attacker-secret", body, signed), "다른 시크릿 거부");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsBlankOrNull() {
|
||||
assertFalse(sig.verify(null, "b", "s"));
|
||||
assertFalse(sig.verify("s", "b", null));
|
||||
assertFalse(sig.verify("s", "b", ""));
|
||||
assertFalse(sig.verify("", "b", "s"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void hashSecretIsDeterministicAndNotPlaintext() {
|
||||
String h1 = sig.hashSecret("my-secret");
|
||||
String h2 = sig.hashSecret("my-secret");
|
||||
assertEquals(h1, h2, "동일 입력 동일 해시");
|
||||
assertEquals(64, h1.length(), "SHA-256 hex 64자");
|
||||
assertNotEquals("my-secret", h1, "저장값은 평문과 다름");
|
||||
}
|
||||
}
|
||||
@ -12,6 +12,8 @@ import { UtilityOrderSummaryPage } from './screens/utility/UtilityOrderSummaryPa
|
||||
import { ComplianceReportPage } from './screens/compliance/ComplianceReportPage';
|
||||
import { ManagerApprovalQueuePage } from './screens/approval/ManagerApprovalQueuePage';
|
||||
import { ReviewDetailsPage } from './screens/approval/ReviewDetailsPage';
|
||||
import { HomePage } from './screens/home/HomePage';
|
||||
import { BoothSalesPage } from './screens/boothsales/BoothSalesPage';
|
||||
import { VisualizationGalleryPage } from './screens/gallery/VisualizationGalleryPage';
|
||||
import { AnalyticsDashboardPage } from './screens/analytics/AnalyticsDashboardPage';
|
||||
import { HallOperationsPage } from './screens/ops/HallOperationsPage';
|
||||
@ -150,6 +152,12 @@ export function App() {
|
||||
<Route path="/analytics" element={<AnalyticsDashboardPage />} />
|
||||
{/* SCR-14 홀 현장운영 대시보드 (M14) */}
|
||||
<Route path="/ops/operations" element={<HallOperationsPage />} />
|
||||
{/* SCR-HOME 메인 홈(로그인 후 랜딩) — design.md v2.1.2 확정판 */}
|
||||
<Route path="/home" element={<HomePage />} />
|
||||
{/* F005/F006 부스 판매(셀프 선택) */}
|
||||
<Route path="/booth-sales" element={<BoothSalesPage />} />
|
||||
{/* §5B 전자결재 결재함(F028) — 홈 내 할 일 route 정합(/work/approval) */}
|
||||
<Route path="/work/approval" element={<ManagerApprovalQueuePage />} />
|
||||
{/* SCR-15 전시 일정 관리 (M1·M6) */}
|
||||
<Route path="/schedule" element={<ExhibitionSchedulePage />} />
|
||||
{/* M9 정산 대시보드 · M1 홀 배정+자동견적 */}
|
||||
|
||||
@ -5,6 +5,7 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import { useMdiStore } from '../../store/mdiStore';
|
||||
import { MdiTabBar } from './MdiTabBar';
|
||||
import { ShellFooter } from './ShellFooter';
|
||||
import { labelForPath } from './mdiLabels';
|
||||
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||
import { DdayChip } from '../ui/Badge';
|
||||
@ -18,6 +19,7 @@ import {
|
||||
IconDocument,
|
||||
IconExhibitors,
|
||||
IconFloorplan,
|
||||
IconHome,
|
||||
IconMoon,
|
||||
IconOperations,
|
||||
IconSearch,
|
||||
@ -42,6 +44,8 @@ const NAV_ITEMS: {
|
||||
to?: string;
|
||||
scoped?: boolean;
|
||||
}[] = [
|
||||
// 메인 홈(SCR-HOME) — 로그인 후 랜딩. 라우트 /home 배선은 통합자(App.tsx) 소관.
|
||||
{ key: 'home', label: '홈', Icon: IconHome, to: '/home' },
|
||||
{ key: 'dashboard', label: '대시보드', Icon: IconDashboard, scoped: true },
|
||||
{ key: 'floorplan', label: '플로어플랜 스튜디오', Icon: IconFloorplan, scoped: true },
|
||||
{ key: 'exhibitors', label: '참가업체 관리', Icon: IconExhibitors, scoped: true },
|
||||
@ -263,6 +267,9 @@ export function AppShell() {
|
||||
<main className="kx-shell__content" id="kx-main-content">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
{/* 전 셸 화면 공통 하단 상태바 (design.md §2-2, 약 36px) */}
|
||||
<ShellFooter />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
51
src/frontend/src/components/layout/ShellFooter.tsx
Normal file
51
src/frontend/src/components/layout/ShellFooter.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** 빌드 버전 — 디자인 §2-2 예시(v2.1.2). 릴리스 시 갱신. */
|
||||
const APP_VERSION = 'v2.1.2';
|
||||
|
||||
/**
|
||||
* 웹 인증 셸 하단 상태바/푸터 (design.md §2-2, v2.1.2 신설, 약 36px).
|
||||
* 사이드바 우측 전폭·DocumentHost 아래 고정. 좌측 저작권, 우측 시스템 상태점·버전·고객지원.
|
||||
* 시스템 상태는 정적 "정상"이 기본이며, 마운트 시 /health 1회만 확인해 명시적 5xx 일 때만 강등한다
|
||||
* (폴링 없음 — 과도한 헬스체크 회피). 색 외 텍스트 병기(WCAG AA), role="contentinfo".
|
||||
*/
|
||||
export function ShellFooter() {
|
||||
const { t } = useTranslation();
|
||||
const [healthy, setHealthy] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
fetch('/health', { signal: ctrl.signal })
|
||||
.then((res) => {
|
||||
// 명시적 서버 오류(5xx)만 강등 — 404/네트워크 등은 정적 정상 유지(오탐 방지).
|
||||
if (res.status >= 500) setHealthy(false);
|
||||
})
|
||||
.catch(() => {
|
||||
/* 네트워크/중단 — 정적 정상 유지 */
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<footer className="kx-shell__footer" role="contentinfo">
|
||||
<span className="kx-shell__footer-copy">{t('shell.footerbar.copyright')}</span>
|
||||
<div className="kx-shell__footer-right">
|
||||
<span className={`kx-shell__footer-status ${healthy ? 'is-ok' : 'is-down'}`}>
|
||||
<span className="kx-shell__footer-dot" aria-hidden="true" />
|
||||
{healthy ? t('shell.footerbar.systemOk') : t('shell.footerbar.systemDown')}
|
||||
</span>
|
||||
<span className="kx-shell__footer-sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<span className="kx-shell__footer-version tnum">{APP_VERSION}</span>
|
||||
<span className="kx-shell__footer-sep" aria-hidden="true">
|
||||
·
|
||||
</span>
|
||||
<a className="kx-shell__footer-link" href="/public/exhibit-inquiry">
|
||||
{t('shell.footerbar.support')}
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@ -251,6 +251,62 @@
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* ── 하단 상태바/푸터 (design.md §2-2, 약 36px) — 사이드바 우측 전폭·콘텐츠 아래 고정 ── */
|
||||
.kx-shell__footer {
|
||||
flex-shrink: 0;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--gutter);
|
||||
background: var(--color-neutral-050);
|
||||
border-top: var(--border-card);
|
||||
font-size: var(--fs-caption);
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
.kx-shell__footer-copy {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.kx-shell__footer-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.kx-shell__footer-status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.kx-shell__footer-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-neutral-500);
|
||||
}
|
||||
.kx-shell__footer-status.is-ok .kx-shell__footer-dot {
|
||||
background: var(--color-success);
|
||||
}
|
||||
.kx-shell__footer-status.is-down {
|
||||
color: var(--color-error);
|
||||
}
|
||||
.kx-shell__footer-status.is-down .kx-shell__footer-dot {
|
||||
background: var(--color-error);
|
||||
}
|
||||
.kx-shell__footer-sep {
|
||||
color: var(--color-neutral-200);
|
||||
}
|
||||
.kx-shell__footer-link {
|
||||
color: var(--color-neutral-500);
|
||||
text-decoration: none;
|
||||
}
|
||||
.kx-shell__footer-link:hover {
|
||||
color: var(--color-primary-600);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── MDI 문서 탭 스트립 (design.md §2.7-2) ── */
|
||||
.kx-mdi {
|
||||
flex-shrink: 0;
|
||||
|
||||
@ -59,6 +59,17 @@ export function IconDashboard(p: IconProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** ⌂ 홈(메인 랜딩 SCR-HOME). */
|
||||
export function IconHome(p: IconProps) {
|
||||
return (
|
||||
<Svg {...p}>
|
||||
<path d="M3 10.5 12 3l9 7.5" {...S} />
|
||||
<path d="M5 9.5V20a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V9.5" {...S} />
|
||||
<path d="M9.5 21v-6h5v6" {...S} />
|
||||
</Svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** ▤ 캘린더/일정 (Stitch icon_calendar). */
|
||||
export function IconCalendar(p: IconProps) {
|
||||
return (
|
||||
|
||||
@ -62,6 +62,12 @@
|
||||
"contactTel": "Main line 1899-1001",
|
||||
"contactEmail": "exhibition@kintex.com",
|
||||
"bottom": "© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si"
|
||||
},
|
||||
"footerbar": {
|
||||
"copyright": "© 2026 KINTEX. All rights reserved.",
|
||||
"systemOk": "System OK",
|
||||
"systemDown": "Maintenance",
|
||||
"support": "Support"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
@ -101,7 +107,105 @@
|
||||
"mapAria": "KINTEX location map",
|
||||
"mapAddress": "217-60 Kintex-ro, Ilsanseo-gu, Goyang-si",
|
||||
"mapPlace": "KINTEX Exhibition Center 1 & 2",
|
||||
"mapViewAria": "View map"
|
||||
"mapViewAria": "View map",
|
||||
"pageAria": "Main home",
|
||||
"greeting": "Hello, {{name}}",
|
||||
"greetingGuest": "Hello",
|
||||
"viewAll": "View all",
|
||||
"loadError": "Failed to load",
|
||||
"role": {
|
||||
"organizer": "Organizer",
|
||||
"exhibitor": "Exhibitor",
|
||||
"contractor": "Contractor",
|
||||
"hallManager": "Hall Manager",
|
||||
"admin": "Admin"
|
||||
},
|
||||
"kpi": {
|
||||
"aria": "Summary metrics"
|
||||
},
|
||||
"banner": {
|
||||
"aria": "Event promo banner",
|
||||
"prev": "Previous banner",
|
||||
"next": "Next banner",
|
||||
"emptyTitle": "No banners registered",
|
||||
"emptyDesc": "Registered event banners appear here."
|
||||
},
|
||||
"showcase": {
|
||||
"title": "Ongoing & Upcoming Exhibitions",
|
||||
"all": "View all",
|
||||
"prev": "Previous",
|
||||
"next": "Next",
|
||||
"statusOngoing": "Ongoing",
|
||||
"statusUpcoming": "Upcoming",
|
||||
"emptyTitle": "No exhibitions in this period",
|
||||
"emptyCta": "View full schedule",
|
||||
"tab": {
|
||||
"ongoing": "Ongoing",
|
||||
"thisMonth": "This month",
|
||||
"nextMonth": "Next month"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Exhibition Schedule",
|
||||
"prev": "Previous month",
|
||||
"next": "Next month",
|
||||
"today": "Today",
|
||||
"all": "All",
|
||||
"centerFilter": "Exhibition center filter",
|
||||
"yearMonth": "{{y}}.{{m}}",
|
||||
"legendOngoing": "Ongoing",
|
||||
"legendUpcoming": "Upcoming",
|
||||
"legendEnded": "Ended",
|
||||
"more": "+{{n}}",
|
||||
"moreTitle": "{{date}} exhibitions",
|
||||
"popupEmpty": "No exhibitions on this day",
|
||||
"listTitle": "Ongoing & Upcoming",
|
||||
"listEmpty": "No upcoming exhibitions",
|
||||
"listEmptyCta": "View full schedule",
|
||||
"loadError": "Failed to load schedule"
|
||||
},
|
||||
"todo": {
|
||||
"title": "My Tasks",
|
||||
"noDue": "No due date",
|
||||
"emptyTitle": "All done",
|
||||
"emptyDesc": "No tasks to handle",
|
||||
"emptyCta": "Open notifications"
|
||||
},
|
||||
"notice": {
|
||||
"title": "Notices",
|
||||
"more": "More",
|
||||
"new": "NEW",
|
||||
"pinned": "Pinned",
|
||||
"emptyTitle": "No notices",
|
||||
"emptyCta": "View all notices"
|
||||
},
|
||||
"quick": {
|
||||
"title": "Quick Actions",
|
||||
"editor": "Layout Editor",
|
||||
"invite": "Invite Exhibitor",
|
||||
"docs": "Documents",
|
||||
"settlement": "Settlement",
|
||||
"notice": "Write Notice",
|
||||
"search": "Search"
|
||||
},
|
||||
"activity": {
|
||||
"title": "Recent Activity",
|
||||
"emptyTitle": "No recent activity",
|
||||
"emptyCta": "Notification settings"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "just now",
|
||||
"minutes": "{{n}} min ago",
|
||||
"hours": "{{n}} hr ago",
|
||||
"days": "{{n}} days ago"
|
||||
},
|
||||
"workspace": {
|
||||
"title": "My Events",
|
||||
"emptyTitle": "No events joined",
|
||||
"emptyDesc": "Check your invite link or code",
|
||||
"invite": "Verify code",
|
||||
"codePh": "Enter invite code"
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"tabs": {
|
||||
@ -626,7 +730,63 @@
|
||||
"day": "Day {{d}}",
|
||||
"dayHasEvent": "Day {{d}} (has event)",
|
||||
"hallUtil": "Hall utilization",
|
||||
"hallUtilMonth": "Hall utilization ({{m}}/{{y}})"
|
||||
"hallUtilMonth": "Hall utilization ({{m}}/{{y}})",
|
||||
"availTitle": "Hall availability",
|
||||
"availMonth": "Hall availability ({{m}}/{{y}})",
|
||||
"availHalls": "{{n}} halls total",
|
||||
"availFreeDays": "{{free}}/{{total}} days free",
|
||||
"availOpen": "Available",
|
||||
"availFull": "Full"
|
||||
},
|
||||
"boothsales": {
|
||||
"title": "Booth Sales",
|
||||
"subtitleManager": "Manage booth sales inventory — status, price, blocking and settlement",
|
||||
"subtitleExhibitor": "Pick an available booth, hold it, then confirm purchase",
|
||||
"noEventTitle": "Please select an event",
|
||||
"noEventDesc": "No event selected for booth sales.",
|
||||
"summaryAria": "Booth sales status summary",
|
||||
"stAvailable": "Available",
|
||||
"stHeld": "Held",
|
||||
"stSold": "Sold",
|
||||
"stBlocked": "Blocked",
|
||||
"rate": "Rental rate {{rate}} KRW/㎡ ({{ver}})",
|
||||
"hallFilter": "Hall filter",
|
||||
"allHalls": "All halls",
|
||||
"loadError": "Failed to load booth sales inventory.",
|
||||
"emptyTitle": "No booths to show",
|
||||
"emptyManage": "No booths registered in this event's layout.",
|
||||
"emptyExhibitor": "No available booths to select right now.",
|
||||
"status": {
|
||||
"available": "Available",
|
||||
"held": "Held",
|
||||
"sold": "Sold",
|
||||
"blocked": "Blocked"
|
||||
},
|
||||
"wonUnit": " KRW",
|
||||
"hallLabel": "Hall",
|
||||
"areaLabel": "Area",
|
||||
"soldTo": "Sold to: {{c}}",
|
||||
"hold": "Hold",
|
||||
"purchase": "Confirm purchase",
|
||||
"release": "Release hold",
|
||||
"setPrice": "Set price",
|
||||
"block": "Block",
|
||||
"unblock": "Unblock",
|
||||
"settle": "Generate invoices",
|
||||
"settling": "Generating…",
|
||||
"heldToast": "Booth held. Confirm your purchase before it expires.",
|
||||
"purchasedToast": "Purchase confirmed.",
|
||||
"releasedToast": "Hold released.",
|
||||
"blockToast": "Booth status updated.",
|
||||
"priceToast": "Sale price updated.",
|
||||
"settleToast": "Invoices generated — {{created}} new, {{skipped}} kept.",
|
||||
"settleFail": "Failed to generate invoices (check permissions).",
|
||||
"holdConflict": "This booth is already held by another user or sold/blocked.",
|
||||
"purchaseConflict": "Your hold expired or the booth was taken by another user.",
|
||||
"blockConflict": "A sold booth cannot be blocked.",
|
||||
"actionFail": "Failed to process the request.",
|
||||
"pricePrompt": "Enter sale price (KRW). Leave blank to restore area × rate.",
|
||||
"priceInvalid": "Enter an amount of 0 or more."
|
||||
},
|
||||
"settlement": {
|
||||
"title": "Settlement & Payment",
|
||||
@ -657,7 +817,8 @@
|
||||
"pending": "Unissued",
|
||||
"invoiced": "Invoiced",
|
||||
"paid": "Paid",
|
||||
"overdue": "Overdue"
|
||||
"overdue": "Overdue",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"listError": "Failed to load the invoice list.",
|
||||
"emptyTitle": "No invoices",
|
||||
@ -706,7 +867,47 @@
|
||||
"errPositive": "Please enter an amount greater than 0.",
|
||||
"errExceed": "Cannot exceed the outstanding balance.",
|
||||
"pgNote": "Actual payment is delegated to an external PG. This screen is for recording payment confirmation only.",
|
||||
"managerOnly": "Only organizers/hall managers can record or issue payments."
|
||||
"managerOnly": "Only organizers/hall managers can record or issue payments.",
|
||||
"colCategory": "Type",
|
||||
"catRental": "Rental/Utility",
|
||||
"catAuctionFee": "Auction fee",
|
||||
"reportTitle": "Settlement Report",
|
||||
"reportShow": "Show report",
|
||||
"reportHide": "Close",
|
||||
"reportHint": "Aggregates totals, collection rate and outstanding by category/status, with CSV export.",
|
||||
"reportError": "Failed to load the report.",
|
||||
"reportCsv": "Download CSV",
|
||||
"reportByCategory": "By category",
|
||||
"reportByStatus": "By status",
|
||||
"reportEmpty": "No invoices to aggregate.",
|
||||
"reportNote": "Totals exclude cancelled invoices. Amounts are server-computed.",
|
||||
"reportRate": "Collection %",
|
||||
"reportCount": "Count",
|
||||
"taxTitle": "Tax Invoice",
|
||||
"taxNo": "Invoice No.",
|
||||
"taxSupplier": "Supplier",
|
||||
"taxBuyer": "Buyer",
|
||||
"taxItem": "Item",
|
||||
"taxNone": "No tax invoice issued yet.",
|
||||
"taxIssue": "Issue tax invoice",
|
||||
"taxIssuing": "Issuing…",
|
||||
"taxIssueFail": "Failed to issue the tax invoice.",
|
||||
"cancelledNote": "This invoice has been cancelled.",
|
||||
"cancelInvoice": "Cancel invoice",
|
||||
"refundPreview": "Refund preview",
|
||||
"refundError": "Failed to load the refund estimate.",
|
||||
"refundBracket": "Bracket",
|
||||
"refundRate": "Refund %",
|
||||
"refundBase": "Base (paid)",
|
||||
"refundAmount": "Refund",
|
||||
"forfeitAmount": "Forfeit",
|
||||
"refundManual": "Auto estimate unavailable (e.g. event date not set) — flagged for finance review.",
|
||||
"cancelReason": "Cancellation reason",
|
||||
"cancelReasonPh": "Reason (optional)",
|
||||
"cancelAbort": "Abort",
|
||||
"cancelConfirm": "Confirm cancel",
|
||||
"cancelling": "Processing…",
|
||||
"cancelFail": "Failed to cancel the invoice."
|
||||
},
|
||||
"hallassign": {
|
||||
"title": "Hall Assignment · Auto Quote",
|
||||
@ -760,6 +961,48 @@
|
||||
"deposit": "Management deposit (separate)",
|
||||
"ruleset": "(ruleset {{v}})"
|
||||
},
|
||||
"ai": {
|
||||
"console": {
|
||||
"title": "AI Console",
|
||||
"subtitle": "Query data in natural language and ask about exhibition regulations. Results are for reference and shown with their basis.",
|
||||
"badge": "Reference",
|
||||
"tabsAria": "AI console tabs",
|
||||
"tabNlquery": "Natural-language query",
|
||||
"tabRules": "Regulation chatbot"
|
||||
},
|
||||
"common": {
|
||||
"running": "Running…",
|
||||
"provClaude": "Claude response",
|
||||
"provOllama": "Ollama response",
|
||||
"provRules": "From regulation text",
|
||||
"provNone": "No response",
|
||||
"unavailable": "The AI feature is temporarily unavailable. Please try again shortly.",
|
||||
"invalid": "This query violates safety rules and cannot be executed.",
|
||||
"forbidden": "You are not authorized to use this feature.",
|
||||
"error": "An error occurred while processing the request."
|
||||
},
|
||||
"nlquery": {
|
||||
"placeholder": "e.g., Show events with status active",
|
||||
"inputAria": "Natural-language query question",
|
||||
"run": "Query",
|
||||
"guardNote": "Only read-only SELECT runs; personal-data columns are never queried. Up to 200 rows.",
|
||||
"rowCount": "{{n}} rows",
|
||||
"showSql": "Show generated SQL",
|
||||
"hideSql": "Hide generated SQL",
|
||||
"sqlAria": "Generated SQL",
|
||||
"emptyTitle": "No results",
|
||||
"emptyDesc": "No data matched the condition. Try rephrasing your question."
|
||||
},
|
||||
"rules": {
|
||||
"placeholder": "e.g., What is the maximum structure height?",
|
||||
"inputAria": "Regulation question",
|
||||
"run": "Ask",
|
||||
"groundNote": "Answers are grounded in the regulation and rate rulesets; if no basis is found we tell you honestly.",
|
||||
"citations": "Cited regulations",
|
||||
"notFound": "Not found in regulations",
|
||||
"degraded": "Regulation summary (AI unavailable)"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "Business Analytics",
|
||||
"subtitle": "KINTEX operating profitability · exhibitor insights",
|
||||
@ -1278,5 +1521,164 @@
|
||||
"updateFail": "Update failed.",
|
||||
"deleteFail": "Deletion failed.",
|
||||
"deleteConfirm": "Delete the slide \"{{title}}\"?"
|
||||
},
|
||||
"approval": {
|
||||
"queueTitle": "Approval Inbox",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search title...",
|
||||
"pendingCount": "Pending",
|
||||
"pendingList": "Documents awaiting my approval",
|
||||
"colTitle": "Title",
|
||||
"colDocType": "Type",
|
||||
"colDrafter": "Drafter",
|
||||
"colSubmitted": "Submitted",
|
||||
"colStep": "Step",
|
||||
"colStatus": "Status",
|
||||
"colAction": "Action",
|
||||
"open": "Open",
|
||||
"emptyPending": "No documents to approve",
|
||||
"status": {
|
||||
"DRAFT": "Draft",
|
||||
"SUBMITTED": "Submitted",
|
||||
"IN_PROGRESS": "In progress",
|
||||
"APPROVED": "Approved",
|
||||
"REJECTED": "Rejected",
|
||||
"CANCELED": "Canceled"
|
||||
},
|
||||
"reviewTitle": "Approval Review",
|
||||
"version": "Version",
|
||||
"drafter": "Drafter",
|
||||
"step": "Step",
|
||||
"demoMode": "Preview (demo)",
|
||||
"downloadOriginal": "Download original",
|
||||
"previewAria": "Document preview",
|
||||
"lineAria": "Approval line & history",
|
||||
"docBody": "Body",
|
||||
"line": "Approval line",
|
||||
"history": "History",
|
||||
"noHistory": "No history yet",
|
||||
"lineStatus": {
|
||||
"WAIT": "Waiting",
|
||||
"CURRENT": "In review",
|
||||
"APPROVED": "Approved",
|
||||
"REJECTED": "Rejected",
|
||||
"SKIPPED": "Skipped"
|
||||
},
|
||||
"action": {
|
||||
"SUBMIT": "Submit",
|
||||
"APPROVE": "Approve",
|
||||
"REJECT": "Reject",
|
||||
"RESUBMIT": "Resubmit",
|
||||
"CANCEL": "Cancel"
|
||||
},
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"approvedDone": "Approved.",
|
||||
"rejectedDone": "Rejected.",
|
||||
"canActHint": "You are the current approver for this document.",
|
||||
"notApproverHint": "Only the current-step approver can act.",
|
||||
"demoFootNote": "Select a document from the approval inbox to approve or reject.",
|
||||
"demoHint": "No approval document selected. Please open one from the approval inbox.",
|
||||
"commentPlaceholder": "Enter a comment (required to reject)",
|
||||
"comment": "Comment"
|
||||
},
|
||||
"auction": {
|
||||
"boq": {
|
||||
"title": "Bill of Quantities (auto)",
|
||||
"error": "Failed to load the bill of quantities.",
|
||||
"scopeBooth": "Booth {{label}}",
|
||||
"scopeEvent": "All booths in the event",
|
||||
"summary": "{{count}} booth(s) · {{area}}㎡",
|
||||
"colSeq": "No",
|
||||
"colTrade": "Trade",
|
||||
"colItem": "Item",
|
||||
"colQty": "Qty",
|
||||
"colUnit": "Unit",
|
||||
"colBasis": "Basis"
|
||||
}
|
||||
},
|
||||
"logistics": {
|
||||
"title": "Logistics Hub",
|
||||
"subtitle": "Move-in/out docks · equipment · rentals · inbound tracking",
|
||||
"tabsAria": "Switch logistics function",
|
||||
"tabs": {
|
||||
"dock": "Dock Booking",
|
||||
"equipment": "Equipment",
|
||||
"rental": "Rentals",
|
||||
"inbound": "Inbound"
|
||||
},
|
||||
"boothNo": "Booth No.",
|
||||
"qty": "Qty",
|
||||
"estimate": "Estimated amount",
|
||||
"submitting": "Processing…",
|
||||
"submitOk": "Request received.",
|
||||
"submitErr": "Request failed.",
|
||||
"statusUpdated": "Status updated.",
|
||||
"loadErr": "Failed to load logistics data.",
|
||||
"serverAmountNote": "Amount is finalized by the server",
|
||||
"equipment": {
|
||||
"formTitle": "Equipment Request",
|
||||
"item": "Equipment",
|
||||
"days": "Days of use",
|
||||
"daysUnit": "d",
|
||||
"useDate": "Planned use date",
|
||||
"submit": "Request Equipment",
|
||||
"listTitle": "Requests",
|
||||
"empty": "No requests yet",
|
||||
"emptyDesc": "Request forklifts, lifts and more.",
|
||||
"approve": "Approve",
|
||||
"reject": "Reject",
|
||||
"rejectReason": "Rejected due to site conditions",
|
||||
"done": "Mark done",
|
||||
"st": {
|
||||
"requested": "Requested",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"done": "Done"
|
||||
}
|
||||
},
|
||||
"rental": {
|
||||
"formTitle": "Rental Order",
|
||||
"item": "Rental item",
|
||||
"start": "Start date",
|
||||
"end": "End date",
|
||||
"submit": "Place Order",
|
||||
"listTitle": "Orders",
|
||||
"empty": "No orders yet",
|
||||
"emptyDesc": "Order tables, chairs, monitors and more.",
|
||||
"period": "{{days}} day(s)",
|
||||
"confirm": "Confirm",
|
||||
"cancel": "Cancel",
|
||||
"deliver": "Mark delivered",
|
||||
"st": {
|
||||
"ordered": "Ordered",
|
||||
"confirmed": "Confirmed",
|
||||
"cancelled": "Cancelled",
|
||||
"delivered": "Delivered"
|
||||
}
|
||||
},
|
||||
"inbound": {
|
||||
"formTitle": "Register Inbound Shipment",
|
||||
"trackingNo": "Tracking no.",
|
||||
"carrier": "Carrier",
|
||||
"itemDesc": "Cargo",
|
||||
"eta": "ETA date",
|
||||
"submit": "Register Shipment",
|
||||
"submitSub": "Track inbound status after registering",
|
||||
"listTitle": "Inbound Status",
|
||||
"empty": "No inbound shipments",
|
||||
"emptyDesc": "Register pre-delivery tracking numbers.",
|
||||
"st": {
|
||||
"registered": "Registered",
|
||||
"inbound_scheduled": "Scheduled",
|
||||
"arrived_warehouse": "At warehouse",
|
||||
"delivered_booth": "Delivered to booth"
|
||||
},
|
||||
"advance": {
|
||||
"inbound_scheduled": "Mark scheduled",
|
||||
"arrived_warehouse": "Mark arrived",
|
||||
"delivered_booth": "Mark delivered to booth"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,6 +62,12 @@
|
||||
"contactTel": "代表番号 1899-1001",
|
||||
"contactEmail": "exhibition@kintex.com",
|
||||
"bottom": "© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si"
|
||||
},
|
||||
"footerbar": {
|
||||
"copyright": "© 2026 KINTEX. All rights reserved.",
|
||||
"systemOk": "システム正常",
|
||||
"systemDown": "システム点検",
|
||||
"support": "カスタマーサポート"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
@ -101,7 +107,105 @@
|
||||
"mapAria": "KINTEX 位置地図",
|
||||
"mapAddress": "高陽市一山西区KINTEXロ217-60",
|
||||
"mapPlace": "KINTEX 第1・2展示場",
|
||||
"mapViewAria": "地図を見る"
|
||||
"mapViewAria": "地図を見る",
|
||||
"pageAria": "メインホーム",
|
||||
"greeting": "こんにちは、{{name}}様",
|
||||
"greetingGuest": "こんにちは",
|
||||
"viewAll": "すべて表示",
|
||||
"loadError": "読み込めませんでした",
|
||||
"role": {
|
||||
"organizer": "主催者",
|
||||
"exhibitor": "出展社",
|
||||
"contractor": "施工業者",
|
||||
"hallManager": "ホールマネージャー",
|
||||
"admin": "管理者"
|
||||
},
|
||||
"kpi": {
|
||||
"aria": "サマリー指標"
|
||||
},
|
||||
"banner": {
|
||||
"aria": "イベント紹介バナー",
|
||||
"prev": "前のバナー",
|
||||
"next": "次のバナー",
|
||||
"emptyTitle": "登録されたバナーがありません",
|
||||
"emptyDesc": "管理者で登録したイベントバナーがここに表示されます。"
|
||||
},
|
||||
"showcase": {
|
||||
"title": "開催中・開催予定の展示会",
|
||||
"all": "すべて表示",
|
||||
"prev": "前へ",
|
||||
"next": "次へ",
|
||||
"statusOngoing": "開催中",
|
||||
"statusUpcoming": "予定",
|
||||
"emptyTitle": "この期間の展示会はありません",
|
||||
"emptyCta": "全日程を見る",
|
||||
"tab": {
|
||||
"ongoing": "開催中",
|
||||
"thisMonth": "今月",
|
||||
"nextMonth": "来月"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "展示会スケジュール",
|
||||
"prev": "前月",
|
||||
"next": "翌月",
|
||||
"today": "今日",
|
||||
"all": "すべて",
|
||||
"centerFilter": "展示場フィルター",
|
||||
"yearMonth": "{{y}}年{{m}}月",
|
||||
"legendOngoing": "開催中",
|
||||
"legendUpcoming": "予定",
|
||||
"legendEnded": "終了",
|
||||
"more": "+{{n}}",
|
||||
"moreTitle": "{{date}} の展示会",
|
||||
"popupEmpty": "その日の展示会はありません",
|
||||
"listTitle": "開催中・開催予定",
|
||||
"listEmpty": "予定された展示会はありません",
|
||||
"listEmptyCta": "全日程を見る",
|
||||
"loadError": "スケジュールを読み込めませんでした"
|
||||
},
|
||||
"todo": {
|
||||
"title": "やること",
|
||||
"noDue": "期限なし",
|
||||
"emptyTitle": "すべて完了",
|
||||
"emptyDesc": "対応する作業はありません",
|
||||
"emptyCta": "通知を開く"
|
||||
},
|
||||
"notice": {
|
||||
"title": "お知らせ",
|
||||
"more": "もっと見る",
|
||||
"new": "NEW",
|
||||
"pinned": "固定",
|
||||
"emptyTitle": "お知らせはありません",
|
||||
"emptyCta": "すべてのお知らせ"
|
||||
},
|
||||
"quick": {
|
||||
"title": "クイック操作",
|
||||
"editor": "レイアウトエディタ",
|
||||
"invite": "出展社を招待",
|
||||
"docs": "書類ワークフロー",
|
||||
"settlement": "精算",
|
||||
"notice": "お知らせ作成",
|
||||
"search": "統合検索"
|
||||
},
|
||||
"activity": {
|
||||
"title": "最近の活動",
|
||||
"emptyTitle": "最近の活動はありません",
|
||||
"emptyCta": "通知設定"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "たった今",
|
||||
"minutes": "{{n}}分前",
|
||||
"hours": "{{n}}時間前",
|
||||
"days": "{{n}}日前"
|
||||
},
|
||||
"workspace": {
|
||||
"title": "マイイベント",
|
||||
"emptyTitle": "参加中のイベントがありません",
|
||||
"emptyDesc": "招待リンクまたは招待コードをご確認ください",
|
||||
"invite": "招待コード確認",
|
||||
"codePh": "招待コードを入力"
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"tabs": {
|
||||
@ -626,7 +730,63 @@
|
||||
"day": "{{d}}日",
|
||||
"dayHasEvent": "{{d}}日 (イベントあり)",
|
||||
"hallUtil": "ホール稼働率",
|
||||
"hallUtilMonth": "ホール稼働率 ({{y}}年{{m}}月)"
|
||||
"hallUtilMonth": "ホール稼働率 ({{y}}年{{m}}月)",
|
||||
"availTitle": "ホール空き状況",
|
||||
"availMonth": "ホール空き状況 ({{y}}年{{m}}月)",
|
||||
"availHalls": "全ホール {{n}} 件",
|
||||
"availFreeDays": "空き {{free}}/{{total}}日",
|
||||
"availOpen": "空きあり",
|
||||
"availFull": "満室"
|
||||
},
|
||||
"boothsales": {
|
||||
"title": "ブース販売",
|
||||
"subtitleManager": "ブース販売在庫の管理 — 状態・価格・ブロックおよび精算連携",
|
||||
"subtitleExhibitor": "空きブースを選んで仮押さえ後、購入を確定してください",
|
||||
"noEventTitle": "イベントを選択してください",
|
||||
"noEventDesc": "ブース販売の対象イベントが未選択です。",
|
||||
"summaryAria": "ブース販売状態のサマリー",
|
||||
"stAvailable": "空き",
|
||||
"stHeld": "仮押さえ",
|
||||
"stSold": "販売済",
|
||||
"stBlocked": "ブロック",
|
||||
"rate": "賃料レート {{rate}}ウォン/㎡ ({{ver}})",
|
||||
"hallFilter": "ホール絞り込み",
|
||||
"allHalls": "全ホール",
|
||||
"loadError": "ブース販売在庫を読み込めませんでした。",
|
||||
"emptyTitle": "表示するブースがありません",
|
||||
"emptyManage": "このイベントのレイアウトに登録されたブースがありません。",
|
||||
"emptyExhibitor": "現在選択可能な空きブースがありません。",
|
||||
"status": {
|
||||
"available": "空き",
|
||||
"held": "仮押さえ",
|
||||
"sold": "販売済",
|
||||
"blocked": "ブロック"
|
||||
},
|
||||
"wonUnit": "ウォン",
|
||||
"hallLabel": "ホール",
|
||||
"areaLabel": "面積",
|
||||
"soldTo": "販売先:{{c}}",
|
||||
"hold": "仮押さえ",
|
||||
"purchase": "購入確定",
|
||||
"release": "仮押さえ解除",
|
||||
"setPrice": "価格設定",
|
||||
"block": "ブロック",
|
||||
"unblock": "ブロック解除",
|
||||
"settle": "請求書生成",
|
||||
"settling": "生成中…",
|
||||
"heldToast": "ブースを仮押さえしました。期限内に購入を確定してください。",
|
||||
"purchasedToast": "購入が確定しました。",
|
||||
"releasedToast": "仮押さえを解除しました。",
|
||||
"blockToast": "ブース状態を更新しました。",
|
||||
"priceToast": "販売価格を更新しました。",
|
||||
"settleToast": "請求書生成完了 — 新規 {{created}}件、維持 {{skipped}}件。",
|
||||
"settleFail": "請求書の生成に失敗しました(権限を確認してください)。",
|
||||
"holdConflict": "このブースは他のユーザーが仮押さえ済み、または販売済/ブロック済です。",
|
||||
"purchaseConflict": "仮押さえが期限切れ、または他のユーザーが仮押さえしました。",
|
||||
"blockConflict": "販売済のブースはブロックできません。",
|
||||
"actionFail": "リクエストを処理できませんでした。",
|
||||
"pricePrompt": "販売価格(ウォン)を入力してください。空欄の場合は面積×レートで自動計算します。",
|
||||
"priceInvalid": "0以上の金額を入力してください。"
|
||||
},
|
||||
"settlement": {
|
||||
"title": "精算・決済",
|
||||
@ -657,7 +817,8 @@
|
||||
"pending": "未発行",
|
||||
"invoiced": "請求済",
|
||||
"paid": "完納",
|
||||
"overdue": "延滞"
|
||||
"overdue": "延滞",
|
||||
"cancelled": "取消"
|
||||
},
|
||||
"listError": "請求書一覧を読み込めませんでした。",
|
||||
"emptyTitle": "請求書がありません",
|
||||
@ -706,7 +867,47 @@
|
||||
"errPositive": "0より大きい金額を入力してください。",
|
||||
"errExceed": "未収残高を超えることはできません。",
|
||||
"pgNote": "実際の決済は外部 PG に委任されます。本画面は入金確認記録専用です。",
|
||||
"managerOnly": "入金・発行は主催者/ホールマネージャーのみ実行できます。"
|
||||
"managerOnly": "入金・発行は主催者/ホールマネージャーのみ実行できます。",
|
||||
"colCategory": "種別",
|
||||
"catRental": "賃貸·設備",
|
||||
"catAuctionFee": "オークション手数料",
|
||||
"reportTitle": "精算レポート",
|
||||
"reportShow": "レポート表示",
|
||||
"reportHide": "閉じる",
|
||||
"reportHint": "カテゴリ・状態別の合計、収納率、未収を集計しCSVで出力します。",
|
||||
"reportError": "レポートを読み込めませんでした。",
|
||||
"reportCsv": "CSVダウンロード",
|
||||
"reportByCategory": "カテゴリ別合計",
|
||||
"reportByStatus": "状態別合計",
|
||||
"reportEmpty": "集計対象の請求がありません。",
|
||||
"reportNote": "合計は取消請求を除いた値です。金額はサーバー計算基準です。",
|
||||
"reportRate": "収納率",
|
||||
"reportCount": "件数",
|
||||
"taxTitle": "税金計算書",
|
||||
"taxNo": "計算書番号",
|
||||
"taxSupplier": "供給者",
|
||||
"taxBuyer": "供給を受ける者",
|
||||
"taxItem": "品目",
|
||||
"taxNone": "発行された税金計算書がありません。",
|
||||
"taxIssue": "税金計算書発行",
|
||||
"taxIssuing": "発行中…",
|
||||
"taxIssueFail": "税金計算書の発行に失敗しました。",
|
||||
"cancelledNote": "取消された請求です。",
|
||||
"cancelInvoice": "請求取消",
|
||||
"refundPreview": "返金試算プレビュー",
|
||||
"refundError": "返金試算を読み込めませんでした。",
|
||||
"refundBracket": "適用区間",
|
||||
"refundRate": "返金率",
|
||||
"refundBase": "基準額(入金)",
|
||||
"refundAmount": "返金額",
|
||||
"forfeitAmount": "控除・違約額",
|
||||
"refundManual": "開催日未確定などで自動試算ができません — 財務の手動確認対象です。",
|
||||
"cancelReason": "取消理由",
|
||||
"cancelReasonPh": "取消理由(任意)",
|
||||
"cancelAbort": "中断",
|
||||
"cancelConfirm": "取消確定",
|
||||
"cancelling": "処理中…",
|
||||
"cancelFail": "請求の取消に失敗しました。"
|
||||
},
|
||||
"hallassign": {
|
||||
"title": "ホール割当 · 自動見積",
|
||||
@ -760,6 +961,48 @@
|
||||
"deposit": "管理費予託金(別途)",
|
||||
"ruleset": "(ルールセット {{v}})"
|
||||
},
|
||||
"ai": {
|
||||
"console": {
|
||||
"title": "AIコンソール",
|
||||
"subtitle": "自然言語でデータを照会し、展示規定を質問します。結果は参考用で、根拠も併記します。",
|
||||
"badge": "参考用",
|
||||
"tabsAria": "AIコンソールタブ",
|
||||
"tabNlquery": "自然言語照会",
|
||||
"tabRules": "規定チャットボット"
|
||||
},
|
||||
"common": {
|
||||
"running": "実行中…",
|
||||
"provClaude": "Claude応答",
|
||||
"provOllama": "Ollama応答",
|
||||
"provRules": "規定原文に基づく",
|
||||
"provNone": "応答なし",
|
||||
"unavailable": "AI機能が一時的に利用できません。しばらくして再度お試しください。",
|
||||
"invalid": "安全ルールに違反するため実行できない照会です。",
|
||||
"forbidden": "この機能を使用する権限がありません。",
|
||||
"error": "リクエスト処理中にエラーが発生しました。"
|
||||
},
|
||||
"nlquery": {
|
||||
"placeholder": "例:ステータスがactiveのイベント一覧を表示",
|
||||
"inputAria": "自然言語照会の質問",
|
||||
"run": "照会",
|
||||
"guardNote": "読み取り専用のSELECTのみ実行し、個人情報の列は照会しません。最大200行。",
|
||||
"rowCount": "{{n}}行",
|
||||
"showSql": "生成SQLを表示",
|
||||
"hideSql": "生成SQLを非表示",
|
||||
"sqlAria": "生成されたSQL",
|
||||
"emptyTitle": "結果がありません",
|
||||
"emptyDesc": "条件に該当するデータがありません。質問を変えて再度お試しください。"
|
||||
},
|
||||
"rules": {
|
||||
"placeholder": "例:装置物の最大高さはいくらですか?",
|
||||
"inputAria": "規定に関する質問",
|
||||
"run": "質問",
|
||||
"groundNote": "規定・料率ルールセットを根拠に回答し、根拠がなければ正直にお知らせします。",
|
||||
"citations": "根拠規定",
|
||||
"notFound": "規定で見つかりませんでした",
|
||||
"degraded": "規定原文の要約(AI利用不可)"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "経営分析",
|
||||
"subtitle": "KINTEX 運営収益性 · 出展社インサイト",
|
||||
@ -1278,5 +1521,164 @@
|
||||
"updateFail": "変更に失敗しました。",
|
||||
"deleteFail": "削除に失敗しました。",
|
||||
"deleteConfirm": "\"{{title}}\" スライドを削除しますか?"
|
||||
},
|
||||
"approval": {
|
||||
"queueTitle": "承認受信箱",
|
||||
"search": "検索",
|
||||
"searchPlaceholder": "タイトル検索...",
|
||||
"pendingCount": "承認待ち",
|
||||
"pendingList": "自分の承認待ち文書",
|
||||
"colTitle": "タイトル",
|
||||
"colDocType": "種別",
|
||||
"colDrafter": "起案者",
|
||||
"colSubmitted": "申請日",
|
||||
"colStep": "段階",
|
||||
"colStatus": "状態",
|
||||
"colAction": "処理",
|
||||
"open": "開く",
|
||||
"emptyPending": "承認する文書がありません",
|
||||
"status": {
|
||||
"DRAFT": "一時保存",
|
||||
"SUBMITTED": "申請",
|
||||
"IN_PROGRESS": "進行中",
|
||||
"APPROVED": "承認完了",
|
||||
"REJECTED": "却下",
|
||||
"CANCELED": "取消"
|
||||
},
|
||||
"reviewTitle": "承認レビュー",
|
||||
"version": "バージョン",
|
||||
"drafter": "起案者",
|
||||
"step": "段階",
|
||||
"demoMode": "プレビュー(デモ)",
|
||||
"downloadOriginal": "原本ダウンロード",
|
||||
"previewAria": "文書プレビュー",
|
||||
"lineAria": "承認ライン・履歴",
|
||||
"docBody": "本文",
|
||||
"line": "承認ライン",
|
||||
"history": "処理履歴",
|
||||
"noHistory": "処理履歴がありません",
|
||||
"lineStatus": {
|
||||
"WAIT": "待機",
|
||||
"CURRENT": "審査中",
|
||||
"APPROVED": "承認",
|
||||
"REJECTED": "却下",
|
||||
"SKIPPED": "スキップ"
|
||||
},
|
||||
"action": {
|
||||
"SUBMIT": "申請",
|
||||
"APPROVE": "承認",
|
||||
"REJECT": "却下",
|
||||
"RESUBMIT": "再申請",
|
||||
"CANCEL": "取消"
|
||||
},
|
||||
"approve": "承認",
|
||||
"reject": "却下",
|
||||
"approvedDone": "承認しました。",
|
||||
"rejectedDone": "却下しました。",
|
||||
"canActHint": "この文書の現在の承認者です。",
|
||||
"notApproverHint": "現在段階の承認者のみ処理できます。",
|
||||
"demoFootNote": "承認受信箱から文書を選択すると承認/却下できます。",
|
||||
"demoHint": "承認文書が指定されていません。承認受信箱から開いてください。",
|
||||
"commentPlaceholder": "コメント(却下時は必須)を入力",
|
||||
"comment": "コメント"
|
||||
},
|
||||
"auction": {
|
||||
"boq": {
|
||||
"title": "数量書(BOQ)自動算出",
|
||||
"error": "数量書を読み込めませんでした。",
|
||||
"scopeBooth": "{{label}} ブース基準",
|
||||
"scopeEvent": "イベント全ブース集計",
|
||||
"summary": "ブース {{count}}件 · {{area}}㎡",
|
||||
"colSeq": "No",
|
||||
"colTrade": "工種",
|
||||
"colItem": "品名",
|
||||
"colQty": "数量",
|
||||
"colUnit": "単位",
|
||||
"colBasis": "算出根拠"
|
||||
}
|
||||
},
|
||||
"logistics": {
|
||||
"title": "物流ハブ",
|
||||
"subtitle": "搬入・搬出ドック · 付帯機材 · レンタル · 事前入荷追跡",
|
||||
"tabsAria": "物流機能の切替",
|
||||
"tabs": {
|
||||
"dock": "ドック予約",
|
||||
"equipment": "機材申請",
|
||||
"rental": "レンタル注文",
|
||||
"inbound": "事前入荷"
|
||||
},
|
||||
"boothNo": "ブース番号",
|
||||
"qty": "数量",
|
||||
"estimate": "概算金額",
|
||||
"submitting": "処理中…",
|
||||
"submitOk": "受け付けました。",
|
||||
"submitErr": "処理に失敗しました。",
|
||||
"statusUpdated": "ステータスを変更しました。",
|
||||
"loadErr": "物流情報を読み込めませんでした。",
|
||||
"serverAmountNote": "金額はサーバー基準で確定します",
|
||||
"equipment": {
|
||||
"formTitle": "付帯機材の申請",
|
||||
"item": "機材品目",
|
||||
"days": "使用日数",
|
||||
"daysUnit": "日",
|
||||
"useDate": "使用予定日",
|
||||
"submit": "機材を申請",
|
||||
"listTitle": "申請履歴",
|
||||
"empty": "申請履歴がありません",
|
||||
"emptyDesc": "フォークリフト・リフト等を申請してください。",
|
||||
"approve": "承認",
|
||||
"reject": "却下",
|
||||
"rejectReason": "現場状況により却下",
|
||||
"done": "処理完了",
|
||||
"st": {
|
||||
"requested": "申請済",
|
||||
"approved": "承認",
|
||||
"rejected": "却下",
|
||||
"done": "完了"
|
||||
}
|
||||
},
|
||||
"rental": {
|
||||
"formTitle": "付帯物品のレンタル注文",
|
||||
"item": "レンタル品目",
|
||||
"start": "開始日",
|
||||
"end": "終了日",
|
||||
"submit": "レンタル注文",
|
||||
"listTitle": "注文履歴",
|
||||
"empty": "注文履歴がありません",
|
||||
"emptyDesc": "テーブル・椅子・モニター等を注文してください。",
|
||||
"period": "{{days}}日",
|
||||
"confirm": "確定",
|
||||
"cancel": "キャンセル",
|
||||
"deliver": "納品完了",
|
||||
"st": {
|
||||
"ordered": "注文済",
|
||||
"confirmed": "確定",
|
||||
"cancelled": "キャンセル",
|
||||
"delivered": "納品"
|
||||
}
|
||||
},
|
||||
"inbound": {
|
||||
"formTitle": "事前入荷の送り状登録",
|
||||
"trackingNo": "送り状番号",
|
||||
"carrier": "運送会社",
|
||||
"itemDesc": "貨物内容",
|
||||
"eta": "入荷予定日",
|
||||
"submit": "送り状を登録",
|
||||
"submitSub": "登録後に入荷状況を追跡します",
|
||||
"listTitle": "事前入荷状況",
|
||||
"empty": "事前入荷がありません",
|
||||
"emptyDesc": "事前配送の送り状を登録してください。",
|
||||
"st": {
|
||||
"registered": "登録済",
|
||||
"inbound_scheduled": "入荷予定",
|
||||
"arrived_warehouse": "倉庫到着",
|
||||
"delivered_booth": "ブース配達"
|
||||
},
|
||||
"advance": {
|
||||
"inbound_scheduled": "入荷予定にする",
|
||||
"arrived_warehouse": "倉庫到着にする",
|
||||
"delivered_booth": "ブース配達完了"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,6 +62,12 @@
|
||||
"contactTel": "대표번호 1899-1001",
|
||||
"contactEmail": "exhibition@kintex.com",
|
||||
"bottom": "© KINTEX. All Rights Reserved. (10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si"
|
||||
},
|
||||
"footerbar": {
|
||||
"copyright": "© 2026 KINTEX. All rights reserved.",
|
||||
"systemOk": "시스템 정상",
|
||||
"systemDown": "시스템 점검",
|
||||
"support": "고객지원"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
@ -101,7 +107,105 @@
|
||||
"mapAria": "킨텍스 위치 지도",
|
||||
"mapAddress": "고양시 일산서구 킨텍스로 217-60",
|
||||
"mapPlace": "KINTEX 제1, 2전시장",
|
||||
"mapViewAria": "지도 보기"
|
||||
"mapViewAria": "지도 보기",
|
||||
"pageAria": "메인 홈",
|
||||
"greeting": "안녕하세요, {{name}}님",
|
||||
"greetingGuest": "안녕하세요",
|
||||
"viewAll": "전체 보기",
|
||||
"loadError": "불러오지 못했습니다",
|
||||
"role": {
|
||||
"organizer": "주최자",
|
||||
"exhibitor": "참가업체",
|
||||
"contractor": "장치업체",
|
||||
"hallManager": "홀매니저",
|
||||
"admin": "관리자"
|
||||
},
|
||||
"kpi": {
|
||||
"aria": "요약 지표"
|
||||
},
|
||||
"banner": {
|
||||
"aria": "행사 소개 배너",
|
||||
"prev": "이전 배너",
|
||||
"next": "다음 배너",
|
||||
"emptyTitle": "등록된 배너가 없습니다",
|
||||
"emptyDesc": "관리자에서 행사 배너를 등록하면 이곳에 노출됩니다."
|
||||
},
|
||||
"showcase": {
|
||||
"title": "진행 중·다가오는 전시",
|
||||
"all": "전체 보기",
|
||||
"prev": "이전",
|
||||
"next": "다음",
|
||||
"statusOngoing": "진행중",
|
||||
"statusUpcoming": "예정",
|
||||
"emptyTitle": "해당 기간 전시가 없습니다",
|
||||
"emptyCta": "전체 일정 보기",
|
||||
"tab": {
|
||||
"ongoing": "진행 중",
|
||||
"thisMonth": "이번 달",
|
||||
"nextMonth": "다음 달"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "전시 일정",
|
||||
"prev": "이전 달",
|
||||
"next": "다음 달",
|
||||
"today": "오늘",
|
||||
"all": "전체",
|
||||
"centerFilter": "전시장 필터",
|
||||
"yearMonth": "{{y}}년 {{m}}월",
|
||||
"legendOngoing": "진행중",
|
||||
"legendUpcoming": "예정",
|
||||
"legendEnded": "종료",
|
||||
"more": "+{{n}}",
|
||||
"moreTitle": "{{date}} 전시",
|
||||
"popupEmpty": "해당 일자에 전시가 없습니다",
|
||||
"listTitle": "진행 중·예정 전시",
|
||||
"listEmpty": "예정된 전시가 없습니다",
|
||||
"listEmptyCta": "전체 일정 보기",
|
||||
"loadError": "일정을 불러오지 못했습니다"
|
||||
},
|
||||
"todo": {
|
||||
"title": "내 할 일",
|
||||
"noDue": "기한 없음",
|
||||
"emptyTitle": "모두 완료",
|
||||
"emptyDesc": "처리할 작업이 없습니다",
|
||||
"emptyCta": "알림 열기"
|
||||
},
|
||||
"notice": {
|
||||
"title": "공지사항",
|
||||
"more": "더보기",
|
||||
"new": "NEW",
|
||||
"pinned": "고정",
|
||||
"emptyTitle": "등록된 공지가 없습니다",
|
||||
"emptyCta": "전체 공지 보기"
|
||||
},
|
||||
"quick": {
|
||||
"title": "빠른 작업",
|
||||
"editor": "배치 에디터",
|
||||
"invite": "참가업체 초대",
|
||||
"docs": "서류 워크플로",
|
||||
"settlement": "정산",
|
||||
"notice": "공지 작성",
|
||||
"search": "통합검색"
|
||||
},
|
||||
"activity": {
|
||||
"title": "최근 활동",
|
||||
"emptyTitle": "최근 활동이 없습니다",
|
||||
"emptyCta": "알림 설정"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "방금",
|
||||
"minutes": "{{n}}분 전",
|
||||
"hours": "{{n}}시간 전",
|
||||
"days": "{{n}}일 전"
|
||||
},
|
||||
"workspace": {
|
||||
"title": "내 행사",
|
||||
"emptyTitle": "참여 중인 행사가 없습니다",
|
||||
"emptyDesc": "초대 링크 또는 초대 코드를 확인하세요",
|
||||
"invite": "초대 코드 확인",
|
||||
"codePh": "초대 코드 입력"
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"tabs": {
|
||||
@ -626,7 +730,63 @@
|
||||
"day": "{{d}}일",
|
||||
"dayHasEvent": "{{d}}일 (행사 있음)",
|
||||
"hallUtil": "홀 가동률",
|
||||
"hallUtilMonth": "홀 가동률 ({{y}}년 {{m}}월)"
|
||||
"hallUtilMonth": "홀 가동률 ({{y}}년 {{m}}월)",
|
||||
"availTitle": "홀 가용 현황",
|
||||
"availMonth": "홀 가용 ({{y}}년 {{m}}월)",
|
||||
"availHalls": "전체 홀 {{n}}개",
|
||||
"availFreeDays": "가용 {{free}}/{{total}}일",
|
||||
"availOpen": "가용",
|
||||
"availFull": "만실"
|
||||
},
|
||||
"boothsales": {
|
||||
"title": "부스 판매",
|
||||
"subtitleManager": "부스 판매 인벤토리 관리 — 상태·가격·차단 및 정산 연계",
|
||||
"subtitleExhibitor": "가용 부스를 선택해 선점 후 구매를 확정하세요",
|
||||
"noEventTitle": "행사를 선택해 주세요",
|
||||
"noEventDesc": "부스 판매를 조회할 행사가 지정되지 않았습니다.",
|
||||
"summaryAria": "부스 판매 상태 요약",
|
||||
"stAvailable": "가용",
|
||||
"stHeld": "선점",
|
||||
"stSold": "판매완료",
|
||||
"stBlocked": "차단",
|
||||
"rate": "임대 요율 {{rate}}원/㎡ ({{ver}})",
|
||||
"hallFilter": "홀 필터",
|
||||
"allHalls": "전체 홀",
|
||||
"loadError": "부스 판매 인벤토리를 불러오지 못했습니다.",
|
||||
"emptyTitle": "표시할 부스가 없습니다",
|
||||
"emptyManage": "이 행사의 배치안(layout)에 등록된 부스가 없습니다.",
|
||||
"emptyExhibitor": "현재 선택 가능한 가용 부스가 없습니다.",
|
||||
"status": {
|
||||
"available": "가용",
|
||||
"held": "선점됨",
|
||||
"sold": "판매완료",
|
||||
"blocked": "차단됨"
|
||||
},
|
||||
"wonUnit": "원",
|
||||
"hallLabel": "홀",
|
||||
"areaLabel": "면적",
|
||||
"soldTo": "판매처: {{c}}",
|
||||
"hold": "선점하기",
|
||||
"purchase": "구매 확정",
|
||||
"release": "선점 해제",
|
||||
"setPrice": "가격 설정",
|
||||
"block": "차단",
|
||||
"unblock": "차단 해제",
|
||||
"settle": "청구서 생성",
|
||||
"settling": "생성 중…",
|
||||
"heldToast": "부스를 선점했습니다. 제한 시간 내에 구매를 확정하세요.",
|
||||
"purchasedToast": "구매가 확정되었습니다.",
|
||||
"releasedToast": "선점을 해제했습니다.",
|
||||
"blockToast": "부스 상태를 변경했습니다.",
|
||||
"priceToast": "판매가를 변경했습니다.",
|
||||
"settleToast": "청구서 생성 완료 — 신규 {{created}}건, 기존 유지 {{skipped}}건.",
|
||||
"settleFail": "청구서 생성에 실패했습니다(권한 확인).",
|
||||
"holdConflict": "이미 다른 사용자가 선점했거나 판매/차단된 부스입니다.",
|
||||
"purchaseConflict": "선점이 만료되었거나 다른 사용자가 선점한 부스입니다.",
|
||||
"blockConflict": "이미 판매된 부스는 차단할 수 없습니다.",
|
||||
"actionFail": "요청을 처리하지 못했습니다.",
|
||||
"pricePrompt": "판매가(원)를 입력하세요. 빈칸이면 면적×요율 자동 계산으로 복원합니다.",
|
||||
"priceInvalid": "0 이상의 금액을 입력해 주세요."
|
||||
},
|
||||
"settlement": {
|
||||
"title": "정산·결제",
|
||||
@ -657,7 +817,8 @@
|
||||
"pending": "미발행",
|
||||
"invoiced": "청구됨",
|
||||
"paid": "완납",
|
||||
"overdue": "연체"
|
||||
"overdue": "연체",
|
||||
"cancelled": "취소"
|
||||
},
|
||||
"listError": "청구서 목록을 불러오지 못했습니다.",
|
||||
"emptyTitle": "청구서가 없습니다",
|
||||
@ -706,7 +867,47 @@
|
||||
"errPositive": "0보다 큰 금액을 입력해 주세요.",
|
||||
"errExceed": "미수 잔액을 초과할 수 없습니다.",
|
||||
"pgNote": "실제 결제는 외부 PG로 위임됩니다. 본 화면은 수납 확인 기록 전용입니다.",
|
||||
"managerOnly": "수납·발행은 주최자/홀매니저만 수행할 수 있습니다."
|
||||
"managerOnly": "수납·발행은 주최자/홀매니저만 수행할 수 있습니다.",
|
||||
"colCategory": "유형",
|
||||
"catRental": "임대·유틸",
|
||||
"catAuctionFee": "옥션 수수료",
|
||||
"reportTitle": "정산 리포트",
|
||||
"reportShow": "리포트 보기",
|
||||
"reportHide": "닫기",
|
||||
"reportHint": "카테고리·상태별 합계와 수납율·미수를 집계하고 CSV로 내려받습니다.",
|
||||
"reportError": "리포트를 불러오지 못했습니다.",
|
||||
"reportCsv": "CSV 다운로드",
|
||||
"reportByCategory": "카테고리별 합계",
|
||||
"reportByStatus": "상태별 합계",
|
||||
"reportEmpty": "집계할 청구가 없습니다.",
|
||||
"reportNote": "합계는 취소된 청구를 제외한 값입니다. 금액은 서버 계산 기준입니다.",
|
||||
"reportRate": "수납율",
|
||||
"reportCount": "건수",
|
||||
"taxTitle": "세금계산서",
|
||||
"taxNo": "계산서 번호",
|
||||
"taxSupplier": "공급자",
|
||||
"taxBuyer": "공급받는자",
|
||||
"taxItem": "품목",
|
||||
"taxNone": "발행된 세금계산서가 없습니다.",
|
||||
"taxIssue": "세금계산서 발행",
|
||||
"taxIssuing": "발행 중…",
|
||||
"taxIssueFail": "세금계산서 발행에 실패했습니다.",
|
||||
"cancelledNote": "취소된 청구입니다.",
|
||||
"cancelInvoice": "청구 취소",
|
||||
"refundPreview": "환불 산정 미리보기",
|
||||
"refundError": "환불 산정을 불러오지 못했습니다.",
|
||||
"refundBracket": "적용 구간",
|
||||
"refundRate": "환불율",
|
||||
"refundBase": "기준액(납입)",
|
||||
"refundAmount": "환불액",
|
||||
"forfeitAmount": "공제·위약액",
|
||||
"refundManual": "행사 개막일 미확정 등으로 자동 산정이 불가합니다 — 재무 수기 검토 대상입니다.",
|
||||
"cancelReason": "취소 사유",
|
||||
"cancelReasonPh": "취소 사유(선택)",
|
||||
"cancelAbort": "취소 중단",
|
||||
"cancelConfirm": "취소 확정",
|
||||
"cancelling": "처리 중…",
|
||||
"cancelFail": "청구 취소에 실패했습니다."
|
||||
},
|
||||
"hallassign": {
|
||||
"title": "홀 배정 · 자동 견적",
|
||||
@ -760,6 +961,48 @@
|
||||
"deposit": "관리비 예치금(별도)",
|
||||
"ruleset": "(룰셋 {{v}})"
|
||||
},
|
||||
"ai": {
|
||||
"console": {
|
||||
"title": "AI 콘솔",
|
||||
"subtitle": "자연어로 데이터를 조회하고 전시 규정을 질의합니다. 결과는 참고용이며 근거를 함께 표기합니다.",
|
||||
"badge": "참고용",
|
||||
"tabsAria": "AI 콘솔 탭",
|
||||
"tabNlquery": "자연어 조회",
|
||||
"tabRules": "규정 챗봇"
|
||||
},
|
||||
"common": {
|
||||
"running": "실행 중…",
|
||||
"provClaude": "Claude 응답",
|
||||
"provOllama": "Ollama 응답",
|
||||
"provRules": "규정 원문 기반",
|
||||
"provNone": "응답 없음",
|
||||
"unavailable": "AI 기능을 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해 주세요.",
|
||||
"invalid": "안전 규칙에 위배되어 실행할 수 없는 조회입니다.",
|
||||
"forbidden": "이 기능을 사용할 권한이 없습니다.",
|
||||
"error": "요청 처리 중 오류가 발생했습니다."
|
||||
},
|
||||
"nlquery": {
|
||||
"placeholder": "예: 상태가 active 인 행사 목록을 보여줘",
|
||||
"inputAria": "자연어 조회 질문",
|
||||
"run": "조회",
|
||||
"guardNote": "읽기 전용 SELECT만 실행되며 개인정보 컬럼은 조회되지 않습니다. 최대 200행.",
|
||||
"rowCount": "{{n}}행",
|
||||
"showSql": "생성 SQL 보기",
|
||||
"hideSql": "생성 SQL 숨기기",
|
||||
"sqlAria": "생성된 SQL",
|
||||
"emptyTitle": "결과가 없습니다",
|
||||
"emptyDesc": "조건에 해당하는 데이터가 없습니다. 질문을 바꿔 다시 시도해 주세요."
|
||||
},
|
||||
"rules": {
|
||||
"placeholder": "예: 장치물 최대 높이가 얼마인가요?",
|
||||
"inputAria": "규정 질문",
|
||||
"run": "질의",
|
||||
"groundNote": "규정·요율 룰셋을 근거로 답하며, 근거가 없으면 정직하게 알려드립니다.",
|
||||
"citations": "근거 규정",
|
||||
"notFound": "규정에서 찾지 못함",
|
||||
"degraded": "규정 원문 요약(AI 미가용)"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "경영분석",
|
||||
"subtitle": "킨텍스 운영 수익성 · 참가업체 인사이트",
|
||||
@ -1278,5 +1521,164 @@
|
||||
"updateFail": "변경에 실패했습니다.",
|
||||
"deleteFail": "삭제에 실패했습니다.",
|
||||
"deleteConfirm": "\"{{title}}\" 슬라이드를 삭제할까요?"
|
||||
},
|
||||
"approval": {
|
||||
"queueTitle": "결재 대기함",
|
||||
"search": "검색",
|
||||
"searchPlaceholder": "제목 검색…",
|
||||
"pendingCount": "결재 대기",
|
||||
"pendingList": "내 결재 대기 문서",
|
||||
"colTitle": "제목",
|
||||
"colDocType": "유형",
|
||||
"colDrafter": "기안자",
|
||||
"colSubmitted": "상신일",
|
||||
"colStep": "단계",
|
||||
"colStatus": "상태",
|
||||
"colAction": "처리",
|
||||
"open": "열기",
|
||||
"emptyPending": "결재할 문서가 없습니다",
|
||||
"status": {
|
||||
"DRAFT": "임시저장",
|
||||
"SUBMITTED": "상신",
|
||||
"IN_PROGRESS": "진행중",
|
||||
"APPROVED": "승인완료",
|
||||
"REJECTED": "반려",
|
||||
"CANCELED": "회수"
|
||||
},
|
||||
"reviewTitle": "결재 검토",
|
||||
"version": "버전",
|
||||
"drafter": "기안자",
|
||||
"step": "단계",
|
||||
"demoMode": "미리보기(데모)",
|
||||
"downloadOriginal": "원본 다운로드",
|
||||
"previewAria": "문서 미리보기",
|
||||
"lineAria": "결재선·이력",
|
||||
"docBody": "본문",
|
||||
"line": "결재선",
|
||||
"history": "처리 이력",
|
||||
"noHistory": "처리 이력이 없습니다",
|
||||
"lineStatus": {
|
||||
"WAIT": "대기",
|
||||
"CURRENT": "결재 진행",
|
||||
"APPROVED": "승인",
|
||||
"REJECTED": "반려",
|
||||
"SKIPPED": "건너뜀"
|
||||
},
|
||||
"action": {
|
||||
"SUBMIT": "상신",
|
||||
"APPROVE": "승인",
|
||||
"REJECT": "반려",
|
||||
"RESUBMIT": "재상신",
|
||||
"CANCEL": "회수"
|
||||
},
|
||||
"approve": "승인",
|
||||
"reject": "반려",
|
||||
"approvedDone": "승인 처리되었습니다.",
|
||||
"rejectedDone": "반려 처리되었습니다.",
|
||||
"canActHint": "이 문서의 현재 단계 결재자입니다.",
|
||||
"notApproverHint": "현재 단계 결재자만 처리할 수 있습니다.",
|
||||
"demoFootNote": "결재 대기함에서 문서를 선택하면 실제 승인/반려가 가능합니다.",
|
||||
"demoHint": "결재 문서가 지정되지 않았습니다. 결재 대기함에서 문서를 열어 주세요.",
|
||||
"commentPlaceholder": "의견(반려 시 필수)을 입력하세요",
|
||||
"comment": "의견"
|
||||
},
|
||||
"auction": {
|
||||
"boq": {
|
||||
"title": "물량서(BOQ) 자동산출",
|
||||
"error": "물량서를 불러오지 못했습니다.",
|
||||
"scopeBooth": "{{label}} 부스 기준",
|
||||
"scopeEvent": "행사 전체 부스 집계",
|
||||
"summary": "부스 {{count}}실 · {{area}}㎡",
|
||||
"colSeq": "No",
|
||||
"colTrade": "공종",
|
||||
"colItem": "품명",
|
||||
"colQty": "수량",
|
||||
"colUnit": "단위",
|
||||
"colBasis": "산출 근거"
|
||||
}
|
||||
},
|
||||
"logistics": {
|
||||
"title": "물류 허브",
|
||||
"subtitle": "반입·반출 도크 · 부대장비 · 렌탈 · 선입고 트래킹",
|
||||
"tabsAria": "물류 기능 전환",
|
||||
"tabs": {
|
||||
"dock": "도크 예약",
|
||||
"equipment": "장비 신청",
|
||||
"rental": "렌탈 주문",
|
||||
"inbound": "선입고"
|
||||
},
|
||||
"boothNo": "부스 번호",
|
||||
"qty": "수량",
|
||||
"estimate": "예상 금액",
|
||||
"submitting": "처리 중…",
|
||||
"submitOk": "접수되었습니다.",
|
||||
"submitErr": "처리에 실패했습니다.",
|
||||
"statusUpdated": "상태가 변경되었습니다.",
|
||||
"loadErr": "물류 정보를 불러오지 못했습니다.",
|
||||
"serverAmountNote": "금액은 서버 기준으로 확정됩니다",
|
||||
"equipment": {
|
||||
"formTitle": "부대장비 신청",
|
||||
"item": "장비 품목",
|
||||
"days": "사용 일수",
|
||||
"daysUnit": "일",
|
||||
"useDate": "사용 예정일",
|
||||
"submit": "장비 신청",
|
||||
"listTitle": "신청 내역",
|
||||
"empty": "신청 내역이 없습니다",
|
||||
"emptyDesc": "지게차·리프트 등 부대장비를 신청하세요.",
|
||||
"approve": "승인",
|
||||
"reject": "반려",
|
||||
"rejectReason": "현장 여건상 반려",
|
||||
"done": "처리 완료",
|
||||
"st": {
|
||||
"requested": "신청됨",
|
||||
"approved": "승인",
|
||||
"rejected": "반려",
|
||||
"done": "완료"
|
||||
}
|
||||
},
|
||||
"rental": {
|
||||
"formTitle": "부대물품 렌탈 주문",
|
||||
"item": "렌탈 품목",
|
||||
"start": "시작일",
|
||||
"end": "종료일",
|
||||
"submit": "렌탈 주문",
|
||||
"listTitle": "주문 내역",
|
||||
"empty": "주문 내역이 없습니다",
|
||||
"emptyDesc": "테이블·의자·모니터 등을 주문하세요.",
|
||||
"period": "{{days}}일",
|
||||
"confirm": "확정",
|
||||
"cancel": "취소",
|
||||
"deliver": "납품 완료",
|
||||
"st": {
|
||||
"ordered": "주문됨",
|
||||
"confirmed": "확정",
|
||||
"cancelled": "취소",
|
||||
"delivered": "납품"
|
||||
}
|
||||
},
|
||||
"inbound": {
|
||||
"formTitle": "선입고 운송장 등록",
|
||||
"trackingNo": "운송장 번호",
|
||||
"carrier": "운송사",
|
||||
"itemDesc": "화물 내용",
|
||||
"eta": "입고 예정일",
|
||||
"submit": "운송장 등록",
|
||||
"submitSub": "등록 후 입고 상태를 추적합니다",
|
||||
"listTitle": "선입고 현황",
|
||||
"empty": "선입고 내역이 없습니다",
|
||||
"emptyDesc": "사전 배송 운송장을 등록하세요.",
|
||||
"st": {
|
||||
"registered": "등록됨",
|
||||
"inbound_scheduled": "입고 예정",
|
||||
"arrived_warehouse": "창고 도착",
|
||||
"delivered_booth": "부스 전달"
|
||||
},
|
||||
"advance": {
|
||||
"inbound_scheduled": "입고 예정 처리",
|
||||
"arrived_warehouse": "창고 도착 처리",
|
||||
"delivered_booth": "부스 전달 완료"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,6 +62,12 @@
|
||||
"contactTel": "总机 1899-1001",
|
||||
"contactEmail": "exhibition@kintex.com",
|
||||
"bottom": "© KINTEX. 版权所有。(10390) 217-60, Kintex-ro, Ilsanseo-gu, Goyang-si"
|
||||
},
|
||||
"footerbar": {
|
||||
"copyright": "© 2026 KINTEX. 版权所有。",
|
||||
"systemOk": "系统正常",
|
||||
"systemDown": "系统维护",
|
||||
"support": "客户支持"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
@ -101,7 +107,105 @@
|
||||
"mapAria": "KINTEX 位置地图",
|
||||
"mapAddress": "高阳市一山西区KINTEX路217-60",
|
||||
"mapPlace": "KINTEX 第1、2展馆",
|
||||
"mapViewAria": "查看地图"
|
||||
"mapViewAria": "查看地图",
|
||||
"pageAria": "主页",
|
||||
"greeting": "您好,{{name}}",
|
||||
"greetingGuest": "您好",
|
||||
"viewAll": "查看全部",
|
||||
"loadError": "加载失败",
|
||||
"role": {
|
||||
"organizer": "主办方",
|
||||
"exhibitor": "参展商",
|
||||
"contractor": "施工商",
|
||||
"hallManager": "展馆经理",
|
||||
"admin": "管理员"
|
||||
},
|
||||
"kpi": {
|
||||
"aria": "概要指标"
|
||||
},
|
||||
"banner": {
|
||||
"aria": "活动介绍横幅",
|
||||
"prev": "上一张",
|
||||
"next": "下一张",
|
||||
"emptyTitle": "暂无横幅",
|
||||
"emptyDesc": "管理员登记的活动横幅将显示在此处。"
|
||||
},
|
||||
"showcase": {
|
||||
"title": "进行中·即将举办的展会",
|
||||
"all": "查看全部",
|
||||
"prev": "上一个",
|
||||
"next": "下一个",
|
||||
"statusOngoing": "进行中",
|
||||
"statusUpcoming": "即将举办",
|
||||
"emptyTitle": "该期间暂无展会",
|
||||
"emptyCta": "查看全部日程",
|
||||
"tab": {
|
||||
"ongoing": "进行中",
|
||||
"thisMonth": "本月",
|
||||
"nextMonth": "下月"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "展会日程",
|
||||
"prev": "上个月",
|
||||
"next": "下个月",
|
||||
"today": "今天",
|
||||
"all": "全部",
|
||||
"centerFilter": "展馆筛选",
|
||||
"yearMonth": "{{y}}年{{m}}月",
|
||||
"legendOngoing": "进行中",
|
||||
"legendUpcoming": "即将举办",
|
||||
"legendEnded": "已结束",
|
||||
"more": "+{{n}}",
|
||||
"moreTitle": "{{date}} 展会",
|
||||
"popupEmpty": "当天没有展会",
|
||||
"listTitle": "进行中·即将举办",
|
||||
"listEmpty": "暂无即将举办的展会",
|
||||
"listEmptyCta": "查看全部日程",
|
||||
"loadError": "日程加载失败"
|
||||
},
|
||||
"todo": {
|
||||
"title": "我的待办",
|
||||
"noDue": "无截止日期",
|
||||
"emptyTitle": "全部完成",
|
||||
"emptyDesc": "没有待处理的事项",
|
||||
"emptyCta": "打开通知"
|
||||
},
|
||||
"notice": {
|
||||
"title": "公告",
|
||||
"more": "更多",
|
||||
"new": "新",
|
||||
"pinned": "置顶",
|
||||
"emptyTitle": "暂无公告",
|
||||
"emptyCta": "查看全部公告"
|
||||
},
|
||||
"quick": {
|
||||
"title": "快捷操作",
|
||||
"editor": "布局编辑器",
|
||||
"invite": "邀请参展商",
|
||||
"docs": "文件流程",
|
||||
"settlement": "结算",
|
||||
"notice": "撰写公告",
|
||||
"search": "综合搜索"
|
||||
},
|
||||
"activity": {
|
||||
"title": "最近动态",
|
||||
"emptyTitle": "暂无最近动态",
|
||||
"emptyCta": "通知设置"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "刚刚",
|
||||
"minutes": "{{n}}分钟前",
|
||||
"hours": "{{n}}小时前",
|
||||
"days": "{{n}}天前"
|
||||
},
|
||||
"workspace": {
|
||||
"title": "我的活动",
|
||||
"emptyTitle": "尚未参与任何活动",
|
||||
"emptyDesc": "请确认邀请链接或邀请码",
|
||||
"invite": "验证邀请码",
|
||||
"codePh": "输入邀请码"
|
||||
}
|
||||
},
|
||||
"detail": {
|
||||
"tabs": {
|
||||
@ -626,7 +730,63 @@
|
||||
"day": "{{d}}日",
|
||||
"dayHasEvent": "{{d}}日 (有活动)",
|
||||
"hallUtil": "展厅使用率",
|
||||
"hallUtilMonth": "展厅使用率 ({{y}}年{{m}}月)"
|
||||
"hallUtilMonth": "展厅使用率 ({{y}}年{{m}}月)",
|
||||
"availTitle": "展厅可用情况",
|
||||
"availMonth": "展厅可用情况 ({{y}}年{{m}}月)",
|
||||
"availHalls": "共 {{n}} 个展厅",
|
||||
"availFreeDays": "可用 {{free}}/{{total}} 天",
|
||||
"availOpen": "可用",
|
||||
"availFull": "已满"
|
||||
},
|
||||
"boothsales": {
|
||||
"title": "展位销售",
|
||||
"subtitleManager": "管理展位销售库存 — 状态、价格、封锁及结算联动",
|
||||
"subtitleExhibitor": "选择可用展位,先占位再确认购买",
|
||||
"noEventTitle": "请选择展会",
|
||||
"noEventDesc": "未选择用于展位销售的展会。",
|
||||
"summaryAria": "展位销售状态汇总",
|
||||
"stAvailable": "可售",
|
||||
"stHeld": "占位中",
|
||||
"stSold": "已售",
|
||||
"stBlocked": "已封锁",
|
||||
"rate": "租金费率 {{rate}} 韩元/㎡ ({{ver}})",
|
||||
"hallFilter": "展厅筛选",
|
||||
"allHalls": "全部展厅",
|
||||
"loadError": "无法加载展位销售库存。",
|
||||
"emptyTitle": "没有可显示的展位",
|
||||
"emptyManage": "该展会的布局中未登记展位。",
|
||||
"emptyExhibitor": "当前没有可选择的展位。",
|
||||
"status": {
|
||||
"available": "可售",
|
||||
"held": "占位中",
|
||||
"sold": "已售",
|
||||
"blocked": "已封锁"
|
||||
},
|
||||
"wonUnit": " 韩元",
|
||||
"hallLabel": "展厅",
|
||||
"areaLabel": "面积",
|
||||
"soldTo": "购买方:{{c}}",
|
||||
"hold": "占位",
|
||||
"purchase": "确认购买",
|
||||
"release": "取消占位",
|
||||
"setPrice": "设置价格",
|
||||
"block": "封锁",
|
||||
"unblock": "解除封锁",
|
||||
"settle": "生成账单",
|
||||
"settling": "生成中…",
|
||||
"heldToast": "已占位。请在到期前确认购买。",
|
||||
"purchasedToast": "购买已确认。",
|
||||
"releasedToast": "已取消占位。",
|
||||
"blockToast": "展位状态已更新。",
|
||||
"priceToast": "销售价格已更新。",
|
||||
"settleToast": "账单生成完成 — 新增 {{created}} 条,保留 {{skipped}} 条。",
|
||||
"settleFail": "生成账单失败(请检查权限)。",
|
||||
"holdConflict": "该展位已被他人占位或已售/已封锁。",
|
||||
"purchaseConflict": "占位已过期或该展位已被他人占用。",
|
||||
"blockConflict": "已售展位无法封锁。",
|
||||
"actionFail": "无法处理请求。",
|
||||
"pricePrompt": "请输入销售价格(韩元)。留空则恢复为面积×费率自动计算。",
|
||||
"priceInvalid": "请输入不小于 0 的金额。"
|
||||
},
|
||||
"settlement": {
|
||||
"title": "结算·付款",
|
||||
@ -657,7 +817,8 @@
|
||||
"pending": "未开具",
|
||||
"invoiced": "已开具",
|
||||
"paid": "已付清",
|
||||
"overdue": "逾期"
|
||||
"overdue": "逾期",
|
||||
"cancelled": "已取消"
|
||||
},
|
||||
"listError": "无法加载账单列表。",
|
||||
"emptyTitle": "暂无账单",
|
||||
@ -706,7 +867,47 @@
|
||||
"errPositive": "请输入大于 0 的金额。",
|
||||
"errExceed": "不能超过未收余额。",
|
||||
"pgNote": "实际付款委托外部支付网关处理。本页面仅用于记录收款确认。",
|
||||
"managerOnly": "仅主办方/展厅经理可记录或开具收款。"
|
||||
"managerOnly": "仅主办方/展厅经理可记录或开具收款。",
|
||||
"colCategory": "类型",
|
||||
"catRental": "租赁·公用",
|
||||
"catAuctionFee": "拍卖手续费",
|
||||
"reportTitle": "结算报告",
|
||||
"reportShow": "查看报告",
|
||||
"reportHide": "关闭",
|
||||
"reportHint": "按类别/状态汇总合计、收款率与未收,并支持CSV导出。",
|
||||
"reportError": "无法加载报告。",
|
||||
"reportCsv": "下载CSV",
|
||||
"reportByCategory": "按类别合计",
|
||||
"reportByStatus": "按状态合计",
|
||||
"reportEmpty": "没有可汇总的账单。",
|
||||
"reportNote": "合计不含已取消账单。金额以服务器计算为准。",
|
||||
"reportRate": "收款率",
|
||||
"reportCount": "数量",
|
||||
"taxTitle": "税务发票",
|
||||
"taxNo": "发票编号",
|
||||
"taxSupplier": "供应方",
|
||||
"taxBuyer": "受票方",
|
||||
"taxItem": "项目",
|
||||
"taxNone": "尚未开具税务发票。",
|
||||
"taxIssue": "开具税务发票",
|
||||
"taxIssuing": "开具中…",
|
||||
"taxIssueFail": "开具税务发票失败。",
|
||||
"cancelledNote": "该账单已取消。",
|
||||
"cancelInvoice": "取消账单",
|
||||
"refundPreview": "退款测算预览",
|
||||
"refundError": "无法加载退款测算。",
|
||||
"refundBracket": "适用区间",
|
||||
"refundRate": "退款比例",
|
||||
"refundBase": "基准额(已付)",
|
||||
"refundAmount": "退款额",
|
||||
"forfeitAmount": "扣除·违约金",
|
||||
"refundManual": "因开展日未确定等无法自动测算 — 需财务人工审核。",
|
||||
"cancelReason": "取消原因",
|
||||
"cancelReasonPh": "取消原因(可选)",
|
||||
"cancelAbort": "中止",
|
||||
"cancelConfirm": "确认取消",
|
||||
"cancelling": "处理中…",
|
||||
"cancelFail": "取消账单失败。"
|
||||
},
|
||||
"hallassign": {
|
||||
"title": "展厅分配 · 自动报价",
|
||||
@ -760,6 +961,48 @@
|
||||
"deposit": "管理费押金(另计)",
|
||||
"ruleset": "(规则集 {{v}})"
|
||||
},
|
||||
"ai": {
|
||||
"console": {
|
||||
"title": "AI 控制台",
|
||||
"subtitle": "用自然语言查询数据并咨询展会规定。结果仅供参考,并标注依据。",
|
||||
"badge": "仅供参考",
|
||||
"tabsAria": "AI 控制台标签",
|
||||
"tabNlquery": "自然语言查询",
|
||||
"tabRules": "规定聊天机器人"
|
||||
},
|
||||
"common": {
|
||||
"running": "执行中…",
|
||||
"provClaude": "Claude 回复",
|
||||
"provOllama": "Ollama 回复",
|
||||
"provRules": "基于规定原文",
|
||||
"provNone": "无回复",
|
||||
"unavailable": "AI 功能暂时不可用,请稍后重试。",
|
||||
"invalid": "该查询违反安全规则,无法执行。",
|
||||
"forbidden": "您无权使用此功能。",
|
||||
"error": "处理请求时发生错误。"
|
||||
},
|
||||
"nlquery": {
|
||||
"placeholder": "例如:显示状态为 active 的活动列表",
|
||||
"inputAria": "自然语言查询问题",
|
||||
"run": "查询",
|
||||
"guardNote": "仅执行只读 SELECT,不查询个人信息列。最多 200 行。",
|
||||
"rowCount": "{{n}} 行",
|
||||
"showSql": "查看生成的 SQL",
|
||||
"hideSql": "隐藏生成的 SQL",
|
||||
"sqlAria": "生成的 SQL",
|
||||
"emptyTitle": "无结果",
|
||||
"emptyDesc": "没有符合条件的数据。请更换问题后重试。"
|
||||
},
|
||||
"rules": {
|
||||
"placeholder": "例如:装置物的最大高度是多少?",
|
||||
"inputAria": "规定问题",
|
||||
"run": "提问",
|
||||
"groundNote": "回答以规定与费率规则集为依据;若无依据将如实告知。",
|
||||
"citations": "依据规定",
|
||||
"notFound": "在规定中未找到",
|
||||
"degraded": "规定原文摘要(AI 不可用)"
|
||||
}
|
||||
},
|
||||
"analytics": {
|
||||
"title": "经营分析",
|
||||
"subtitle": "KINTEX 运营盈利能力 · 参展商洞察",
|
||||
@ -1278,5 +1521,164 @@
|
||||
"updateFail": "变更失败。",
|
||||
"deleteFail": "删除失败。",
|
||||
"deleteConfirm": "确定删除幻灯片 \"{{title}}\" 吗?"
|
||||
},
|
||||
"approval": {
|
||||
"queueTitle": "审批收件箱",
|
||||
"search": "搜索",
|
||||
"searchPlaceholder": "搜索标题...",
|
||||
"pendingCount": "待审批",
|
||||
"pendingList": "待我审批的文档",
|
||||
"colTitle": "标题",
|
||||
"colDocType": "类型",
|
||||
"colDrafter": "起草人",
|
||||
"colSubmitted": "提交日",
|
||||
"colStep": "步骤",
|
||||
"colStatus": "状态",
|
||||
"colAction": "操作",
|
||||
"open": "打开",
|
||||
"emptyPending": "没有待审批的文档",
|
||||
"status": {
|
||||
"DRAFT": "草稿",
|
||||
"SUBMITTED": "已提交",
|
||||
"IN_PROGRESS": "进行中",
|
||||
"APPROVED": "已批准",
|
||||
"REJECTED": "已驳回",
|
||||
"CANCELED": "已撤回"
|
||||
},
|
||||
"reviewTitle": "审批审查",
|
||||
"version": "版本",
|
||||
"drafter": "起草人",
|
||||
"step": "步骤",
|
||||
"demoMode": "预览(演示)",
|
||||
"downloadOriginal": "下载原件",
|
||||
"previewAria": "文档预览",
|
||||
"lineAria": "审批线与历史",
|
||||
"docBody": "正文",
|
||||
"line": "审批线",
|
||||
"history": "处理历史",
|
||||
"noHistory": "暂无处理历史",
|
||||
"lineStatus": {
|
||||
"WAIT": "等待",
|
||||
"CURRENT": "审批中",
|
||||
"APPROVED": "已批准",
|
||||
"REJECTED": "已驳回",
|
||||
"SKIPPED": "已跳过"
|
||||
},
|
||||
"action": {
|
||||
"SUBMIT": "提交",
|
||||
"APPROVE": "批准",
|
||||
"REJECT": "驳回",
|
||||
"RESUBMIT": "重新提交",
|
||||
"CANCEL": "撤回"
|
||||
},
|
||||
"approve": "批准",
|
||||
"reject": "驳回",
|
||||
"approvedDone": "已批准。",
|
||||
"rejectedDone": "已驳回。",
|
||||
"canActHint": "您是该文档当前步骤的审批人。",
|
||||
"notApproverHint": "仅当前步骤审批人可操作。",
|
||||
"demoFootNote": "从审批收件箱选择文档即可批准或驳回。",
|
||||
"demoHint": "未指定审批文档,请从审批收件箱打开。",
|
||||
"commentPlaceholder": "输入意见(驳回时必填)",
|
||||
"comment": "意见"
|
||||
},
|
||||
"auction": {
|
||||
"boq": {
|
||||
"title": "工程量清单(BOQ)自动生成",
|
||||
"error": "无法加载工程量清单。",
|
||||
"scopeBooth": "{{label}} 展位",
|
||||
"scopeEvent": "全场展位汇总",
|
||||
"summary": "展位 {{count}}个 · {{area}}㎡",
|
||||
"colSeq": "序号",
|
||||
"colTrade": "工种",
|
||||
"colItem": "品名",
|
||||
"colQty": "数量",
|
||||
"colUnit": "单位",
|
||||
"colBasis": "计算依据"
|
||||
}
|
||||
},
|
||||
"logistics": {
|
||||
"title": "物流中心",
|
||||
"subtitle": "进出场月台 · 附加设备 · 租赁 · 预入库追踪",
|
||||
"tabsAria": "切换物流功能",
|
||||
"tabs": {
|
||||
"dock": "月台预约",
|
||||
"equipment": "设备申请",
|
||||
"rental": "租赁订单",
|
||||
"inbound": "预入库"
|
||||
},
|
||||
"boothNo": "展位号",
|
||||
"qty": "数量",
|
||||
"estimate": "预估金额",
|
||||
"submitting": "处理中…",
|
||||
"submitOk": "已受理。",
|
||||
"submitErr": "处理失败。",
|
||||
"statusUpdated": "状态已更新。",
|
||||
"loadErr": "无法加载物流信息。",
|
||||
"serverAmountNote": "金额以服务器为准确定",
|
||||
"equipment": {
|
||||
"formTitle": "附加设备申请",
|
||||
"item": "设备品目",
|
||||
"days": "使用天数",
|
||||
"daysUnit": "天",
|
||||
"useDate": "预计使用日",
|
||||
"submit": "申请设备",
|
||||
"listTitle": "申请记录",
|
||||
"empty": "暂无申请记录",
|
||||
"emptyDesc": "申请叉车、升降机等附加设备。",
|
||||
"approve": "批准",
|
||||
"reject": "驳回",
|
||||
"rejectReason": "因现场条件驳回",
|
||||
"done": "处理完成",
|
||||
"st": {
|
||||
"requested": "已申请",
|
||||
"approved": "已批准",
|
||||
"rejected": "已驳回",
|
||||
"done": "已完成"
|
||||
}
|
||||
},
|
||||
"rental": {
|
||||
"formTitle": "附加物品租赁订单",
|
||||
"item": "租赁品目",
|
||||
"start": "开始日期",
|
||||
"end": "结束日期",
|
||||
"submit": "下单租赁",
|
||||
"listTitle": "订单记录",
|
||||
"empty": "暂无订单记录",
|
||||
"emptyDesc": "订购桌椅、显示器等。",
|
||||
"period": "{{days}}天",
|
||||
"confirm": "确认",
|
||||
"cancel": "取消",
|
||||
"deliver": "交付完成",
|
||||
"st": {
|
||||
"ordered": "已下单",
|
||||
"confirmed": "已确认",
|
||||
"cancelled": "已取消",
|
||||
"delivered": "已交付"
|
||||
}
|
||||
},
|
||||
"inbound": {
|
||||
"formTitle": "预入库运单登记",
|
||||
"trackingNo": "运单号",
|
||||
"carrier": "承运商",
|
||||
"itemDesc": "货物内容",
|
||||
"eta": "预计入库日",
|
||||
"submit": "登记运单",
|
||||
"submitSub": "登记后追踪入库状态",
|
||||
"listTitle": "预入库状态",
|
||||
"empty": "暂无预入库记录",
|
||||
"emptyDesc": "登记预配送运单号。",
|
||||
"st": {
|
||||
"registered": "已登记",
|
||||
"inbound_scheduled": "待入库",
|
||||
"arrived_warehouse": "到达仓库",
|
||||
"delivered_booth": "送达展位"
|
||||
},
|
||||
"advance": {
|
||||
"inbound_scheduled": "标记待入库",
|
||||
"arrived_warehouse": "标记到仓",
|
||||
"delivered_booth": "标记送达展位"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
249
src/frontend/src/screens/analytics/AiConsolePanel.tsx
Normal file
249
src/frontend/src/screens/analytics/AiConsolePanel.tsx
Normal file
@ -0,0 +1,249 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '../../components/ui/Button';
|
||||
import { AiLabel } from '../../components/ui/Badge';
|
||||
import { EmptyState } from '../../components/ui/States';
|
||||
import { ApiRequestError } from '../../api/client';
|
||||
import { aiConsoleApi } from './aiConsoleApi';
|
||||
import type { NlQueryResult, RulesChatResult } from './aiConsoleApi';
|
||||
import './aiConsole.css';
|
||||
|
||||
/*
|
||||
* F092 자연어 조회(Text-to-SQL) + F025 규정 챗봇 콘솔 — SCR-13 경영분석 하위 패널.
|
||||
* 두 탭 동거: [자연어 조회] 질문→표 결과+생성 SQL 접기, [규정 챗봇] 질문→근거 룰 인용 답변.
|
||||
* 모든 AI 산출물은 AiLabel + provider/degraded 를 정직하게 표기(참고용 · 환각 방지).
|
||||
*/
|
||||
type Tab = 'nlquery' | 'rules';
|
||||
|
||||
export function AiConsolePanel() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<Tab>('nlquery');
|
||||
|
||||
return (
|
||||
<section className="kx-card kx-aicon" aria-label={t('ai.console.title')}>
|
||||
<div className="kx-card__head">
|
||||
<div>
|
||||
<h2>{t('ai.console.title')}</h2>
|
||||
<p className="kx-card__hint">{t('ai.console.subtitle')}</p>
|
||||
</div>
|
||||
<AiLabel>{t('ai.console.badge')}</AiLabel>
|
||||
</div>
|
||||
|
||||
<div className="kx-seg" role="tablist" aria-label={t('ai.console.tabsAria')}>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === 'nlquery'}
|
||||
className={`kx-seg__btn ${tab === 'nlquery' ? 'is-active' : ''}`}
|
||||
onClick={() => setTab('nlquery')}
|
||||
>
|
||||
{t('ai.console.tabNlquery')}
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
aria-selected={tab === 'rules'}
|
||||
className={`kx-seg__btn ${tab === 'rules' ? 'is-active' : ''}`}
|
||||
onClick={() => setTab('rules')}
|
||||
>
|
||||
{t('ai.console.tabRules')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === 'nlquery' ? <NlQueryTab /> : <RulesChatTab />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 탭 1: 자연어 조회(Text-to-SQL) ──
|
||||
function NlQueryTab() {
|
||||
const { t } = useTranslation();
|
||||
const [q, setQ] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<NlQueryResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSql, setShowSql] = useState(true);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!q.trim() || loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await aiConsoleApi.nlquery(q.trim());
|
||||
setResult(r);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, t));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kx-aicon__pane">
|
||||
<form className="kx-aicon__ask" onSubmit={submit}>
|
||||
<input
|
||||
className="kx-input"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t('ai.nlquery.placeholder')}
|
||||
aria-label={t('ai.nlquery.inputAria')}
|
||||
/>
|
||||
<Button variant="ai" type="submit" disabled={loading || !q.trim()}>
|
||||
{loading ? t('ai.common.running') : t('ai.nlquery.run')}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="kx-aicon__note">{t('ai.nlquery.guardNote')}</p>
|
||||
|
||||
{error && <div className="kx-aicon__error" role="alert">{error}</div>}
|
||||
|
||||
{result && (
|
||||
<div className="kx-aicon__result">
|
||||
<div className="kx-aicon__meta">
|
||||
<span className="kx-aicon__prov">{providerLabel(result.provider, t)}</span>
|
||||
<span className="kx-aicon__count tnum">{t('ai.nlquery.rowCount', { n: result.rowCount })}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="kx-aicon__toggle"
|
||||
onClick={() => setShowSql((s) => !s)}
|
||||
aria-expanded={showSql}
|
||||
>
|
||||
{showSql ? t('ai.nlquery.hideSql') : t('ai.nlquery.showSql')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showSql && (
|
||||
<pre className="kx-aicon__sql" aria-label={t('ai.nlquery.sqlAria')}>
|
||||
<code>{result.sql}</code>
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{result.rowCount === 0 ? (
|
||||
<EmptyState title={t('ai.nlquery.emptyTitle')} description={t('ai.nlquery.emptyDesc')} />
|
||||
) : (
|
||||
<div className="kx-table-scroll">
|
||||
<table className="kx-table kx-table--zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
{result.columns.map((c) => (
|
||||
<th key={c}>{c}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.rows.map((row, i) => (
|
||||
<tr key={i}>
|
||||
{result.columns.map((c) => (
|
||||
<td key={c}>{cell(row[c])}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 탭 2: 규정 챗봇 ──
|
||||
function RulesChatTab() {
|
||||
const { t } = useTranslation();
|
||||
const [q, setQ] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<RulesChatResult | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!q.trim() || loading) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const r = await aiConsoleApi.rulesChat(q.trim());
|
||||
setResult(r);
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, t));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="kx-aicon__pane">
|
||||
<form className="kx-aicon__ask" onSubmit={submit}>
|
||||
<input
|
||||
className="kx-input"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder={t('ai.rules.placeholder')}
|
||||
aria-label={t('ai.rules.inputAria')}
|
||||
/>
|
||||
<Button variant="ai" type="submit" disabled={loading || !q.trim()}>
|
||||
{loading ? t('ai.common.running') : t('ai.rules.run')}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="kx-aicon__note">{t('ai.rules.groundNote')}</p>
|
||||
|
||||
{error && <div className="kx-aicon__error" role="alert">{error}</div>}
|
||||
|
||||
{result && (
|
||||
<div className="kx-aicon__result">
|
||||
<div className="kx-aicon__meta">
|
||||
<span className="kx-aicon__prov">{providerLabel(result.provider, t)}</span>
|
||||
{!result.grounded && (
|
||||
<span className="kx-aicon__ungrounded">{t('ai.rules.notFound')}</span>
|
||||
)}
|
||||
{result.degraded && result.grounded && (
|
||||
<span className="kx-aicon__degraded">{t('ai.rules.degraded')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="kx-aicon__answer">{result.answer}</div>
|
||||
|
||||
{result.citations.length > 0 && (
|
||||
<div className="kx-aicon__cites">
|
||||
<span className="kx-aicon__cites-h">{t('ai.rules.citations')}</span>
|
||||
<ul>
|
||||
{result.citations.map((c) => (
|
||||
<li key={c.code}>
|
||||
<code className="kx-aicon__code">{c.code}</code> {c.label}
|
||||
{c.detail && <span className="kx-aicon__cite-detail"> · {c.detail}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── helpers ──
|
||||
function cell(v: unknown): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
if (typeof v === 'object') return JSON.stringify(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function providerLabel(provider: string, t: (k: string) => string): string {
|
||||
if (provider === 'claude') return t('ai.common.provClaude');
|
||||
if (provider === 'ollama') return t('ai.common.provOllama');
|
||||
if (provider === 'rules') return t('ai.common.provRules');
|
||||
return t('ai.common.provNone');
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown, t: (k: string) => string): string {
|
||||
if (err instanceof ApiRequestError) {
|
||||
// err.code 는 백엔드 오류코드 문자열(AI_UNAVAILABLE 은 공용 타입 유니온 밖 — string 비교).
|
||||
const code = err.code as string;
|
||||
if (code === 'AI_UNAVAILABLE') return t('ai.common.unavailable');
|
||||
if (code === 'VALIDATION') return err.message || t('ai.common.invalid');
|
||||
if (code === 'FORBIDDEN') return t('ai.common.forbidden');
|
||||
return err.message || t('ai.common.error');
|
||||
}
|
||||
return t('ai.common.error');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user