feat(backlog): wave 4 — ops real-data, M9 settlement (V24), M1 hall assign+quote (V25), roleCode/nav; fix OTP retry lockout

- fix(security): verifyOtp/enrollVerify consumed the challenge BEFORE code verification —
  one mistyped TOTP burned the session and every retry hit "세션 만료" (live incident).
  Now consume only on success, keep challenge for up to 5 failures (OtpChallengeStoreTest)
- SCR-14 ops: congestion from real check-in aggregates, power from utility orders;
  hardware widgets stay as honest "sensor not connected" empty states
- M9 settlement: /api/settlement (dashboard/invoices/generate/issue/payments), V24 invoice
  tables, server-authoritative amounts (ST_Area x rate + VAT), PG-delegation notice, /settlement UI
- M1 hall assign: /api/halls CRUD + period-overlap conflict (409 with details) + rates-v1.0
  auto-quote, V25 idempotent period columns, /halls board UI
- shared: AuthUser.roleCode (JWT rc claim, additive), AdminGuard precise check, AppShell
  placeholder nav wired via event-context builders, /_styleguide dev-only route

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zio 2026-07-12 10:14:29 +09:00
parent 8a49a68a6a
commit 4049b7c222
43 changed files with 4009 additions and 144 deletions

View File

@ -98,6 +98,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 | **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 | **실데이터·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 | **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 | **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 |
| 2026-07-12 | **3차 웨이브(백로그 3트랙) + 배포 크래시 근본수정** — ①크래시 규명: c6f3a8d jar가 MyBatis `<script>` XML 위반(AuctionMapper `deadline < now()`·VisitorMapper `<=`)으로 부팅 크래시 루프→롤백. 매퍼 교정 + `MapperAnnotationParseTest` 회귀 가드 + `checkin.css` 누락 작성(서버 프론트 빌드 실패 원인) ②M16 BI 전역: `GET /api/analytics/overview`(행사 간 매출·홀 가동률 추이·리텐션·선형회귀 예측, 홀매니저 전용, 마이그레이션 無) + /analytics 인라인 섹션 ③M18 관리자: 사용자·역할/권한·공통코드·메뉴 CRUD 화면 4종(백엔드 system 라이브 소비, 신규 백엔드 0)+`AdminGuard`(전 /admin/* 라우트 가드 배선)+랜딩 모듈 바로가기. A8 룰셋은 기왕 실배선 확인 ④SCR-15 일정: V23(category·est_visitors 백필 1,098건)+홀 가동률/캘린더 API 2종+실 전환 ⑤QA minor: ✓글리프 SVG화(kx-field·★는 기왕 해소). 검증: compileJava·test(매퍼 파싱 포함)·tsc -b --force·vite build 전부 EXIT 0. ⚠AdminGuard 판정 한계: 전역 role_code 미노출로 hallManager 기준 — 정확 판정은 AuthUser.roleCode 노출 필요(후속) | | 2026-07-12 | **3차 웨이브(백로그 3트랙) + 배포 크래시 근본수정** — ①크래시 규명: c6f3a8d jar가 MyBatis `<script>` XML 위반(AuctionMapper `deadline < now()`·VisitorMapper `<=`)으로 부팅 크래시 루프→롤백. 매퍼 교정 + `MapperAnnotationParseTest` 회귀 가드 + `checkin.css` 누락 작성(서버 프론트 빌드 실패 원인) ②M16 BI 전역: `GET /api/analytics/overview`(행사 간 매출·홀 가동률 추이·리텐션·선형회귀 예측, 홀매니저 전용, 마이그레이션 無) + /analytics 인라인 섹션 ③M18 관리자: 사용자·역할/권한·공통코드·메뉴 CRUD 화면 4종(백엔드 system 라이브 소비, 신규 백엔드 0)+`AdminGuard`(전 /admin/* 라우트 가드 배선)+랜딩 모듈 바로가기. A8 룰셋은 기왕 실배선 확인 ④SCR-15 일정: V23(category·est_visitors 백필 1,098건)+홀 가동률/캘린더 API 2종+실 전환 ⑤QA minor: ✓글리프 SVG화(kx-field·★는 기왕 해소). 검증: compileJava·test(매퍼 파싱 포함)·tsc -b --force·vite build 전부 EXIT 0. ⚠AdminGuard 판정 한계: 전역 role_code 미노출로 hallManager 기준 — 정확 판정은 AuthUser.roleCode 노출 필요(후속) |
| 2026-07-12 | **도메인 2차 웨이브(중단분 복구·마감)** — 중단됐던 미커밋 웨이브 완성: ①M15 옥션 실배선 고도화(`screens/auction/auctionApi.ts` 정본 + 스코어링·견적계산 단위테스트) ②M10 체크인 데스크(SCR-31, `/visitors/checkin` 라우트·네비 신규 배선, QR 토큰·checkin_at·리드 exhibitor_id/동의/메모 확장, 체크인/검색/통계/배지재발급/리드생성/CSV export API — 마스킹·RBAC·@Audited) + 룰기반 리드 스코어링 엔진(`visitor/scoring`) ③M17 CMS 버전 이력·미디어 라이브러리·공개 CMS API(`/api/public/cms`)·HtmlSanitizer ④단위테스트 7종. **★Flyway 수복: 이미 배포된 V17/V19에 append돼 있던 확장 SQL을 V21로 분리·원복**(체크섬 불일치 배포실패 예방). 검증: compileJava·test·tsc -b 전부 EXIT 0. ⚠고아 초안 `src/api/auction{Api,Types}.ts` 2파일은 화면 미사용(정본은 screens/auction 로컬) — 삭제는 소유자 확인 필요 규칙상 **미커밋·보류** | | 2026-07-12 | **도메인 2차 웨이브(중단분 복구·마감)** — 중단됐던 미커밋 웨이브 완성: ①M15 옥션 실배선 고도화(`screens/auction/auctionApi.ts` 정본 + 스코어링·견적계산 단위테스트) ②M10 체크인 데스크(SCR-31, `/visitors/checkin` 라우트·네비 신규 배선, QR 토큰·checkin_at·리드 exhibitor_id/동의/메모 확장, 체크인/검색/통계/배지재발급/리드생성/CSV export API — 마스킹·RBAC·@Audited) + 룰기반 리드 스코어링 엔진(`visitor/scoring`) ③M17 CMS 버전 이력·미디어 라이브러리·공개 CMS API(`/api/public/cms`)·HtmlSanitizer ④단위테스트 7종. **★Flyway 수복: 이미 배포된 V17/V19에 append돼 있던 확장 SQL을 V21로 분리·원복**(체크섬 불일치 배포실패 예방). 검증: compileJava·test·tsc -b 전부 EXIT 0. ⚠고아 초안 `src/api/auction{Api,Types}.ts` 2파일은 화면 미사용(정본은 screens/auction 로컬) — 삭제는 소유자 확인 필요 규칙상 **미커밋·보류** |
| 2026-07-12 | **배포·핫픽스 3건 + 풀 E2E 라이브 검증 완료** — 1차 push는 서버 `tsc -b` 실패로 프론트 미배포(로컬 `--noEmit`과 설정 차이): ①toast 미렌더+TS6133·recharts formatter 타입 수정(8509787) ②레거시 `/api/auth/login`이 잠금·OTP 강제를 우회하던 보안 구멍 → secureLogin 위임(a657f95) ③**PG 별칭 소문자 폴딩으로 Map 매퍼 22파일·191별칭 전부 런타임 null이던 결함 → `AS "alias"` 일괄 교정(89bf2ce)** — login-slides imageUrl null(01:08부터)·secure 로그인 전멸이 이것. E2E 확인: secure 로그인 admin=OTP_ENROLL+챌린지(시크릿 미누출)·레거시=401 차단·visitor 가입→로그인→카탈로그 78건 실데이터·admin API 403 RBAC·Flyway V12/V13 적용·신규 번들 라이브. design.md v2.1.1(Stitch 84화면 상태 정합·designer 경유) | | 2026-07-12 | **배포·핫픽스 3건 + 풀 E2E 라이브 검증 완료** — 1차 push는 서버 `tsc -b` 실패로 프론트 미배포(로컬 `--noEmit`과 설정 차이): ①toast 미렌더+TS6133·recharts formatter 타입 수정(8509787) ②레거시 `/api/auth/login`이 잠금·OTP 강제를 우회하던 보안 구멍 → secureLogin 위임(a657f95) ③**PG 별칭 소문자 폴딩으로 Map 매퍼 22파일·191별칭 전부 런타임 null이던 결함 → `AS "alias"` 일괄 교정(89bf2ce)** — login-slides imageUrl null(01:08부터)·secure 로그인 전멸이 이것. E2E 확인: secure 로그인 admin=OTP_ENROLL+챌린지(시크릿 미누출)·레거시=401 차단·visitor 가입→로그인→카탈로그 78건 실데이터·admin API 403 RBAC·Flyway V12/V13 적용·신규 번들 라이브. design.md v2.1.1(Stitch 84화면 상태 정합·designer 경유) |

View File

@ -65,11 +65,13 @@ public class AuthServiceImpl implements AuthService {
} }
// 멤버십 반영 갱신된 역할로 토큰 재발급(현재 역할 기준 재조립). // 멤버십 반영 갱신된 역할로 토큰 재발급(현재 역할 기준 재조립).
// 초대코드행사/부스/역할 매핑(invite 테이블) db-engineer 인수 대기 매핑 매퍼 제공 역할 upsert 배선. // 초대코드행사/부스/역할 매핑(invite 테이블) db-engineer 인수 대기 매핑 매퍼 제공 역할 upsert 배선.
return buildLoginResponse(principal.userId(), principal.displayName(), principal.hallManager()); return buildLoginResponse(principal.userId(), principal.displayName(), principal.hallManager(),
principal.roleCode());
} }
/** 사용자 역할을 조회해 토큰 + 워크스페이스 카드를 조립한다. */ /** 사용자 역할을 조회해 토큰 + 워크스페이스 카드를 조립한다. globalRoleCode=app_user.role_code(null 허용). */
private LoginResponse buildLoginResponse(String userId, String displayName, boolean hallManager) { private LoginResponse buildLoginResponse(String userId, String displayName, boolean hallManager,
String globalRoleCode) {
List<Map<String, Object>> roleRows = userMapper.findEventRoles(userId); List<Map<String, Object>> roleRows = userMapper.findEventRoles(userId);
Map<String, EventRole> eventRoles = new LinkedHashMap<>(); Map<String, EventRole> eventRoles = new LinkedHashMap<>();
if (roleRows != null) { if (roleRows != null) {
@ -81,10 +83,10 @@ public class AuthServiceImpl implements AuthService {
} }
} }
} }
String token = jwtService.issue(userId, displayName, eventRoles, hallManager); String token = jwtService.issue(userId, displayName, eventRoles, hallManager, globalRoleCode);
List<WorkspaceDto> workspaces = toWorkspaces(roleRows); List<WorkspaceDto> workspaces = toWorkspaces(roleRows);
return new LoginResponse(token, jwtService.ttlSeconds(), return new LoginResponse(token, jwtService.ttlSeconds(),
new LoginResponse.UserSummary(userId, displayName, hallManager), workspaces); new LoginResponse.UserSummary(userId, displayName, hallManager, globalRoleCode), workspaces);
} }
private List<WorkspaceDto> loadWorkspaces(String userId) { private List<WorkspaceDto> loadWorkspaces(String userId) {

View File

@ -31,25 +31,35 @@ public class JwtService {
this.ttlSeconds = props.getAccessTokenTtlSeconds(); this.ttlSeconds = props.getAccessTokenTtlSeconds();
} }
/** 액세스 토큰 발급. eventRoles: 행사ID→역할. */ /** 액세스 토큰 발급. eventRoles: 행사ID→역할. roleCode: 전역 역할(app_user.role_code, null 허용). */
public String issue(String userId, String displayName, public String issue(String userId, String displayName,
Map<String, EventRole> eventRoles, boolean hallManager) { Map<String, EventRole> eventRoles, boolean hallManager, String roleCode) {
Instant now = Instant.now(); Instant now = Instant.now();
Map<String, String> rolesClaim = new LinkedHashMap<>(); Map<String, String> rolesClaim = new LinkedHashMap<>();
if (eventRoles != null) { if (eventRoles != null) {
eventRoles.forEach((eid, role) -> rolesClaim.put(eid, role.name())); eventRoles.forEach((eid, role) -> rolesClaim.put(eid, role.name()));
} }
return Jwts.builder() var builder = Jwts.builder()
.subject(userId) .subject(userId)
.claim("name", displayName) .claim("name", displayName)
.claim("roles", rolesClaim) .claim("roles", rolesClaim)
.claim("hm", hallManager) .claim("hm", hallManager);
if (roleCode != null && !roleCode.isBlank()) {
builder.claim("rc", roleCode); // 순증 클레임 /me 전역 역할 복원용
}
return builder
.issuedAt(Date.from(now)) .issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(ttlSeconds))) .expiration(Date.from(now.plusSeconds(ttlSeconds)))
.signWith(key) .signWith(key)
.compact(); .compact();
} }
/** 하위호환 오버로드 — 전역 역할 미상 경로용(roleCode=null). */
public String issue(String userId, String displayName,
Map<String, EventRole> eventRoles, boolean hallManager) {
return issue(userId, displayName, eventRoles, hallManager, null);
}
/** 토큰 검증 후 principal 복원. 실패 시 401. */ /** 토큰 검증 후 principal 복원. 실패 시 401. */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public KintexPrincipal verify(String token) { public KintexPrincipal verify(String token) {
@ -62,7 +72,8 @@ public class JwtService {
m.forEach((k, v) -> roles.put(String.valueOf(k), EventRole.valueOf(String.valueOf(v)))); m.forEach((k, v) -> roles.put(String.valueOf(k), EventRole.valueOf(String.valueOf(v))));
} }
boolean hm = Boolean.TRUE.equals(c.get("hm", Boolean.class)); boolean hm = Boolean.TRUE.equals(c.get("hm", Boolean.class));
return new KintexPrincipal(c.getSubject(), c.get("name", String.class), roles, hm); String roleCode = c.get("rc", String.class);
return new KintexPrincipal(c.getSubject(), c.get("name", String.class), roles, hm, roleCode);
} catch (JwtException | IllegalArgumentException e) { } catch (JwtException | IllegalArgumentException e) {
throw new ApiException(ErrorCode.UNAUTHORIZED, "토큰이 유효하지 않거나 만료되었습니다."); throw new ApiException(ErrorCode.UNAUTHORIZED, "토큰이 유효하지 않거나 만료되었습니다.");
} }

View File

@ -9,14 +9,22 @@ import java.util.Map;
* @param displayName 표시 이름 * @param displayName 표시 이름
* @param eventRoles 행사ID 역할 (행사 단위 RBAC). 홀매니저는 전체 열람이므로 별도 플래그. * @param eventRoles 행사ID 역할 (행사 단위 RBAC). 홀매니저는 전체 열람이므로 별도 플래그.
* @param hallManager 킨텍스 내부 홀매니저 여부(전체 행사 열람+승인) * @param hallManager 킨텍스 내부 홀매니저 여부(전체 행사 열람+승인)
* @param roleCode 전역 역할(app_user.role_code ADMIN/MANAGER/, 없으면 null). 순증 필드(하위호환).
*/ */
public record KintexPrincipal( public record KintexPrincipal(
String userId, String userId,
String displayName, String displayName,
Map<String, EventRole> eventRoles, Map<String, EventRole> eventRoles,
boolean hallManager boolean hallManager,
String roleCode
) { ) {
/** 하위호환 보조 생성자 — roleCode 미상(전역 역할 정보 없음) 경로용. 기존 4-인자 호출 유지. */
public KintexPrincipal(String userId, String displayName,
Map<String, EventRole> eventRoles, boolean hallManager) {
this(userId, displayName, eventRoles, hallManager, null);
}
/** 해당 행사에서 사용자의 역할(없으면 null). 홀매니저는 항상 HALL_MANAGER로 간주. */ /** 해당 행사에서 사용자의 역할(없으면 null). 홀매니저는 항상 HALL_MANAGER로 간주. */
public EventRole roleFor(String eventId) { public EventRole roleFor(String eventId) {
if (hallManager) { if (hallManager) {

View File

@ -12,6 +12,14 @@ public record LoginResponse(
UserSummary user, UserSummary user,
List<WorkspaceDto> workspaces List<WorkspaceDto> workspaces
) { ) {
public record UserSummary(String userId, String displayName, boolean hallManager) { /**
* 로그인 사용자 요약. roleCode = app_user.role_code(전역 역할 ADMIN/MANAGER/, 없으면 null) 순증 필드,
* 기존 필드(userId·displayName·hallManager) 불변(하위호환). 비밀번호 해시·시크릿은 절대 미포함.
*/
public record UserSummary(String userId, String displayName, boolean hallManager, String roleCode) {
/** 하위호환 보조 생성자 — roleCode 미상 경로(예: 초대 수락 재발급)는 null. */
public UserSummary(String userId, String displayName, boolean hallManager) {
this(userId, displayName, hallManager, null);
}
} }
} }

View File

@ -0,0 +1,23 @@
package com.zioinfo.kintex.hallassign;
import com.zioinfo.kintex.hallassign.dto.ConflictDto;
import java.util.List;
/**
* ·기간 겹침 충돌 컨트롤러가 로컬에서 잡아 409 + 충돌 행사(data) 반환한다.
* (표준 {@code ApiException} code+message만 전달하므로, 충돌 행사 목록을 실어 나르기 위한 전용 예외.)
*/
public class HallAssignConflictException extends RuntimeException {
private final transient List<ConflictDto> conflicts;
public HallAssignConflictException(List<ConflictDto> conflicts) {
super("배정하려는 홀·기간이 다른 행사와 겹칩니다.");
this.conflicts = conflicts;
}
public List<ConflictDto> conflicts() {
return conflicts;
}
}

View File

@ -0,0 +1,124 @@
package com.zioinfo.kintex.hallassign;
import com.zioinfo.kintex.auth.EventAccessGuard;
import com.zioinfo.kintex.auth.KintexPrincipal;
import com.zioinfo.kintex.common.ApiResponse;
import com.zioinfo.kintex.common.ApiResponse.ApiError;
import com.zioinfo.kintex.common.audit.Audited;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.hallassign.dto.AssignmentRequest;
import com.zioinfo.kintex.hallassign.dto.ConflictDto;
import com.zioinfo.kintex.hallassign.dto.HallAssignmentDto;
import com.zioinfo.kintex.hallassign.dto.HallDto;
import com.zioinfo.kintex.hallassign.dto.QuoteRequest;
import com.zioinfo.kintex.hallassign.dto.QuoteResult;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* M1 배정·자동 견적 API (SCR /halls).
* <ul>
* <li>조회(·배정·충돌·견적): 로그인 필수.</li>
* <li>배정 변경(생성·해제): 홀매니저(킨텍스 내부) + {@link Audited}.</li>
* </ul>
* 충돌 POST 배정은 409 + 충돌 행사 목록(data) 반환한다(계약).
*/
@RestController
@RequestMapping("/api/halls")
public class HallAssignController {
private final HallAssignService service;
private final EventAccessGuard guard;
public HallAssignController(HallAssignService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** GET /api/halls — 홀 마스터 전체(배정 보드 행·견적 근거). */
@GetMapping
public ApiResponse<List<HallDto>> halls(@AuthenticationPrincipal KintexPrincipal principal) {
guard.require(principal);
return ApiResponse.ok(service.halls());
}
/** GET /api/halls/assignments?from=&to=&eventId= — 배정 보드 목록(기간 겹침). 홀매니저 전용. */
@GetMapping("/assignments")
public ApiResponse<List<HallAssignmentDto>> assignments(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam(required = false) String from,
@RequestParam(required = false) String to,
@RequestParam(required = false) String eventId) {
requireHallManager(principal);
return ApiResponse.ok(service.assignments(from, to, eventId));
}
/** GET /api/halls/conflicts?hallId=&from=&to=&excludeEventId= — 홀·기간 충돌 사전검사(200). 홀매니저 전용. */
@GetMapping("/conflicts")
public ApiResponse<List<ConflictDto>> conflicts(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam String hallId,
@RequestParam String from,
@RequestParam String to,
@RequestParam(required = false) String excludeEventId) {
requireHallManager(principal);
return ApiResponse.ok(service.conflicts(hallId, from, to, excludeEventId));
}
/**
* POST /api/halls/assignments 배정 생성. 홀매니저 전용 + 감사.
* 겹침 충돌 409 + {@code { conflicts: [...] }} 반환(계약).
*/
@PostMapping("/assignments")
@Audited(action = "HALL_ASSIGN_CREATE", targetType = "hall_assignment")
public ResponseEntity<ApiResponse<Object>> assign(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody AssignmentRequest req) {
requireHallManager(principal);
try {
HallAssignmentDto created = service.assign(req);
return ResponseEntity.ok(ApiResponse.ok(created));
} catch (HallAssignConflictException ex) {
Object body = Map.of("conflicts", ex.conflicts());
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ApiResponse<>(false, body, new ApiError(ErrorCode.CONFLICT.name(), ex.getMessage())));
}
}
/** DELETE /api/halls/assignments/{id} — 홀 배정 해제. 홀매니저 전용 + 감사. */
@DeleteMapping("/assignments/{id}")
@Audited(action = "HALL_ASSIGN_RELEASE", targetType = "hall_assignment")
public ApiResponse<Void> release(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
requireHallManager(principal);
service.release(id);
return ApiResponse.ok(null);
}
/** POST /api/halls/quote — 자동 견적(요율 룰셋 rates-v1.0). 로그인 필수(참가/주최자 셀프 견적 허용). */
@PostMapping("/quote")
public ApiResponse<QuoteResult> quote(@AuthenticationPrincipal KintexPrincipal principal,
@Valid @RequestBody QuoteRequest req) {
guard.require(principal);
return ApiResponse.ok(service.quote(req));
}
/** 홀 배정 변경 권한 — 킨텍스 내부 홀매니저만(§2 최종 배정 확정은 킨텍스 내부 의사결정). */
private void requireHallManager(KintexPrincipal principal) {
guard.require(principal);
if (!principal.hallManager()) {
throw new ApiException(ErrorCode.FORBIDDEN, "홀 배정은 킨텍스 홀매니저만 변경할 수 있습니다.");
}
}
}

View File

@ -0,0 +1,147 @@
package com.zioinfo.kintex.hallassign;
import org.apache.ibatis.annotations.Delete;
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;
/**
* M1 배정·자동 견적 매퍼 hall(마스터)·hall_assignment(배정)·event(개최기간).
* camelCase 별칭은 쌍따옴표(PG 소문자 폴딩 함정). 기간 겹침은 [start<=to AND end>=from] 폐구간.
* 선택 파라미터(null) {@code COALESCE(#{p}, 대체)} 흡수해 PG 파라미터 타입 미결정 오류를 회피한다.
*/
@Mapper
public interface HallAssignMapper {
/** 홀 마스터 전체(배정 보드 행·견적 근거). */
@Select("""
SELECT id,
exhibition_center AS "exhibitionCenter",
label,
area_m2 AS "areaM2",
floor_type AS "floorType",
booth_capacity AS "boothCapacity"
FROM hall
ORDER BY exhibition_center, id
""")
List<Map<String, Object>> findHalls();
/** 단일 홀 마스터(견적 계산용). 없으면 null. */
@Select("""
SELECT id,
exhibition_center AS "exhibitionCenter",
label,
area_m2 AS "areaM2",
floor_type AS "floorType",
booth_capacity AS "boothCapacity"
FROM hall
WHERE id = #{hallId}
""")
Map<String, Object> findHallById(@Param("hallId") String hallId);
/** 행사 개최기간(점유기간 미지정 시 보완). 없으면 null. */
@Select("""
SELECT id,
name,
to_char(start_date, 'YYYY-MM-DD') AS "startDate",
to_char(end_date, 'YYYY-MM-DD') AS "endDate"
FROM event
WHERE id = #{eventId}
""")
Map<String, Object> findEvent(@Param("eventId") String eventId);
/**
* 배정 보드 목록 [from, to] 기간과 점유가 겹치는 배정(·기간 매트릭스).
* eventId 지정 해당 행사만.
*/
@Select("""
SELECT ha.id,
ha.event_id AS "eventId",
e.name AS "eventName",
ha.hall_id AS "hallId",
h.label AS "hallLabel",
ha.is_primary AS "primary",
to_char(ha.occupy_start, 'YYYY-MM-DD') AS "occupyStart",
to_char(ha.occupy_end, 'YYYY-MM-DD') AS "occupyEnd",
ha.status,
ha.note
FROM hall_assignment ha
JOIN event e ON e.id = ha.event_id
JOIN hall h ON h.id = ha.hall_id
WHERE ha.occupy_start IS NOT NULL
AND ha.occupy_start <= CAST(#{to} AS date)
AND ha.occupy_end >= CAST(#{from} AS date)
AND ha.event_id = COALESCE(CAST(#{eventId} AS varchar), ha.event_id)
ORDER BY ha.hall_id, ha.occupy_start
""")
List<Map<String, Object>> findAssignments(@Param("from") String from,
@Param("to") String to,
@Param("eventId") String eventId);
/** 단일 배정 조회(생성 직후 응답·삭제 전 확인). 없으면 null. */
@Select("""
SELECT ha.id,
ha.event_id AS "eventId",
e.name AS "eventName",
ha.hall_id AS "hallId",
h.label AS "hallLabel",
ha.is_primary AS "primary",
to_char(ha.occupy_start, 'YYYY-MM-DD') AS "occupyStart",
to_char(ha.occupy_end, 'YYYY-MM-DD') AS "occupyEnd",
ha.status,
ha.note
FROM hall_assignment ha
JOIN event e ON e.id = ha.event_id
JOIN hall h ON h.id = ha.hall_id
WHERE ha.id = #{id}
""")
Map<String, Object> findAssignmentById(@Param("id") String id);
/**
* ·기간 겹침 충돌(같은 홀에서 [from, to] 폐구간과 겹치는 기존 배정).
* excludeEventId/excludeAssignmentId(null 가능) 자기 자신·재배정 대상 제외.
*/
@Select("""
SELECT ha.id AS "assignmentId",
ha.event_id AS "eventId",
e.name AS "eventName",
ha.hall_id AS "hallId",
h.label AS "hallLabel",
to_char(ha.occupy_start, 'YYYY-MM-DD') AS "occupyStart",
to_char(ha.occupy_end, 'YYYY-MM-DD') AS "occupyEnd"
FROM hall_assignment ha
JOIN event e ON e.id = ha.event_id
JOIN hall h ON h.id = ha.hall_id
WHERE ha.hall_id = #{hallId}
AND ha.occupy_start IS NOT NULL
AND ha.occupy_start <= CAST(#{to} AS date)
AND ha.occupy_end >= CAST(#{from} AS date)
AND ha.event_id <> COALESCE(CAST(#{excludeEventId} AS varchar), '')
AND ha.id <> COALESCE(CAST(#{excludeAssignmentId} AS varchar), '')
ORDER BY ha.occupy_start
""")
List<Map<String, Object>> findConflicts(@Param("hallId") String hallId,
@Param("from") String from,
@Param("to") String to,
@Param("excludeEventId") String excludeEventId,
@Param("excludeAssignmentId") String excludeAssignmentId);
/** 배정 생성. 점유기간·상태·비고 포함. */
@Insert("""
INSERT INTO hall_assignment
(id, event_id, hall_id, is_primary, occupy_start, occupy_end, status, note, created_at, updated_at)
VALUES
(#{id}, #{eventId}, #{hallId}, #{primary},
CAST(#{occupyStart} AS date), CAST(#{occupyEnd} AS date),
'assigned', #{note}, now(), now())
""")
int insertAssignment(Map<String, Object> p);
/** 배정 해제(삭제). 삭제 행 수 반환. */
@Delete("DELETE FROM hall_assignment WHERE id = #{id}")
int deleteAssignment(@Param("id") String id);
}

View File

@ -0,0 +1,361 @@
package com.zioinfo.kintex.hallassign;
import com.zioinfo.kintex.common.error.ApiException;
import com.zioinfo.kintex.common.error.ErrorCode;
import com.zioinfo.kintex.hallassign.dto.AssignmentRequest;
import com.zioinfo.kintex.hallassign.dto.ConflictDto;
import com.zioinfo.kintex.hallassign.dto.HallAssignmentDto;
import com.zioinfo.kintex.hallassign.dto.HallDto;
import com.zioinfo.kintex.hallassign.dto.QuoteLine;
import com.zioinfo.kintex.hallassign.dto.QuoteRequest;
import com.zioinfo.kintex.hallassign.dto.QuoteResult;
import com.zioinfo.kintex.rules.RateRuleSet;
import com.zioinfo.kintex.rules.RuleSetLoader;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* M1 배정·자동 견적 서비스.
* <ul>
* <li>배정 CRUD + ·기간 겹침 충돌 검사(같은 ·기간 겹침 충돌 행사 반환).</li>
* <li>자동 견적: 요율 룰셋(rates-v1.0) 기반 라인 계산 서버 계산 권위.</li>
* </ul>
*/
@Service
public class HallAssignService {
private final HallAssignMapper mapper;
private final RuleSetLoader ruleSets;
public HallAssignService(HallAssignMapper mapper, RuleSetLoader ruleSets) {
this.mapper = mapper;
this.ruleSets = ruleSets;
}
// 마스터
public List<HallDto> halls() {
List<HallDto> out = new ArrayList<>();
for (Map<String, Object> r : mapper.findHalls()) {
out.add(toHall(r));
}
return out;
}
// 배정 조회
public List<HallAssignmentDto> assignments(String from, String to, String eventId) {
DateRange range = defaultRange(from, to);
List<HallAssignmentDto> out = new ArrayList<>();
for (Map<String, Object> r : mapper.findAssignments(range.from, range.to, blankToNull(eventId))) {
out.add(toAssignment(r));
}
return out;
}
/** 홀·기간 충돌 사전검사(200) — 보드 표시·배정 전 검증. */
public List<ConflictDto> conflicts(String hallId, String from, String to, String excludeEventId) {
requireDate(from, "from");
requireDate(to, "to");
List<ConflictDto> out = new ArrayList<>();
for (Map<String, Object> r : mapper.findConflicts(hallId, from, to, blankToNull(excludeEventId), null)) {
out.add(toConflict(r));
}
return out;
}
// 배정 생성/해제
/** 겹침 없으면 배정 생성 후 dto 반환. */
@Transactional
public HallAssignmentDto assign(AssignmentRequest req) {
Map<String, Object> hall = mapper.findHallById(req.hallId());
if (hall == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "홀을 찾을 수 없습니다.");
}
Map<String, Object> event = mapper.findEvent(req.eventId());
if (event == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "행사를 찾을 수 없습니다.");
}
// 점유기간 결정: 요청 우선, 없으면 행사 개최기간으로 보완.
String start = firstNonBlank(req.occupyStart(), str(event.get("startDate")));
String end = firstNonBlank(req.occupyEnd(), str(event.get("endDate")));
if (start == null || end == null) {
throw new ApiException(ErrorCode.VALIDATION, "점유기간(occupyStart~occupyEnd)이 필요합니다.");
}
requireOrder(start, end);
List<ConflictDto> conflicts = conflicts(req.hallId(), start, end, req.eventId());
if (!conflicts.isEmpty()) {
throw new HallAssignConflictException(conflicts);
}
Map<String, Object> p = new HashMap<>();
String id = "ha-" + UUID.randomUUID().toString().substring(0, 12);
p.put("id", id);
p.put("eventId", req.eventId());
p.put("hallId", req.hallId());
p.put("primary", req.primary());
p.put("occupyStart", start);
p.put("occupyEnd", end);
p.put("note", req.note());
mapper.insertAssignment(p);
return toAssignment(mapper.findAssignmentById(id));
}
/** 배정 해제. 없으면 404. */
@Transactional
public void release(String assignmentId) {
Map<String, Object> existing = mapper.findAssignmentById(assignmentId);
if (existing == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "배정을 찾을 수 없습니다.");
}
mapper.deleteAssignment(assignmentId);
}
// 자동 견적
/** 요율 룰셋(rates-v1.0) 기반 견적. 라인 내역·임대료 합계·관리비 예치금 범위 포함. */
public QuoteResult quote(QuoteRequest req) {
RateRuleSet rates = ruleSets.rates();
if (rates == null || rates.rental() == null) {
throw new ApiException(ErrorCode.INTERNAL, "요율 룰셋이 로드되지 않았습니다.");
}
Map<String, Object> rental = rates.rental();
LocalDate start = parseDate(req.startDate(), "startDate");
LocalDate end = parseDate(req.endDate(), "endDate");
if (end.isBefore(start)) {
throw new ApiException(ErrorCode.VALIDATION, "종료일이 시작일보다 빠릅니다.");
}
int days = (int) ChronoUnit.DAYS.between(start, end) + 1; // 개최일 포함(폐구간).
double exhibitionPerM2 = dbl(rental, "exhibitionHallPerM2", 2250);
double lobbyPerM2 = dbl(rental, "lobbyPerM2", 10000);
double outdoorPerM2 = dbl(rental, "outdoorPerM2", 2000);
double eventHallPerM2 = dbl(rental, "eventHallPerM2", 2420);
double peakRatio = dbl(rental, "peakSurchargeRatio", 0.10);
double offPeakRatio = dbl(rental, "offPeakDiscountRatio", -0.10);
double hall1Ratio = dbl(rental, "hall1SurchargeRatio", 0.10);
double depositMinRatio = dbl(rental, "depositRatioMin", 0.15);
double depositMaxRatio = dbl(rental, "depositRatioMax", 0.20);
List<Integer> peakMonths = intList(rental.get("peakMonths"));
List<Integer> offPeakMonths = intList(rental.get("offPeakMonths"));
List<QuoteLine> lines = new ArrayList<>();
BigDecimal rentalSubtotal = BigDecimal.ZERO;
BigDecimal hall1Surcharge = BigDecimal.ZERO;
int totalCapacity = 0;
for (String hallId : req.hallIds()) {
Map<String, Object> hall = mapper.findHallById(hallId);
if (hall == null) {
throw new ApiException(ErrorCode.NOT_FOUND, "홀을 찾을 수 없습니다: " + hallId);
}
BigDecimal area = num(hall.get("areaM2"));
String floorType = str(hall.get("floorType"));
String label = str(hall.get("label"));
Integer capacity = hall.get("boothCapacity") == null ? null
: ((Number) hall.get("boothCapacity")).intValue();
if (capacity != null) {
totalCapacity += capacity;
}
double perM2 = ratePerM2(floorType, exhibitionPerM2, lobbyPerM2, outdoorPerM2, eventHallPerM2);
BigDecimal base = area.multiply(BigDecimal.valueOf(perM2)).multiply(BigDecimal.valueOf(days));
rentalSubtotal = rentalSubtotal.add(base);
lines.add(new QuoteLine(
"RENTAL",
label + " 임대료",
String.format("면적 %s㎡ × %,.0f원/㎡ × %d일", strip(area), perM2, days),
round(base)));
// 제1전시장 홀1(H1) 프리미엄 할증(룰셋 hall1SurchargeRatio) 해당 홀에만.
if ("H1".equalsIgnoreCase(hallId) && hall1Ratio != 0) {
hall1Surcharge = hall1Surcharge.add(base.multiply(BigDecimal.valueOf(hall1Ratio)));
}
}
// 성수기 할증 / 비수기 할인(개최 시작월 기준, 단일 적용).
BigDecimal seasonAdj = BigDecimal.ZERO;
int month = start.getMonthValue();
if (peakMonths.contains(month) && peakRatio != 0) {
seasonAdj = rentalSubtotal.multiply(BigDecimal.valueOf(peakRatio));
lines.add(new QuoteLine("PEAK_SURCHARGE", "성수기 할증",
String.format("임대료 소계 × %+.0f%%", peakRatio * 100), round(seasonAdj)));
} else if (offPeakMonths.contains(month) && offPeakRatio != 0) {
seasonAdj = rentalSubtotal.multiply(BigDecimal.valueOf(offPeakRatio));
lines.add(new QuoteLine("OFFPEAK_DISCOUNT", "비수기 할인",
String.format("임대료 소계 × %+.0f%%", offPeakRatio * 100), round(seasonAdj)));
}
if (hall1Surcharge.signum() != 0) {
lines.add(new QuoteLine("HALL1_SURCHARGE", "제1전시장 홀1 할증",
String.format("홀1 임대료 × %+.0f%%", hall1Ratio * 100), round(hall1Surcharge)));
}
BigDecimal total = rentalSubtotal.add(seasonAdj).add(hall1Surcharge);
long depositMin = round(total.multiply(BigDecimal.valueOf(depositMinRatio)));
long depositMax = round(total.multiply(BigDecimal.valueOf(depositMaxRatio)));
lines.add(new QuoteLine("MGMT_DEPOSIT", "관리비 예치금(임대료의 15~20%)",
String.format("임대료 합계의 %.0f~%.0f%% (별도 예치)", depositMinRatio * 100, depositMaxRatio * 100),
depositMin));
String capacityNote = null;
if (req.boothCount() != null && totalCapacity > 0 && req.boothCount() > totalCapacity) {
capacityNote = String.format("요청 부스 %d개가 홀 표준 수용 %d개를 초과합니다(배치 재검토 필요).",
req.boothCount(), totalCapacity);
}
return new QuoteResult(
rates.rulesetVersion(),
rates.currency() == null ? "KRW" : rates.currency(),
days,
lines,
round(rentalSubtotal),
round(total),
depositMin,
depositMax,
capacityNote,
rates.disclaimer());
}
// 매핑 헬퍼
private HallDto toHall(Map<String, Object> r) {
return new HallDto(
str(r.get("id")),
((Number) r.get("exhibitionCenter")).intValue(),
str(r.get("label")),
num(r.get("areaM2")),
str(r.get("floorType")),
r.get("boothCapacity") == null ? null : ((Number) r.get("boothCapacity")).intValue());
}
private HallAssignmentDto toAssignment(Map<String, Object> r) {
return new HallAssignmentDto(
str(r.get("id")),
str(r.get("eventId")),
str(r.get("eventName")),
str(r.get("hallId")),
str(r.get("hallLabel")),
Boolean.TRUE.equals(r.get("primary")),
str(r.get("occupyStart")),
str(r.get("occupyEnd")),
str(r.get("status")),
str(r.get("note")));
}
private ConflictDto toConflict(Map<String, Object> r) {
return new ConflictDto(
str(r.get("assignmentId")),
str(r.get("eventId")),
str(r.get("eventName")),
str(r.get("hallId")),
str(r.get("hallLabel")),
str(r.get("occupyStart")),
str(r.get("occupyEnd")));
}
private double ratePerM2(String floorType, double exhibition, double lobby, double outdoor, double eventHall) {
if (floorType == null) {
return exhibition;
}
return switch (floorType) {
case "outdoor" -> outdoor;
case "carpet" -> eventHall; // 홀6(이벤트홀) 카펫.
default -> exhibition;
};
}
// 변환/검증 유틸
private static String str(Object o) {
return o == null ? null : o.toString();
}
private static BigDecimal num(Object o) {
if (o == null) {
return BigDecimal.ZERO;
}
if (o instanceof BigDecimal b) {
return b;
}
return new BigDecimal(o.toString());
}
private static long round(BigDecimal v) {
return v.setScale(0, RoundingMode.HALF_UP).longValueExact();
}
private static String strip(BigDecimal v) {
return v.stripTrailingZeros().toPlainString();
}
private static double dbl(Map<String, Object> m, String key, double dflt) {
Object o = m.get(key);
return o instanceof Number n ? n.doubleValue() : dflt;
}
@SuppressWarnings("unchecked")
private static List<Integer> intList(Object o) {
List<Integer> out = new ArrayList<>();
if (o instanceof List<?> l) {
for (Object e : l) {
if (e instanceof Number n) {
out.add(n.intValue());
}
}
}
return out;
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String firstNonBlank(String a, String b) {
if (a != null && !a.isBlank()) {
return a;
}
return (b == null || b.isBlank()) ? null : b;
}
private static LocalDate parseDate(String s, String field) {
try {
return LocalDate.parse(s);
} catch (DateTimeParseException e) {
throw new ApiException(ErrorCode.VALIDATION, field + " 날짜 형식이 올바르지 않습니다(YYYY-MM-DD).");
}
}
private static void requireDate(String s, String field) {
if (s == null || s.isBlank()) {
throw new ApiException(ErrorCode.VALIDATION, field + "(YYYY-MM-DD)이 필요합니다.");
}
parseDate(s, field);
}
private static void requireOrder(String start, String end) {
LocalDate s = parseDate(start, "occupyStart");
LocalDate e = parseDate(end, "occupyEnd");
if (e.isBefore(s)) {
throw new ApiException(ErrorCode.VALIDATION, "점유 종료일이 시작일보다 빠릅니다.");
}
}
private record DateRange(String from, String to) {
}
private DateRange defaultRange(String from, String to) {
LocalDate f = (from == null || from.isBlank()) ? LocalDate.now().withDayOfYear(1) : parseDate(from, "from");
LocalDate t = (to == null || to.isBlank()) ? f.plusYears(1) : parseDate(to, "to");
return new DateRange(f.toString(), t.toString());
}
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.kintex.hallassign.dto;
import jakarta.validation.constraints.NotBlank;
/**
* 배정 생성 요청. 점유기간(occupyStart~occupyEnd) 겹침 충돌 검사의 기준이다.
* 점유기간 미지정 서버가 행사 개최기간으로 보완한다.
*/
public record AssignmentRequest(
@NotBlank String eventId,
@NotBlank String hallId,
boolean primary,
String occupyStart,
String occupyEnd,
String note
) {
}

View File

@ -0,0 +1,15 @@
package com.zioinfo.kintex.hallassign.dto;
/**
* ·기간 겹침 충돌 상대 409 응답 본문(충돌 행사) 사전검사(GET conflicts) 공용.
*/
public record ConflictDto(
String assignmentId,
String eventId,
String eventName,
String hallId,
String hallLabel,
String occupyStart,
String occupyEnd
) {
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.kintex.hallassign.dto;
/**
* 배정 배정 보드(×기간)·목록 조회 응답. event/hall 조인 사본.
*/
public record HallAssignmentDto(
String id,
String eventId,
String eventName,
String hallId,
String hallLabel,
boolean primary,
String occupyStart,
String occupyEnd,
String status,
String note
) {
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.kintex.hallassign.dto;
import java.math.BigDecimal;
/**
* 마스터 조회 DTO 배정 보드 ·견적 근거. hall 테이블(V2/V4) 사본.
*/
public record HallDto(
String id,
int exhibitionCenter,
String label,
BigDecimal areaM2,
String floorType,
Integer boothCapacity
) {
}

View File

@ -0,0 +1,12 @@
package com.zioinfo.kintex.hallassign.dto;
/**
* 견적 라인 항목 코드·라벨·산출근거·금액(KRW 정수). 음수 가능(할인).
*/
public record QuoteLine(
String code,
String label,
String basis,
long amount
) {
}

View File

@ -0,0 +1,17 @@
package com.zioinfo.kintex.hallassign.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/**
* 자동 견적 요청 ()·개최기간·부스 규모(선택). 계산은 전적으로 서버 권위(요율 룰셋 rates-v1.0).
*/
public record QuoteRequest(
@NotEmpty List<String> hallIds,
@NotBlank String startDate,
@NotBlank String endDate,
Integer boothCount
) {
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.kintex.hallassign.dto;
import java.util.List;
/**
* 자동 견적 결과 라인 내역 + 임대료 합계·관리비 예치금 범위. 최종 금액은 킨텍스 확정 안내(disclaimer).
*/
public record QuoteResult(
String rulesetVersion,
String currency,
int days,
List<QuoteLine> lines,
long rentalSubtotal,
long total,
long depositMin,
long depositMax,
String capacityNote,
String disclaimer
) {
}

View File

@ -8,19 +8,65 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 현장 운영 매퍼 hall_assignment·hall read-only(행사에 배정된 목록). * 현장 운영 매퍼 read-only 집계. 마이그레이션 불변(기존 테이블 집계만).
* 혼잡/HVAC/조명/주차 센서 원천이 없으므로 목록만 조회한다. 마이그레이션 불변. *
* <p>실집계원:
* <ul>
* <li>배정 + 판매면적(hall_assignment·hall·layout·booth ST_Area) 혼잡 분배 가중치</li>
* <li>전력 부하(utility_order.quote.requestedKw) 단위 신청 용량 </li>
* <li>체크인(visitor_registration.checkin_at) 현장 인원·시간대 추이</li>
* </ul>
* HVAC/조명/주차 센서 원천 없음 조회하지 않는다.
*/ */
@Mapper @Mapper
public interface OpsMapper { public interface OpsMapper {
/** 행사 배정 홀 목록(대표 홀 우선 정렬). */ /**
* 행사 배정 목록 + 판매면적(salesArea, ) + 신청 전력(powerKw, kW).
* powerKw 유틸리티 신청이 없으면 null. 대표 우선 정렬.
*/
@Select(""" @Select("""
SELECT h.id AS "hallId", h.label SELECT h.id AS "hallId", h.label AS "label",
COALESCE(ar.sales_area, 0) AS "salesArea",
pw.req_kw AS "powerKw"
FROM hall_assignment ha FROM hall_assignment ha
JOIN hall h ON h.id = ha.hall_id JOIN hall h ON h.id = ha.hall_id
LEFT JOIN (
SELECT l.hall_id AS hall_id, sum(ST_Area(b.geom)) AS sales_area
FROM layout l JOIN booth b ON b.layout_id = l.id
WHERE l.event_id = #{eventId}
GROUP BY l.hall_id
) ar ON ar.hall_id = h.id
LEFT JOIN (
SELECT l.hall_id AS hall_id, sum((u.quote->>'requestedKw')::numeric) AS req_kw
FROM utility_order u
JOIN booth b ON b.id = u.booth_id
JOIN layout l ON l.id = b.layout_id
WHERE u.event_id = #{eventId}
AND u.quote->>'requestedKw' ~ '^[0-9]+(\\.[0-9]+)?$'
GROUP BY l.hall_id
) pw ON pw.hall_id = h.id
WHERE ha.event_id = #{eventId} WHERE ha.event_id = #{eventId}
ORDER BY ha.is_primary DESC, h.id ORDER BY ha.is_primary DESC, h.id
""") """)
List<Map<String, Object>> findAssignedHalls(@Param("eventId") String eventId); List<Map<String, Object>> findHallOps(@Param("eventId") String eventId);
/** 현장 체크인 완료 인원(checkin_at 기록) — 추정 현장 인원 근거. */
@Select("""
SELECT count(*)
FROM visitor_registration
WHERE event_id = #{eventId} AND checkin_at IS NOT NULL
""")
long countCheckedIn(@Param("eventId") String eventId);
/** 시간대(0~23시)별 체크인 집계 — 혼잡 추이(hour = 'HH:00'). */
@Select("""
SELECT lpad(extract(hour FROM checkin_at)::int::text, 2, '0') || ':00' AS "hour",
count(*) AS "count"
FROM visitor_registration
WHERE event_id = #{eventId} AND checkin_at IS NOT NULL
GROUP BY extract(hour FROM checkin_at)
ORDER BY extract(hour FROM checkin_at)
""")
List<Map<String, Object>> findCheckinSeries(@Param("eventId") String eventId);
} }

View File

@ -8,11 +8,19 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* 현장 운영 서비스 배정 목록만 조회. 혼잡/HVAC/조명/주차는 센서 부재 0/null/빈배열(정직). * 현장 운영 서비스.
*
* <p>실집계: 혼잡 = M10 체크인(현장 인원) 판매면적 비중으로 분배한 추정치 + 시간대 추이,
* 전력 = M4 유틸리티 신청 부하(requestedKw) 홀별 . HVAC/조명/주차는 센서·IoT 미연동 null/빈배열.
* 사람 계측·전력계 하드웨어 코드는 없다(표시 전용, PLANNING §2.6).
*/ */
@Service @Service
public class OpsService { public class OpsService {
/** 홀 인원 밀도(명/㎡) 혼잡 임계 — 전시 통로 기준 근사치. */
private static final double DENSITY_BUSY = 0.7;
private static final double DENSITY_MODERATE = 0.3;
private final OpsMapper mapper; private final OpsMapper mapper;
public OpsService(OpsMapper mapper) { public OpsService(OpsMapper mapper) {
@ -20,20 +28,103 @@ public class OpsService {
} }
public OpsDto getOps(String eventId) { public OpsDto getOps(String eventId) {
List<OpsDto.Hall> halls = new ArrayList<>(); long onSite = mapper.countCheckedIn(eventId);
List<Map<String, Object>> rows = mapper.findAssignedHalls(eventId);
if (rows != null) { List<Map<String, Object>> rows = mapper.findHallOps(eventId);
if (rows == null) {
rows = new ArrayList<>();
}
double totalArea = 0;
for (Map<String, Object> r : rows) { for (Map<String, Object> r : rows) {
// 혼잡 센서(M14) 미연동 estPeople=0, heat=smooth. totalArea += dbl(r.get("salesArea"));
halls.add(new OpsDto.Hall(str(r.get("hallId")), str(r.get("label")), 0L, "smooth")); }
List<OpsDto.Hall> halls = new ArrayList<>();
Double powerTotal = null;
int hallCount = rows.size();
for (Map<String, Object> r : rows) {
double area = dbl(r.get("salesArea"));
Double powerKw = r.get("powerKw") == null ? null : dbl(r.get("powerKw"));
long est = 0L;
if (onSite > 0) {
if (totalArea > 0) {
est = Math.round(onSite * (area / totalArea));
} else if (hallCount > 0) {
// 부스 미배치 균등 분배(면적 가중 불가).
est = Math.round((double) onSite / hallCount);
} }
} }
// HVAC/조명/주차 센서·IoT 미연동 null/빈배열.
halls.add(new OpsDto.Hall(
str(r.get("hallId")), str(r.get("label")), est, heatFor(est, area), powerKw));
if (powerKw != null) {
powerTotal = (powerTotal == null ? 0d : powerTotal) + powerKw;
}
}
List<OpsDto.CheckinPoint> series = new ArrayList<>();
List<Map<String, Object>> seriesRows = mapper.findCheckinSeries(eventId);
if (seriesRows != null) {
for (Map<String, Object> r : seriesRows) {
series.add(new OpsDto.CheckinPoint(str(r.get("hour")), lng(r.get("count"))));
}
}
// HVAC/조명/주차 센서·IoT 미연동 null/빈배열(정직).
OpsDto.Hvac hvac = new OpsDto.Hvac(null, null, null); OpsDto.Hvac hvac = new OpsDto.Hvac(null, null, null);
return new OpsDto(halls, hvac, new ArrayList<>(), new ArrayList<>()); return new OpsDto(halls, hvac, new ArrayList<>(), new ArrayList<>(), onSite, powerTotal, series);
}
/** 인원 밀도(명/㎡) 기준 혼잡 등급. 인원 0 또는 면적 부재 시 보수적 처리. */
private static String heatFor(long est, double area) {
if (est <= 0) {
return "smooth";
}
if (area <= 0) {
return "moderate";
}
double density = est / area;
if (density >= DENSITY_BUSY) {
return "busy";
}
if (density >= DENSITY_MODERATE) {
return "moderate";
}
return "smooth";
} }
private static String str(Object o) { private static String str(Object o) {
return o == null ? null : String.valueOf(o); return o == null ? null : String.valueOf(o);
} }
private static double dbl(Object o) {
if (o == null) {
return 0d;
}
if (o instanceof Number n) {
return n.doubleValue();
}
try {
return Double.parseDouble(String.valueOf(o));
} catch (NumberFormatException e) {
return 0d;
}
}
private static long lng(Object o) {
if (o == null) {
return 0L;
}
if (o instanceof Number n) {
return n.longValue();
}
try {
return Long.parseLong(String.valueOf(o));
} catch (NumberFormatException e) {
return 0L;
}
}
} }

View File

@ -3,29 +3,43 @@ package com.zioinfo.kintex.ops.dto;
import java.util.List; import java.util.List;
/** /**
* 현장 운영 현황 (SCR-16 / 06_backend_api_gaps §5). * 현장 운영 현황 (SCR-14 / 06_backend_api_gaps §5).
* 집계원: hall_assignment·hall( 목록). 혼잡/HVAC/조명/주차는 센서·IoT 미연동(PLANNING M14) 0/null/빈배열(정직). *
* <p>하드웨어 연동 코드 없음 표시 전용. 실시간성 필요 WebSocket /topic/ops/{eventId} 확장 검토(현재 폴링 GET). * <p>실집계원(하드웨어 무관):
* <ul>
* <li>혼잡(halls.estPeople/heat) M10 체크인 실집계(visitor_registration.checkin_at) 파생.
* 현장 인원은 판매면적(ST_Area) 비중으로 분배한 추정치이며 사람 계측 센서가 아니다.</li>
* <li>전력(halls.powerKw / powerTotalKw) M4 유틸리티 신청 부하(utility_order.quote.requestedKw) 집계.</li>
* <li>checkinSeries 시간대(0~23시) 체크인 집계(혼잡 추이).</li>
* </ul>
* HVAC/조명/주차는 센서·IoT 미연동(PLANNING M14) null/빈배열(정직). 하드웨어 연동 코드 없음.
*/ */
public record OpsDto( public record OpsDto(
List<Hall> halls, List<Hall> halls,
Hvac hvac, Hvac hvac,
List<LightingZone> lighting, List<LightingZone> lighting,
List<ParkingZone> parking List<ParkingZone> parking,
long onSiteCount,
Double powerTotalKw,
List<CheckinPoint> checkinSeries
) { ) {
/** 홀 혼잡(estPeople·heat) — 센서 부재 시 0/smooth. */ /** 홀 혼잡/전력 — estPeople·heat(체크인 파생), powerKw(유틸리티 신청 부하, 신청 없으면 null). */
public record Hall(String hallId, String label, long estPeople, String heat) { public record Hall(String hallId, String label, long estPeople, String heat, Double powerKw) {
} }
/** 공조 계측(센서 부재 시 null). */ /** 공조 계측(센서 부재 시 null). */
public record Hvac(Double tempC, Integer humidityPercent, Integer co2Ppm) { public record Hvac(Double tempC, Integer humidityPercent, Integer co2Ppm) {
} }
/** 조명 존 점등률. */ /** 조명 존 점등률(IoT 부재 시 빈배열). */
public record LightingZone(String zone, int onPercent) { public record LightingZone(String zone, int onPercent) {
} }
/** 주차 존 점유·수용. */ /** 주차 존 점유·수용(연계 부재 시 빈배열). */
public record ParkingZone(String zone, int occupancy, int capacity) { public record ParkingZone(String zone, int occupancy, int capacity) {
} }
/** 시간대별 체크인 수(hour = 'HH:00'). */
public record CheckinPoint(String hour, long count) {
}
} }

View File

@ -15,10 +15,12 @@ import java.util.concurrent.ConcurrentHashMap;
public class OtpChallengeStore { public class OtpChallengeStore {
private static final long TTL_MILLIS = 5 * 60 * 1000L; private static final long TTL_MILLIS = 5 * 60 * 1000L;
/** 챌린지당 코드 검증 실패 허용 횟수 — 초과 시 챌린지 무효화(브루트포스 방어). */
private static final int MAX_FAILURES = 5;
private final SecureRandom random = new SecureRandom(); private final SecureRandom random = new SecureRandom();
private final ConcurrentHashMap<String, Entry> store = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Entry> store = new ConcurrentHashMap<>();
private record Entry(String userId, long expiresAt) { private record Entry(String userId, long expiresAt, int failures) {
} }
/** userId에 대한 1회성 챌린지 토큰 발급. */ /** userId에 대한 1회성 챌린지 토큰 발급. */
@ -27,7 +29,7 @@ public class OtpChallengeStore {
byte[] buf = new byte[24]; byte[] buf = new byte[24];
random.nextBytes(buf); random.nextBytes(buf);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(buf); String token = Base64.getUrlEncoder().withoutPadding().encodeToString(buf);
store.put(token, new Entry(userId, Instant.now().toEpochMilli() + TTL_MILLIS)); store.put(token, new Entry(userId, Instant.now().toEpochMilli() + TTL_MILLIS, 0));
return token; return token;
} }
@ -55,6 +57,28 @@ public class OtpChallengeStore {
return e.userId(); return e.userId();
} }
/**
* 코드 검증 실패 기록 챌린지는 유지(재시도 허용)하되 {@value #MAX_FAILURES} 초과 무효화.
* 반환: 챌린지가 아직 살아 있으면 true(재시도 가능), 무효화·만료면 false.
*/
public boolean recordFailure(String token) {
if (token == null) {
return false;
}
Entry e = store.get(token);
if (e == null || e.expiresAt() < Instant.now().toEpochMilli()) {
store.remove(token);
return false;
}
int failures = e.failures() + 1;
if (failures >= MAX_FAILURES) {
store.remove(token);
return false;
}
store.put(token, new Entry(e.userId(), e.expiresAt(), failures));
return true;
}
private void purgeExpired() { private void purgeExpired() {
long now = Instant.now().toEpochMilli(); long now = Instant.now().toEpochMilli();
store.entrySet().removeIf(en -> en.getValue().expiresAt() < now); store.entrySet().removeIf(en -> en.getValue().expiresAt() < now);

View File

@ -73,20 +73,30 @@ public class TwoFactorService {
&& requiresOtp(userId, hallManager, str(row.get("roleCode")))) { && requiresOtp(userId, hallManager, str(row.get("roleCode")))) {
return SecureLoginResponse.otpEnroll(challengeStore.issue(userId)); return SecureLoginResponse.otpEnroll(challengeStore.issue(userId));
} }
return SecureLoginResponse.ok(buildLoginResponse(userId, str(row.get("displayName")), hallManager)); return SecureLoginResponse.ok(buildLoginResponse(userId, str(row.get("displayName")),
hallManager, str(row.get("roleCode"))));
} }
/** 2단계: 챌린지 토큰 + OTP 코드 검증 → 최종 로그인 토큰. */ /**
* 2단계: 챌린지 토큰 + OTP 코드 검증 최종 로그인 토큰.
* 챌린지는 검증 성공 시에만 소비한다 오입력 1회가 세션을 태우면 사용자가 "코드 틀림→세션 만료"
* 루프에 갇힌다(2026-07-12 라이브 장애). 재시도는 챌린지당 실패 5회·TTL 5분 내로 제한(브루트포스 방어).
*/
public LoginResponse verifyOtp(String challengeToken, String code) { public LoginResponse verifyOtp(String challengeToken, String code) {
String userId = challengeStore.consume(challengeToken); String userId = challengeStore.peek(challengeToken);
if (userId == null) { if (userId == null) {
throw new ApiException(ErrorCode.OTP_REQUIRED, "2차 인증 세션이 만료되었습니다. 다시 로그인해 주세요."); throw new ApiException(ErrorCode.OTP_REQUIRED, "2차 인증 세션이 만료되었습니다. 다시 로그인해 주세요.");
} }
Map<String, Object> sec = securityMapper.findSecurityById(userId); Map<String, Object> sec = securityMapper.findSecurityById(userId);
if (sec == null || !totpService.verify(secretCipher.open(str(sec.get("otpSecret"))), code)) { if (sec == null || !totpService.verify(secretCipher.open(str(sec.get("otpSecret"))), code)) {
throw new ApiException(ErrorCode.OTP_INVALID); boolean alive = challengeStore.recordFailure(challengeToken);
throw new ApiException(ErrorCode.OTP_INVALID,
alive ? "OTP 코드가 올바르지 않습니다. 다시 입력해 주세요."
: "시도 횟수를 초과했습니다. 다시 로그인해 주세요.");
} }
return buildLoginResponse(userId, str(sec.get("displayName")), bool(sec.get("hallManager"))); challengeStore.consume(challengeToken);
return buildLoginResponse(userId, str(sec.get("displayName")), bool(sec.get("hallManager")),
str(sec.get("roleCode")));
} }
/** 로그인 중 등록 강제 — setup 단계(프리오스 챌린지 유지). 시크릿 발급·QR 반환. */ /** 로그인 중 등록 강제 — setup 단계(프리오스 챌린지 유지). 시크릿 발급·QR 반환. */
@ -100,18 +110,23 @@ public class TwoFactorService {
return setupOtp(userId, label != null ? label : userId); return setupOtp(userId, label != null ? label : userId);
} }
/** 로그인 중 등록 강제 — verify 단계(챌린지 소비·활성화 후 최종 토큰). */ /** 로그인 중 등록 강제 — verify 단계(검증 성공 시에만 챌린지 소비·활성화 후 최종 토큰). */
public LoginResponse enrollVerify(String challengeToken, String code) { public LoginResponse enrollVerify(String challengeToken, String code) {
String userId = challengeStore.consume(challengeToken); String userId = challengeStore.peek(challengeToken);
if (userId == null) { if (userId == null) {
throw new ApiException(ErrorCode.OTP_REQUIRED, "2차 인증 세션이 만료되었습니다. 다시 로그인해 주세요."); throw new ApiException(ErrorCode.OTP_REQUIRED, "2차 인증 세션이 만료되었습니다. 다시 로그인해 주세요.");
} }
Map<String, Object> sec = securityMapper.findSecurityById(userId); Map<String, Object> sec = securityMapper.findSecurityById(userId);
if (sec == null || !totpService.verify(secretCipher.open(str(sec.get("otpSecret"))), code)) { if (sec == null || !totpService.verify(secretCipher.open(str(sec.get("otpSecret"))), code)) {
throw new ApiException(ErrorCode.OTP_INVALID); boolean alive = challengeStore.recordFailure(challengeToken);
throw new ApiException(ErrorCode.OTP_INVALID,
alive ? "OTP 코드가 올바르지 않습니다. 다시 입력해 주세요."
: "시도 횟수를 초과했습니다. 다시 로그인해 주세요.");
} }
challengeStore.consume(challengeToken);
securityMapper.enableOtp(userId); securityMapper.enableOtp(userId);
return buildLoginResponse(userId, str(sec.get("displayName")), bool(sec.get("hallManager"))); return buildLoginResponse(userId, str(sec.get("displayName")), bool(sec.get("hallManager")),
str(sec.get("roleCode")));
} }
/** OTP 등록 시작 — 시크릿 발급(봉인 저장)·QR 반환(미활성, confirm 필요). */ /** OTP 등록 시작 — 시크릿 발급(봉인 저장)·QR 반환(미활성, confirm 필요). */
@ -162,8 +177,13 @@ public class TwoFactorService {
return new OtpStatusResponse(enabled, enabled ? "OTP" : "EMAIL"); return new OtpStatusResponse(enabled, enabled ? "OTP" : "EMAIL");
} }
/** 사용자 역할 → 토큰 + 워크스페이스 카드 조립(AuthServiceImpl 로직 정합, 토큰만 재발급). */ /**
private LoginResponse buildLoginResponse(String userId, String displayName, boolean hallManager) { * 사용자 역할 토큰 + 워크스페이스 카드 조립(AuthServiceImpl 로직 정합, 토큰만 재발급).
* globalRoleCode = app_user.role_code(전역 역할, null 허용). 주의: 여기서 r.get("roleCode")
* event_member.role_code(행사 역할)이며 전역 role_code와 별개 혼동 금지.
*/
private LoginResponse buildLoginResponse(String userId, String displayName, boolean hallManager,
String globalRoleCode) {
List<Map<String, Object>> roleRows = userMapper.findEventRoles(userId); List<Map<String, Object>> roleRows = userMapper.findEventRoles(userId);
Map<String, EventRole> eventRoles = new LinkedHashMap<>(); Map<String, EventRole> eventRoles = new LinkedHashMap<>();
if (roleRows != null) { if (roleRows != null) {
@ -175,7 +195,7 @@ public class TwoFactorService {
} }
} }
} }
String token = jwtService.issue(userId, displayName, eventRoles, hallManager); String token = jwtService.issue(userId, displayName, eventRoles, hallManager, globalRoleCode);
List<WorkspaceDto> workspaces = new ArrayList<>(); List<WorkspaceDto> workspaces = new ArrayList<>();
if (roleRows != null) { if (roleRows != null) {
for (Map<String, Object> r : roleRows) { for (Map<String, Object> r : roleRows) {
@ -185,7 +205,7 @@ public class TwoFactorService {
} }
} }
return new LoginResponse(token, jwtService.ttlSeconds(), return new LoginResponse(token, jwtService.ttlSeconds(),
new LoginResponse.UserSummary(userId, displayName, hallManager), workspaces); new LoginResponse.UserSummary(userId, displayName, hallManager, globalRoleCode), workspaces);
} }
private static EventRole parseRole(String code) { private static EventRole parseRole(String code) {

View File

@ -0,0 +1,83 @@
package com.zioinfo.kintex.settlement;
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.settlement.dto.SettlementDtos.*;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
/**
* M9 정산·결제 API 행사 청구/수납 현황 대시보드·청구서 목록/상세·생성·발행·수납 기록.
* <p>인증 필수. 조회는 행사 접근 권한(멤버·홀매니저), 생성·발행·수납은 매니저 이상(서비스 재검증).
* 결제는 PG 위임 고지 패턴 실제 PG 연동 없이 상태 전이·수동 수납만.
*/
@RestController
@RequestMapping("/api/settlement")
public class SettlementController {
private final SettlementService service;
private final EventAccessGuard guard;
public SettlementController(SettlementService service, EventAccessGuard guard) {
this.service = service;
this.guard = guard;
}
/** GET /api/settlement/dashboard?eventId= — 청구/수납/미수 KPI + 상태 분포. */
@GetMapping("/dashboard")
public ApiResponse<SettlementDashboard> dashboard(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam String eventId) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.getDashboard(eventId));
}
/** GET /api/settlement/invoices?eventId=&status=&page=&size= — 청구서 목록(상태 필터·페이지네이션). */
@GetMapping("/invoices")
public ApiResponse<PageResponse<InvoiceSummary>> invoices(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam String eventId,
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.listInvoices(eventId, status, page, size));
}
/** GET /api/settlement/invoices/{id} — 청구서 상세(라인아이템 + 수납 이력). */
@GetMapping("/invoices/{id}")
public ApiResponse<InvoiceDetail> detail(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
return ApiResponse.ok(service.getDetail(id, principal));
}
/** POST /api/settlement/invoices/generate?eventId= — 참가업체별 청구서 집계 생성(매니저 이상). */
@Audited(action = "SETTLEMENT_GENERATE", targetType = "invoice")
@PostMapping("/invoices/generate")
public ApiResponse<GenerateResult> generate(@AuthenticationPrincipal KintexPrincipal principal,
@RequestParam String eventId) {
guard.requireEventAccess(principal, eventId);
return ApiResponse.ok(service.generate(eventId, principal));
}
/** POST /api/settlement/invoices/{id}/issue — 발행(pending→invoiced, 매니저 이상). */
@Audited(action = "SETTLEMENT_ISSUE", targetType = "invoice")
@PostMapping("/invoices/{id}/issue")
public ApiResponse<InvoiceDetail> issue(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id) {
guard.require(principal);
return ApiResponse.ok(service.issue(id, principal));
}
/** POST /api/settlement/invoices/{id}/payments — 수납 기록(수동, 매니저 이상). */
@Audited(action = "SETTLEMENT_PAYMENT", targetType = "invoice_payment")
@PostMapping("/invoices/{id}/payments")
public ApiResponse<InvoiceDetail> recordPayment(@AuthenticationPrincipal KintexPrincipal principal,
@PathVariable String id,
@RequestBody RecordPaymentRequest req) {
guard.require(principal);
return ApiResponse.ok(service.recordPayment(id, principal, req));
}
}

View File

@ -0,0 +1,183 @@
package com.zioinfo.kintex.settlement;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Map;
/**
* M9 정산 매퍼 invoice·invoice_payment + 임대/유틸리티 집계(read-only).
* <p><b> Map 반환 @Select 별칭은 반드시 쌍따옴표(AS "x")</b> PG는 따옴표 없는 별칭을 소문자로
* 접어 Map 키가 전부 null 된다(WORK_STATUS §7). {@code <script>} 블록의 {@code <}/{@code <=}
* 사용하지 않는다(부팅 크래시 방지 비교는 뒤집거나 회피).
*/
@Mapper
public interface SettlementMapper {
// 대시보드 집계
@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}
""")
Map<String, Object> dashboardTotals(@Param("eventId") String eventId);
@Select("""
SELECT status AS "status",
count(*) AS "count",
COALESCE(sum(total), 0) AS "totalAmount",
COALESCE(sum(paid_amount), 0) AS "paidAmount"
FROM invoice WHERE event_id = #{eventId}
GROUP BY status
ORDER BY status
""")
List<Map<String, Object>> statusDistribution(@Param("eventId") String eventId);
// 청구서 목록/상세
String INVOICE_COLS = """
SELECT i.id,
i.event_id AS "eventId",
i.invoice_no AS "invoiceNo",
i.exhibitor_name AS "exhibitorName",
i.subtotal, i.vat, i.total,
i.paid_amount AS "paidAmount",
i.status,
to_char(i.due_date, 'YYYY-MM-DD') AS "dueDate",
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,
i.line_items::text AS "lineItems"
FROM invoice i
""";
@Select("<script>" + INVOICE_COLS
+ "<where>"
+ " i.event_id = #{eventId}"
+ " <if test='status != null and status != \"\"'>AND i.status = #{status}</if>"
+ "</where>"
+ " ORDER BY i.created_at DESC, i.invoice_no DESC LIMIT #{limit} OFFSET #{offset}"
+ "</script>")
List<Map<String, Object>> listInvoices(@Param("eventId") String eventId,
@Param("status") String status,
@Param("limit") int limit,
@Param("offset") int offset);
@Select("<script>"
+ "SELECT count(*) FROM invoice"
+ "<where>"
+ " event_id = #{eventId}"
+ " <if test='status != null and status != \"\"'>AND status = #{status}</if>"
+ "</where>"
+ "</script>")
long countInvoices(@Param("eventId") String eventId, @Param("status") String status);
@Select("<script>" + INVOICE_COLS + " WHERE i.id = #{id}</script>")
Map<String, Object> findInvoice(@Param("id") String id);
@Select("""
SELECT id,
amount,
method,
memo,
to_char(recorded_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "recordedAt"
FROM invoice_payment WHERE invoice_id = #{invoiceId}
ORDER BY recorded_at ASC
""")
List<Map<String, Object>> listPayments(@Param("invoiceId") String invoiceId);
// 상태 전이/수납
/** 발행: pending → invoiced (issued_at·due_date 확정). 이미 발행된 건은 미영향(WHERE 조건). */
@Update("""
UPDATE invoice
SET status = 'invoiced', issued_at = now(),
due_date = COALESCE(due_date, (now() + interval '30 day')::date),
updated_at = now()
WHERE id = #{id} AND status = 'pending'
""")
int issueInvoice(@Param("id") String id);
@Insert("""
INSERT INTO invoice_payment (id, invoice_id, amount, method, memo, recorded_by)
VALUES (#{id}, #{invoiceId}, #{amount}, #{method}, #{memo}, #{recordedBy})
""")
int insertPayment(Map<String, Object> p);
/**
* 수납 반영 paid_amount 결제 이력 합계로 재계산하고 상태를 파생.
* 완납(paid_amount &gt;= total)이면 paid, 부분 수납이면 invoiced, 유지.
* (비교 연산은 SQL 본문 {@code <script>} 아님 이므로 이스케이프 불필요.)
*/
@Update("""
UPDATE invoice i
SET paid_amount = p.sum_paid,
status = CASE
WHEN p.sum_paid >= i.total THEN 'paid'
WHEN p.sum_paid > 0 THEN 'invoiced'
ELSE i.status
END,
paid_at = CASE WHEN p.sum_paid >= i.total THEN now() ELSE i.paid_at END,
issued_at = COALESCE(i.issued_at, now()),
due_date = COALESCE(i.due_date, (now() + interval '30 day')::date),
updated_at = now()
FROM (SELECT COALESCE(sum(amount), 0) AS sum_paid
FROM invoice_payment WHERE invoice_id = #{id}) p
WHERE i.id = #{id}
""")
int applyPayment(@Param("id") String id);
// 청구서 생성(집계)
/** 전시홀 임대 요율(원/㎡) — master_data RATE.exhibitionHallPerM2(rates-v1.0). 없으면 null. */
@Select("""
SELECT (value_json #>> '{}')::numeric
FROM master_data WHERE category = 'RATE' AND code = 'exhibitionHallPerM2'
""")
Double findRentalRatePerM2();
/** 행사 참가업체별(배정 업체명) 판매면적·부스 수 — 임대료 산정 원천. */
@Select("""
SELECT lower(btrim(b.assigned_company_name)) AS "companyKey",
max(btrim(b.assigned_company_name)) AS "exhibitorName",
COALESCE(sum(ST_Area(b.geom)), 0) AS "areaM2",
count(b.id) AS "boothCount"
FROM booth b
JOIN layout l ON l.id = b.layout_id
WHERE l.event_id = #{eventId}
AND b.assigned_company_name IS NOT NULL AND btrim(b.assigned_company_name) <> ''
GROUP BY lower(btrim(b.assigned_company_name))
""")
List<Map<String, Object>> aggregateRentalByExhibitor(@Param("eventId") String eventId);
/** 행사 참가업체별 유틸리티 매출 — utility_order.quote.total 합(숫자형만). */
@Select("""
SELECT lower(btrim(b.assigned_company_name)) AS "companyKey",
COALESCE(sum((uo.quote->>'total')::numeric), 0) AS "utility"
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 b.assigned_company_name IS NOT NULL AND btrim(b.assigned_company_name) <> ''
AND uo.quote->>'total' ~ '^[0-9]+(\\.[0-9]+)?$'
GROUP BY lower(btrim(b.assigned_company_name))
""")
List<Map<String, Object>> aggregateUtilityByExhibitor(@Param("eventId") String eventId);
/** 생성 멱등 — (event_id, exhibitor_name) 충돌 시 무시(기존 청구서 보존). */
@Insert("""
INSERT INTO invoice (id, event_id, exhibitor_name, invoice_no,
subtotal, vat, total, status, line_items, created_by)
VALUES (#{id}, #{eventId}, #{exhibitorName}, #{invoiceNo},
#{subtotal}, #{vat}, #{total}, 'pending', CAST(#{lineItems} AS jsonb), #{createdBy})
ON CONFLICT (event_id, exhibitor_name) DO NOTHING
""")
int insertInvoice(Map<String, Object> p);
/** 행사 청구서 번호 채번용 — 현재 최대 순번(YYYY 접두 무관 단순 시퀀스). */
@Select("SELECT count(*) FROM invoice WHERE event_id = #{eventId}")
int countInvoicesForSeq(@Param("eventId") String eventId);
}

View File

@ -0,0 +1,348 @@
package com.zioinfo.kintex.settlement;
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.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.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* M9 정산·결제 서비스 청구 집계·현황 대시보드·상태 전이·수납 기록.
*
* <p><b>결제 = PG 위임 고지 패턴</b>: 실제 PG 연동은 없다. 상태 전이(pendinginvoicedpaid/overdue)
* 수동 수납 기록만 수행한다(공개 P 화면 결제 고지와 동일 정책). <b>금액은 서버 계산이 권위</b>
* 임대료 = 판매면적(ST_Area) × 요율(rates-v1.0), 유틸리티 = utility_order , 부가세 10%.
*
* <p><b>권한</b>: 조회는 행사 접근 권한(멤버·홀매니저). 청구 생성·발행·수납 기록은 매니저 이상
* (주최자 또는 홀매니저) 컨트롤러 {@code @Audited} + 서비스 재검증.
*/
@Service
public class SettlementService {
private static final int MAX_LIMIT = 200;
private static final double VAT_RATE = 0.10;
private static final String PAYMENT_NOTE =
"결제는 외부 PG(전자결제)로 위임됩니다. 본 화면은 청구·수납 현황 관리 전용이며 카드정보는 저장하지 않습니다.";
private static final ObjectMapper JSON = new ObjectMapper();
private final SettlementMapper mapper;
public SettlementService(SettlementMapper mapper) {
this.mapper = mapper;
}
// 대시보드
public SettlementDashboard getDashboard(String eventId) {
Map<String, Object> t = mapper.dashboardTotals(eventId);
long billed = lng(t == null ? null : t.get("billed"));
long collected = lng(t == null ? null : t.get("collected"));
long invoiceCount = lng(t == null ? null : t.get("invoiceCount"));
long overdueCount = lng(t == null ? null : t.get("overdueCount"));
long outstanding = Math.max(0, billed - collected);
double rate = billed > 0 ? round1((collected * 100.0) / billed) : 0.0;
List<StatusBucket> buckets = new ArrayList<>();
List<Map<String, Object>> rows = mapper.statusDistribution(eventId);
if (rows != null) {
for (Map<String, Object> r : rows) {
buckets.add(new StatusBucket(
str(r.get("status")), lng(r.get("count")),
lng(r.get("totalAmount")), lng(r.get("paidAmount"))));
}
}
return new SettlementDashboard(eventId, billed, collected, outstanding,
invoiceCount, overdueCount, rate, buckets, PAYMENT_NOTE);
}
// 목록
public PageResponse<InvoiceSummary> listInvoices(String eventId, String status, int page, int size) {
int limit = Math.min(Math.max(size, 1), MAX_LIMIT);
int offset = Math.max(page, 0) * limit;
String st = nullIfBlank(status);
long total = mapper.countInvoices(eventId, st);
List<Map<String, Object>> rows = mapper.listInvoices(eventId, st, limit, offset);
List<InvoiceSummary> items = new ArrayList<>();
if (rows != null) {
for (Map<String, Object> r : rows) {
items.add(toSummary(r));
}
}
return PageResponse.of(items, Math.max(page, 0), limit, total);
}
// 상세
public InvoiceDetail getDetail(String id, KintexPrincipal principal) {
Map<String, Object> r = mapper.findInvoice(id);
if (r == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
long subtotal = lng(r.get("subtotal"));
long vat = lng(r.get("vat"));
long total = lng(r.get("total"));
long paid = lng(r.get("paidAmount"));
List<LineItem> lines = parseLines(str(r.get("lineItems")));
List<PaymentRecord> payments = new ArrayList<>();
List<Map<String, Object>> pr = mapper.listPayments(id);
if (pr != null) {
for (Map<String, Object> p : pr) {
payments.add(new PaymentRecord(
str(p.get("id")), lng(p.get("amount")), str(p.get("method")),
str(p.get("memo")), str(p.get("recordedAt"))));
}
}
boolean canManage = isManager(principal, str(r.get("eventId")));
return new InvoiceDetail(
str(r.get("id")), str(r.get("eventId")), str(r.get("invoiceNo")),
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);
}
// 발행(pending invoiced)
@Transactional
public InvoiceDetail issue(String id, KintexPrincipal principal) {
Map<String, Object> r = mapper.findInvoice(id);
if (r == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
requireManager(principal, str(r.get("eventId")));
if (!"pending".equals(str(r.get("status")))) {
throw new ApiException(ErrorCode.CONFLICT); // 이미 발행/수납/완납된
}
mapper.issueInvoice(id);
return getDetail(id, principal);
}
// 수납 기록(수동)
@Transactional
public InvoiceDetail recordPayment(String id, KintexPrincipal principal, RecordPaymentRequest req) {
Map<String, Object> r = mapper.findInvoice(id);
if (r == null) {
throw new ApiException(ErrorCode.NOT_FOUND);
}
requireManager(principal, str(r.get("eventId")));
if (req == null || req.amount() == null || req.amount() <= 0) {
throw new ApiException(ErrorCode.VALIDATION);
}
long total = lng(r.get("total"));
long paid = lng(r.get("paidAmount"));
if (paid + req.amount() > total) {
throw new ApiException(ErrorCode.VALIDATION); // 청구액 초과 수납 방지
}
Map<String, Object> p = new HashMap<>();
p.put("id", "pay-" + UUID.randomUUID().toString().substring(0, 12));
p.put("invoiceId", id);
p.put("amount", req.amount());
p.put("method", mapMethod(req.method()));
p.put("memo", nullIfBlank(req.memo()));
p.put("recordedBy", principal.userId());
mapper.insertPayment(p);
mapper.applyPayment(id); // paid_amount 재계산 + 상태 파생(완납 paid)
return getDetail(id, principal);
}
// 청구서 생성(집계)
/**
* 행사 참가업체별로 임대료(면적×요율)+유틸리티 합을 집계해 청구서를 생성(pending).
* (event_id, exhibitor_name) 멱등 이미 존재하는 참가업체는 건너뛴다.
*/
@Transactional
public GenerateResult generate(String eventId, KintexPrincipal principal) {
if (nullIfBlank(eventId) == null) {
throw new ApiException(ErrorCode.VALIDATION);
}
requireManager(principal, eventId);
Double rateObj = mapper.findRentalRatePerM2();
double ratePerM2 = rateObj == null ? 0.0 : rateObj;
Map<String, Long> utilByKey = new HashMap<>();
List<Map<String, Object>> utilRows = mapper.aggregateUtilityByExhibitor(eventId);
if (utilRows != null) {
for (Map<String, Object> u : utilRows) {
utilByKey.put(str(u.get("companyKey")), Math.round(dbl(u.get("utility"))));
}
}
List<Map<String, Object>> rentalRows = mapper.aggregateRentalByExhibitor(eventId);
int created = 0;
int skipped = 0;
int seq = mapper.countInvoicesForSeq(eventId);
List<InvoiceSummary> out = new ArrayList<>();
if (rentalRows != null) {
for (Map<String, Object> row : rentalRows) {
String key = str(row.get("companyKey"));
String name = str(row.get("exhibitorName"));
double area = dbl(row.get("areaM2"));
long boothCount = lng(row.get("boothCount"));
long rental = Math.round(area * ratePerM2);
long utility = utilByKey.getOrDefault(key, 0L);
long subtotal = rental + utility;
long vat = Math.round(subtotal * VAT_RATE);
long total = subtotal + vat;
List<Map<String, Object>> lines = new ArrayList<>();
lines.add(lineItem("RENTAL",
"부스 임대료 (" + Math.round(area) + "㎡ · " + boothCount + "부스)",
Math.round(area), Math.round(ratePerM2), rental));
if (utility > 0) {
lines.add(lineItem("UTILITY", "유틸리티(전기·네트워크·급배수)", 1, utility, utility));
}
seq++;
String invoiceNo = eventInvoiceNo(eventId, seq);
Map<String, Object> p = new HashMap<>();
p.put("id", "inv-" + UUID.randomUUID().toString().substring(0, 12));
p.put("eventId", eventId);
p.put("exhibitorName", name);
p.put("invoiceNo", invoiceNo);
p.put("subtotal", subtotal);
p.put("vat", vat);
p.put("total", total);
p.put("lineItems", toJson(lines));
p.put("createdBy", principal.userId());
int n = mapper.insertInvoice(p);
if (n > 0) {
created++;
out.add(new InvoiceSummary(str(p.get("id")), eventId, invoiceNo, name,
subtotal, vat, total, 0, total, "pending", null, null));
} else {
skipped++;
seq--; // 충돌로 미생성 번호 되돌림
}
}
}
return new GenerateResult(eventId, created, skipped, out);
}
// 헬퍼
private InvoiceSummary toSummary(Map<String, Object> r) {
long total = lng(r.get("total"));
long paid = lng(r.get("paidAmount"));
return new InvoiceSummary(
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")));
}
/** 매니저 이상(주최자 또는 킨텍스 홀매니저)만 청구·수납 조작 가능. */
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);
}
}
private static Map<String, Object> lineItem(String category, String description,
long quantity, long unitPrice, long amount) {
Map<String, Object> m = new HashMap<>();
m.put("category", category);
m.put("description", description);
m.put("quantity", quantity);
m.put("unitPrice", unitPrice);
m.put("amount", amount);
return m;
}
private List<LineItem> parseLines(String json) {
List<LineItem> out = new ArrayList<>();
if (json == null || json.isBlank()) {
return out;
}
try {
List<Map<String, Object>> raw = JSON.readValue(json, new com.fasterxml.jackson.core.type.TypeReference<>() {
});
for (Map<String, Object> m : raw) {
out.add(new LineItem(
str(m.get("category")), str(m.get("description")),
lng(m.get("quantity")), lng(m.get("unitPrice")), lng(m.get("amount"))));
}
} catch (Exception e) {
// 스냅샷 파싱 실패 라인은 생략(금액 합계는 컬럼값이 권위) 스택트레이스 미노출.
return out;
}
return out;
}
private static String eventInvoiceNo(String eventId, int seq) {
String suffix = eventId == null ? "EVT" : eventId.replaceAll("[^A-Za-z0-9]", "").toUpperCase();
if (suffix.length() > 8) {
suffix = suffix.substring(suffix.length() - 8);
}
return suffix + "-" + String.format("%04d", seq);
}
private static String mapMethod(String m) {
if (m == null) {
return "manual";
}
return switch (m.toLowerCase()) {
case "transfer" -> "transfer";
case "card" -> "card";
default -> "manual";
};
}
private static String toJson(Object o) {
try {
return JSON.writeValueAsString(o);
} catch (Exception e) {
return "[]";
}
}
private static String nullIfBlank(String s) {
return s == null || s.isBlank() ? null : s;
}
private static String str(Object o) {
return o == null ? null : String.valueOf(o);
}
private static long lng(Object o) {
if (o == null) return 0L;
if (o instanceof Number n) return n.longValue();
try {
return Long.parseLong(String.valueOf(o));
} catch (NumberFormatException e) {
return 0L;
}
}
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 double round1(double v) {
return Math.round(v * 10.0) / 10.0;
}
}

View File

@ -0,0 +1,114 @@
package com.zioinfo.kintex.settlement.dto;
import java.util.List;
/**
* M9 정산·결제 DTO 묶음 대시보드·청구서 목록/상세·수납 기록.
* 금액은 모두 서버 계산 권위( 단위, long). 민감정보·PII 미포함.
*/
public final class SettlementDtos {
private SettlementDtos() {
}
// 대시보드(SCR /settlement)
/** 정산 현황 요약 KPI + 상태 분포. paymentNote = PG 위임 고지 문구. */
public record SettlementDashboard(
String eventId,
long billed, // 청구액(Σ total)
long collected, // 수납액(Σ paid_amount)
long outstanding, // 미수(billed - collected)
long invoiceCount,
long overdueCount,
double collectionRate, // 수납률(%) = collected/billed×100
List<StatusBucket> statusDistribution,
String paymentNote
) {
}
/** 상태별 건수·금액 분포(청구/수납). */
public record StatusBucket(
String status, // pending|invoiced|paid|overdue
long count,
long totalAmount,
long paidAmount
) {
}
// 청구서 목록
public record InvoiceSummary(
String id,
String eventId,
String invoiceNo,
String exhibitorName,
long subtotal,
long vat,
long total,
long paidAmount,
long outstanding,
String status,
String dueDate, // ISO 날짜(YYYY-MM-DD) 또는 null
String issuedAt // ISO 8601 또는 null
) {
}
// 청구서 상세(라인아이템 + 수납 이력)
public record InvoiceDetail(
String id,
String eventId,
String invoiceNo,
String exhibitorName,
long subtotal,
long vat,
long total,
long paidAmount,
long outstanding,
String status,
String dueDate,
String issuedAt,
String paidAt,
String note,
List<LineItem> lineItems,
List<PaymentRecord> payments,
boolean canManage // 매니저 이상(수납/발행 버튼 노출용) 서버 판정
) {
}
/** 청구 라인아이템(산출 스냅샷). category = RENTAL|UTILITY|OTHER. */
public record LineItem(
String category,
String description,
long quantity,
long unitPrice,
long amount
) {
}
/** 수납 기록. */
public record PaymentRecord(
String id,
long amount,
String method,
String memo,
String recordedAt
) {
}
// 요청
/** 수납 기록 요청 — 금액은 서버가 재검증(>0, 총액 초과 방지). */
public record RecordPaymentRequest(
Long amount,
String method, // manual|transfer|card (선택, 기본 manual)
String memo
) {
}
/** 청구서 생성 결과 — 신규 생성 건수 + 요약. */
public record GenerateResult(
String eventId,
int created,
int skipped,
List<InvoiceSummary> invoices
) {
}
}

View File

@ -0,0 +1,76 @@
-- 킨텍스 — M9 정산·결제 스키마 + 시드 (멱등)
-- PLANNING §5 M9: 행사×참가업체 청구서(invoice) 집계·상태 전이(pending→invoiced→paid/overdue).
-- 임대료 = booth ST_Area × 요율(master_data RATE.exhibitionHallPerM2, rates-v1.0) + 유틸리티(utility_order).
-- 결제는 PG 위임 고지 패턴 — 실 PG 연동 없음. 수납은 수동 기록(invoice_payment) 누적 → 상태 파생.
-- 보안 불변: 본 마이그레이션은 V24 단독. V1~V23 및 타 팀 마이그레이션을 변경하지 않는다.
-- 청구서(Invoice) — 행사×참가업체 1행. line_items 는 산출 스냅샷(jsonb) ------------
CREATE TABLE IF NOT EXISTS invoice (
id varchar(40) PRIMARY KEY,
event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE,
exhibitor_id varchar(40), -- company 소프트 참조(선택)
exhibitor_name varchar(200) NOT NULL, -- 표시용 참가업체명(민감정보 아님)
invoice_no varchar(40) NOT NULL UNIQUE,
subtotal bigint NOT NULL DEFAULT 0, -- 공급가액(임대+유틸+기타)
vat bigint NOT NULL DEFAULT 0, -- 부가세(10%)
total bigint NOT NULL DEFAULT 0, -- 청구 총액 = subtotal + vat
paid_amount bigint NOT NULL DEFAULT 0, -- 누적 수납액(= Σ invoice_payment.amount)
status varchar(20) NOT NULL DEFAULT 'pending', -- pending|invoiced|paid|overdue
due_date date,
issued_at timestamptz,
paid_at timestamptz,
line_items jsonb, -- [{category,description,quantity,unitPrice,amount}]
note text,
created_by varchar(40),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (event_id, exhibitor_name) -- 생성 멱등 키(참가업체당 1청구서)
);
CREATE INDEX IF NOT EXISTS idx_invoice_event ON invoice(event_id);
CREATE INDEX IF NOT EXISTS idx_invoice_status ON invoice(event_id, status);
-- 수납 기록(Payment) — 계약금/중도금/잔금 등 분납 이력. 매니저 수동 기록 -------------
CREATE TABLE IF NOT EXISTS invoice_payment (
id varchar(40) PRIMARY KEY,
invoice_id varchar(40) NOT NULL REFERENCES invoice(id) ON DELETE CASCADE,
amount bigint NOT NULL,
method varchar(30) NOT NULL DEFAULT 'manual', -- manual|transfer|card (PG 위임 고지)
memo varchar(200),
recorded_by varchar(40),
recorded_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_invoice_payment_invoice ON invoice_payment(invoice_id);
-- ===== 시드(멱등) — 기존 event(e-2026-smf) 참조. 데모 참가업체 4사 청구서 ==========
-- 상태 분포 시연: paid(완납)·invoiced(부분수납)·overdue(연체)·pending(미발행).
INSERT INTO invoice (id, event_id, exhibitor_id, exhibitor_name, invoice_no,
subtotal, vat, total, paid_amount, status, due_date, issued_at, paid_at,
line_items, note, created_by) VALUES
('inv-smf-0001', 'e-2026-smf', NULL, '(주)스마트오토메이션', 'SMF-2026-0001',
18000000, 1800000, 19800000, 19800000, 'paid',
DATE '2026-07-25', now() - interval '20 day', now() - interval '5 day',
'[{"category":"RENTAL","description":"부스 임대료 (108㎡ · A-101)","quantity":108,"unitPrice":145000,"amount":15660000},{"category":"UTILITY","description":"전기·네트워크 유틸리티","quantity":1,"unitPrice":2340000,"amount":2340000}]'::jsonb,
'완납 처리(계약금+중도금+잔금).', 'system-seed'),
('inv-smf-0002', 'e-2026-smf', NULL, '델타로보틱스', 'SMF-2026-0002',
9000000, 900000, 9900000, 3960000, 'invoiced',
DATE '2026-07-25', now() - interval '18 day', NULL,
'[{"category":"RENTAL","description":"부스 임대료 (72㎡ · B-204)","quantity":72,"unitPrice":112500,"amount":8100000},{"category":"UTILITY","description":"급배수 유틸리티","quantity":1,"unitPrice":900000,"amount":900000}]'::jsonb,
'계약금 40% 수납.', 'system-seed'),
('inv-smf-0003', 'e-2026-smf', NULL, '한국센서테크', 'SMF-2026-0003',
5400000, 540000, 5940000, 0, 'overdue',
DATE '2026-07-10', now() - interval '30 day', NULL,
'[{"category":"RENTAL","description":"부스 임대료 (54㎡ · C-310)","quantity":54,"unitPrice":100000,"amount":5400000}]'::jsonb,
'납부 기한 경과 — 리마인더 발송 대상.', 'system-seed'),
('inv-smf-0004', 'e-2026-smf', NULL, '비전인더스트리', 'SMF-2026-0004',
13500000, 1350000, 14850000, 0, 'pending',
DATE '2026-08-01', NULL, NULL,
'[{"category":"RENTAL","description":"부스 임대료 (144㎡ · D-402)","quantity":144,"unitPrice":93750,"amount":13500000}]'::jsonb,
'발행 대기(초안).', 'system-seed')
ON CONFLICT (event_id, exhibitor_name) DO NOTHING;
INSERT INTO invoice_payment (id, invoice_id, amount, method, memo, recorded_by, recorded_at) VALUES
('pay-smf-0001-1', 'inv-smf-0001', 3960000, 'transfer', '계약금 20%', 'system-seed', now() - interval '18 day'),
('pay-smf-0001-2', 'inv-smf-0001', 9900000, 'transfer', '중도금 50%', 'system-seed', now() - interval '10 day'),
('pay-smf-0001-3', 'inv-smf-0001', 5940000, 'transfer', '잔금 30%', 'system-seed', now() - interval '5 day'),
('pay-smf-0002-1', 'inv-smf-0002', 3960000, 'transfer', '계약금 40%', 'system-seed', now() - interval '8 day')
ON CONFLICT (id) DO NOTHING;

View File

@ -0,0 +1,25 @@
-- M1 홀 배정·자동 견적 — hall_assignment 점유기간/상태 보강 (멱등)
-- 배경: 기존 hall_assignment(V2)는 (event_id, hall_id, is_primary)만 보유해
-- "같은 홀·기간 겹침" 충돌 검사가 불가하다. 점유기간·상태·감사 컬럼을 순증한다.
-- 규칙: V1~V24 불변. ADD COLUMN IF NOT EXISTS + 백필 UPDATE + IF NOT EXISTS 인덱스로 재실행 안전.
ALTER TABLE hall_assignment ADD COLUMN IF NOT EXISTS occupy_start date;
ALTER TABLE hall_assignment ADD COLUMN IF NOT EXISTS occupy_end date;
ALTER TABLE hall_assignment ADD COLUMN IF NOT EXISTS status varchar(20) NOT NULL DEFAULT 'assigned';
ALTER TABLE hall_assignment ADD COLUMN IF NOT EXISTS note varchar(500);
ALTER TABLE hall_assignment ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE hall_assignment ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
-- 점유기간 미설정(기존 시드) 행은 행사 개최 기간으로 백필.
-- 점유기간은 반입~반출을 포함하나, 실측 반입/반출 일자가 없으므로 개최일 기준으로 근사(추정 백필).
UPDATE hall_assignment ha
SET occupy_start = e.start_date,
occupy_end = e.end_date
FROM event e
WHERE ha.event_id = e.id
AND ha.occupy_start IS NULL
AND e.start_date IS NOT NULL;
-- 홀·기간 겹침 충돌 검사 가속 인덱스.
CREATE INDEX IF NOT EXISTS idx_hall_assignment_hall_period
ON hall_assignment (hall_id, occupy_start, occupy_end);

View File

@ -0,0 +1,49 @@
package com.zioinfo.kintex.security;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
/**
* OTP 챌린지 재시도 시맨틱 회귀 테스트(2026-07-12 라이브 장애).
* 오입력 1회가 챌린지를 태우면 "코드 틀림 → 세션 만료" 루프에 갇힌다
* 실패 챌린지는 유지(5회 한도), 소비는 검증 성공 시에만.
*/
class OtpChallengeStoreTest {
@Test
void failureKeepsChallengeAliveUntilLimit() {
OtpChallengeStore store = new OtpChallengeStore();
String token = store.issue("usr-1");
// 실패 4회까지는 챌린지 유지(재시도 가능)
for (int i = 1; i <= 4; i++) {
assertTrue(store.recordFailure(token), "실패 " + i + "회차엔 챌린지가 살아 있어야 한다");
assertEquals("usr-1", store.peek(token));
}
// 5회째 실패 무효화
assertFalse(store.recordFailure(token));
assertNull(store.peek(token));
assertNull(store.consume(token));
}
@Test
void consumeIsOneShot() {
OtpChallengeStore store = new OtpChallengeStore();
String token = store.issue("usr-2");
assertEquals("usr-2", store.peek(token));
assertEquals("usr-2", store.consume(token));
assertNull(store.consume(token));
}
@Test
void unknownTokenIsRejected() {
OtpChallengeStore store = new OtpChallengeStore();
assertFalse(store.recordFailure("no-such-token"));
assertNull(store.peek(null));
assertNull(store.consume(null));
}
}

View File

@ -51,6 +51,8 @@ import { SystemSettingsPage } from './screens/admin/SystemSettingsPage';
import { RulesetVersionsPage } from './screens/admin/RulesetVersionsPage'; import { RulesetVersionsPage } from './screens/admin/RulesetVersionsPage';
import { TenantAdminPage } from './screens/admin/TenantAdminPage'; import { TenantAdminPage } from './screens/admin/TenantAdminPage';
import { AdminGuard } from './screens/admin/AdminGuard'; import { AdminGuard } from './screens/admin/AdminGuard';
import { SettlementDashboardPage } from './screens/settlement/SettlementDashboardPage';
import { HallAssignmentPage } from './screens/hallassign/HallAssignmentPage';
import { SysUserAdminPage } from './screens/admin/SysUserAdminPage'; import { SysUserAdminPage } from './screens/admin/SysUserAdminPage';
import { RoleAdminPage } from './screens/admin/RoleAdminPage'; import { RoleAdminPage } from './screens/admin/RoleAdminPage';
import { CommonCodeAdminPage } from './screens/admin/CommonCodeAdminPage'; import { CommonCodeAdminPage } from './screens/admin/CommonCodeAdminPage';
@ -150,12 +152,15 @@ export function App() {
<Route path="/ops/operations" element={<HallOperationsPage />} /> <Route path="/ops/operations" element={<HallOperationsPage />} />
{/* SCR-15 전시 일정 관리 (M1·M6) */} {/* SCR-15 전시 일정 관리 (M1·M6) */}
<Route path="/schedule" element={<ExhibitionSchedulePage />} /> <Route path="/schedule" element={<ExhibitionSchedulePage />} />
{/* M9 정산 대시보드 · M1 홀 배정+자동견적 */}
<Route path="/settlement" element={<SettlementDashboardPage />} />
<Route path="/halls" element={<HallAssignmentPage />} />
{/* SCR-16 관리자 대시보드 (M18 랜딩) */} {/* SCR-16 관리자 대시보드 (M18 랜딩) */}
<Route path="/admin" element={<AdminGuard><AdminDashboardPage /></AdminGuard>} /> <Route path="/admin" element={<AdminGuard><AdminDashboardPage /></AdminGuard>} />
{/* 관리자 — 로그인 슬라이드 관리 (M18) */} {/* 관리자 — 로그인 슬라이드 관리 (M18) */}
<Route path="/admin/login-slides" element={<AdminGuard><LoginSlideAdminPage /></AdminGuard>} /> <Route path="/admin/login-slides" element={<AdminGuard><LoginSlideAdminPage /></AdminGuard>} />
{/* SCR-17 컴포넌트 가이드 (내부 전용 · 네비 비노출) */} {/* SCR-17 컴포넌트 가이드 (내부 전용 · 네비 비노출) */}
<Route path="/_styleguide" element={<ComponentGuidePage />} /> {import.meta.env.DEV && <Route path="/_styleguide" element={<ComponentGuidePage />} />}
{/* §5B 공통 업무 기능 (SCR-39~48) — 역할 공통 영역 */} {/* §5B 공통 업무 기능 (SCR-39~48) — 역할 공통 영역 */}
<Route path="/work/worklog" element={<WorklogPage />} /> <Route path="/work/worklog" element={<WorklogPage />} />

View File

@ -69,6 +69,8 @@ export interface AuthUser {
userId: string; userId: string;
displayName: string; displayName: string;
hallManager: boolean; hallManager: boolean;
// 전역 역할(app_user.role_code — 'ADMIN' | 'MANAGER' | …). 관람객 등 미지정 계정은 null/undefined.
roleCode?: string | null;
} }
export interface LoginResponse { export interface LoginResponse {
accessToken: string; accessToken: string;
@ -136,6 +138,8 @@ export interface KintexPrincipal {
displayName: string; displayName: string;
eventRoles: Record<string, EventRole>; eventRoles: Record<string, EventRole>;
hallManager: boolean; hallManager: boolean;
// 전역 역할(app_user.role_code, JWT rc 클레임 복원). 미지정 시 null/undefined.
roleCode?: string | null;
} }
// ── 3. M2 플로어플랜 (SCR-03/04) ── // ── 3. M2 플로어플랜 (SCR-03/04) ──

View File

@ -1,6 +1,7 @@
import type { ComponentType } from 'react'; import type { ComponentType } from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { DdayChip } from '../ui/Badge'; import { DdayChip } from '../ui/Badge';
import { import {
IconAnalytics, IconAnalytics,
@ -21,24 +22,46 @@ import {
import './app-shell.css'; import './app-shell.css';
/** /**
* . `to` react-router NavLink( ), (#). * . `to` react-router NavLink( ).
* (· ) (:eventId) . * `scoped: true` (··) (:eventId)
* 4(·· ·) . * to useResolvedEventId ,
* ( ) (/login ) .
*/ */
const NAV_ITEMS: { key: string; label: string; Icon: ComponentType<IconProps>; to?: string }[] = [ const NAV_ITEMS: {
{ key: 'dashboard', label: '대시보드', Icon: IconDashboard }, key: string;
{ key: 'floorplan', label: '플로어플랜 스튜디오', Icon: IconFloorplan }, label: string;
{ key: 'exhibitors', label: '참가업체 관리', Icon: IconExhibitors }, Icon: ComponentType<IconProps>;
to?: string;
scoped?: boolean;
}[] = [
{ key: 'dashboard', label: '대시보드', Icon: IconDashboard, scoped: true },
{ key: 'floorplan', label: '플로어플랜 스튜디오', Icon: IconFloorplan, scoped: true },
{ key: 'exhibitors', label: '참가업체 관리', Icon: IconExhibitors, scoped: true },
{ key: 'schedule', label: '전시 일정', Icon: IconCalendar, to: '/schedule' }, { key: 'schedule', label: '전시 일정', Icon: IconCalendar, to: '/schedule' },
{ key: 'operations', label: '현장운영', Icon: IconOperations, to: '/ops/operations' }, { key: 'operations', label: '현장운영', Icon: IconOperations, to: '/ops/operations' },
{ key: 'analytics', label: '경영분석', Icon: IconAnalytics, to: '/analytics' }, { key: 'analytics', label: '경영분석', Icon: IconAnalytics, to: '/analytics' },
{ key: 'documents', label: '서류·마일스톤', Icon: IconDocument, to: '/docs' }, { key: 'documents', label: '서류·마일스톤', Icon: IconDocument, to: '/docs' },
{ key: 'settlement', label: '정산', Icon: IconSettlement }, // 정산 — 이번 웨이브 신설. 라우트(/settlement)는 통합자가 App.tsx 에 배선(요청서 §통합자 배선).
{ key: 'settlement', label: '정산', Icon: IconSettlement, to: '/settlement' },
{ key: 'admin', label: '관리자', Icon: IconSettings, to: '/admin' }, { key: 'admin', label: '관리자', Icon: IconSettings, to: '/admin' },
]; ];
/**
* (App.tsx ).
* id id 'H7'
* (LoginPage.enterWorkspace ).
*
* ( /events/:eventId/exhibitors ).
*/
const EVENT_SCOPED_PATH: Record<string, (eventId: string) => string> = {
dashboard: (id) => `/events/${id}/dashboard`,
floorplan: (id) => `/events/${id}/halls/H7/layout`,
exhibitors: (id) => `/events/${id}/dashboard`,
};
/** 도메인 모듈(SCR-25~38 이식분) — 백엔드 미구현 모듈은 화면에 "샘플 데이터" 배지 표기. */ /** 도메인 모듈(SCR-25~38 이식분) — 백엔드 미구현 모듈은 화면에 "샘플 데이터" 배지 표기. */
const DOMAIN_NAV: { key: string; label: string; Icon: ComponentType<IconProps>; to: string }[] = [ const DOMAIN_NAV: { key: string; label: string; Icon: ComponentType<IconProps>; to: string }[] = [
{ key: 'halls', label: '홀 배정', Icon: IconFloorplan, to: '/halls' },
{ key: 'logistics', label: '반입·반출', Icon: IconOperations, to: '/logistics' }, { key: 'logistics', label: '반입·반출', Icon: IconOperations, to: '/logistics' },
{ key: 'auctions', label: '공사 옥션', Icon: IconSettlement, to: '/auctions' }, { key: 'auctions', label: '공사 옥션', Icon: IconSettlement, to: '/auctions' },
{ key: 'visitors', label: '관람객', Icon: IconUsers, to: '/visitors' }, { key: 'visitors', label: '관람객', Icon: IconUsers, to: '/visitors' },
@ -70,6 +93,17 @@ export function AppShell() {
const workspace = useAuthStore((s) => s.currentWorkspace()); const workspace = useAuthStore((s) => s.currentWorkspace());
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate(); const navigate = useNavigate();
const resolvedEventId = useResolvedEventId();
/** 행사 스코프 항목 진입 — 대상 행사 해소 시 계산 경로로, 미해소 시 행사 선택(/login)으로. */
function goScoped(key: string) {
const build = EVENT_SCOPED_PATH[key];
if (resolvedEventId && build) {
navigate(build(resolvedEventId));
} else {
navigate('/login'); // 참여 행사 없음 → 워크스페이스 선택 그리드
}
}
return ( return (
<div className="kx-shell"> <div className="kx-shell">
@ -103,10 +137,14 @@ export function AppShell() {
{item.label} {item.label}
</NavLink> </NavLink>
) : ( ) : (
<a className="kx-shell__nav-link" href="#" aria-disabled="true"> <button
type="button"
className="kx-shell__nav-link kx-shell__nav-link--btn"
onClick={() => goScoped(item.key)}
>
<span className="kx-shell__nav-icon"><item.Icon size={20} /></span> <span className="kx-shell__nav-icon"><item.Icon size={20} /></span>
{item.label} {item.label}
</a> </button>
)} )}
</li> </li>
))} ))}

View File

@ -95,6 +95,15 @@
color: var(--color-primary-700); color: var(--color-primary-700);
font-weight: 600; font-weight: 600;
} }
/* 행사 스코프 진입(대시보드·플로어플랜·참가업체) — button 이지만 nav-link 와 동일 표현. */
.kx-shell__nav-link--btn {
width: 100%;
border: 0;
background: transparent;
text-align: left;
cursor: pointer;
font-family: inherit;
}
.kx-shell__nav-icon { .kx-shell__nav-icon {
width: 20px; width: 20px;
display: inline-flex; display: inline-flex;

View File

@ -3,11 +3,12 @@
* SystemAccessGuard.requireAdmin ( || app_user.role_code=ADMIN), * SystemAccessGuard.requireAdmin ( || app_user.role_code=ADMIN),
* UI . * UI .
* *
* 근거: 프론트 authStore role_code(ADMIN/MANAGER) * 근거: authStore(AuthUser) role_code(app_user.role_code) .
* AuthUser.hallManager( ) (EventRole) . * = roleCode==='ADMIN' || roleCode==='MANAGER' || hallManager===true,
* = hallManager===true, HALL_MANAGER. * HALL_MANAGER. roleCode user.roleCode
* ADMIN/MANAGER hallManager=false * ( null ). SystemAccessGuard.requireAdmin ,
* ( ). _workspace . * UI ( ) . ( hallManager ADMIN/MANAGER
* roleCode .)
* *
* ( ): element <AdminGuard> . App.tsx . * ( ): element <AdminGuard> . App.tsx .
*/ */
@ -21,8 +22,10 @@ export function useIsAdmin(): boolean {
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const roleForCurrent = useAuthStore((s) => s.roleForCurrent); const roleForCurrent = useAuthStore((s) => s.roleForCurrent);
if (!user) return false; if (!user) return false;
const role = (user.roleCode ?? '').toUpperCase();
if (role === 'ADMIN' || role === 'MANAGER') return true;
if (user.hallManager) return true; if (user.hallManager) return true;
return roleForCurrent() === 'HALL_MANAGER'; return roleForCurrent() === 'HALL_MANAGER'; // 폴백: 현재 행사 홀매니저 역할
} }
export function AdminGuard({ children }: { children: ReactNode }) { export function AdminGuard({ children }: { children: ReactNode }) {

View File

@ -0,0 +1,400 @@
/*
* SCR /halls M1 + .
* 진입: 사이드바 "홀 배정"(App.tsx ). =/, =·.
* : × ( ·) (··)
* (·· ).
* API 배선: GET /api/halls · /assignments · POST /assignments(409 ) · DELETE · POST /quote.
* client가 409 GET /conflicts() .
*/
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { IconWarning } from '../../components/ui/icons';
import { ApiRequestError } from '../../api/client';
import {
hallAssignApi,
type AssignmentBody,
type Conflict,
type Hall,
type HallAssignment,
type QuoteResult,
} from './hallAssignApi';
import './hallassign.css';
/** 두 폐구간 [aS,aE]·[bS,bE] 겹침 여부(YYYY-MM-DD 문자열 비교 가능). */
function overlaps(aS: string | null, aE: string | null, bS: string | null, bE: string | null): boolean {
if (!aS || !aE || !bS || !bE) return false;
return aS <= bE && bS <= aE;
}
/** 같은 홀에서 서로 겹치는 배정 id 집합(보드 충돌 하이라이트). */
function conflictingIds(list: HallAssignment[]): Set<string> {
const out = new Set<string>();
for (let i = 0; i < list.length; i += 1) {
for (let j = i + 1; j < list.length; j += 1) {
const a = list[i];
const b = list[j];
if (a.hallId === b.hallId && overlaps(a.occupyStart, a.occupyEnd, b.occupyStart, b.occupyEnd)) {
out.add(a.id);
out.add(b.id);
}
}
}
return out;
}
function fmtKrw(n: number): string {
return `${n.toLocaleString('ko-KR')}`;
}
const EMPTY_FORM: AssignmentBody = { eventId: '', hallId: '', primary: true, occupyStart: '', occupyEnd: '', note: '' };
export function HallAssignmentPage() {
const queryClient = useQueryClient();
const [from, setFrom] = useState('');
const [to, setTo] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [form, setForm] = useState<AssignmentBody>(EMPTY_FORM);
const [assignError, setAssignError] = useState<string | null>(null);
const [conflictHits, setConflictHits] = useState<Conflict[]>([]);
const [toast, setToast] = useState<string | null>(null);
const hallsQ = useQuery({ queryKey: ['halls'], queryFn: () => hallAssignApi.halls(), retry: false });
const assignQ = useQuery({
queryKey: ['hall-assignments', from, to],
queryFn: () => hallAssignApi.assignments(from || undefined, to || undefined),
retry: false,
});
const eventsQ = useQuery({ queryKey: ['hall-events'], queryFn: () => hallAssignApi.events(), retry: false, enabled: modalOpen });
const halls = hallsQ.data ?? [];
const assignments = assignQ.data ?? [];
const conflictSet = useMemo(() => conflictingIds(assignments), [assignments]);
const byHall = useMemo(() => {
const map = new Map<string, HallAssignment[]>();
for (const a of assignments) {
const arr = map.get(a.hallId) ?? [];
arr.push(a);
map.set(a.hallId, arr);
}
return map;
}, [assignments]);
// 관리자(홀매니저) 전용 API 접근이 막히면(403) 조회 자체가 실패 — 보드는 안내로 대체.
const boardForbidden = assignQ.error instanceof ApiRequestError && assignQ.error.code === 'FORBIDDEN';
const assignMut = useMutation({
mutationFn: (body: AssignmentBody) => hallAssignApi.assign(body),
onSuccess: () => {
setModalOpen(false);
setForm(EMPTY_FORM);
setAssignError(null);
setConflictHits([]);
setToast('홀이 배정되었습니다.');
queryClient.invalidateQueries({ queryKey: ['hall-assignments'] });
},
onError: async (err) => {
if (err instanceof ApiRequestError && err.code === 'CONFLICT') {
setAssignError('배정하려는 홀·기간이 다른 행사와 겹칩니다.');
// 공용 client가 409 본문을 소거하므로 충돌 상세를 사전검사로 재조회.
try {
const hits = await hallAssignApi.conflicts(
form.hallId,
form.occupyStart || '',
form.occupyEnd || '',
form.eventId,
);
setConflictHits(hits);
} catch {
setConflictHits([]);
}
} else {
setAssignError(err instanceof ApiRequestError ? err.message : '배정에 실패했습니다.');
}
},
});
const releaseMut = useMutation({
mutationFn: (id: string) => hallAssignApi.release(id),
onSuccess: () => {
setToast('배정이 해제되었습니다.');
queryClient.invalidateQueries({ queryKey: ['hall-assignments'] });
},
onError: () => setToast('해제에 실패했습니다(권한 확인).'),
});
function openModal() {
setForm(EMPTY_FORM);
setAssignError(null);
setConflictHits([]);
setModalOpen(true);
}
function onPickEvent(eventId: string) {
const ev = eventsQ.data?.find((e) => e.id === eventId);
setForm((f) => ({
...f,
eventId,
occupyStart: ev?.startDate ?? f.occupyStart,
occupyEnd: ev?.endDate ?? f.occupyEnd,
}));
}
function submitAssign() {
if (!form.eventId || !form.hallId) {
setAssignError('행사와 홀을 선택하세요.');
return;
}
setAssignError(null);
setConflictHits([]);
assignMut.mutate(form);
}
return (
<div className="kx-page kx-hall">
{toast && <div className="kx-hall__toast" role="status" onClick={() => setToast(null)}>{toast}</div>}
<header className="kx-hall__top">
<div>
<h1> · </h1>
<p className="kx-card__hint">× (M1).</p>
</div>
<div className="kx-hall__filter">
<label>
<input className="kx-select" type="date" value={from} onChange={(e) => setFrom(e.target.value)} />
</label>
<label>
<input className="kx-select" type="date" value={to} onChange={(e) => setTo(e.target.value)} />
</label>
<Button variant="primary" onClick={openModal}> </Button>
</div>
</header>
{/* ── 배정 보드 ── */}
<section className="kx-card">
<div className="kx-card__head">
<h2> </h2>
<span className="kx-card__hint"> .</span>
</div>
{assignQ.isLoading || hallsQ.isLoading ? (
<div className="kx-hall__rows"><Skeleton height={40} /><Skeleton height={40} /><Skeleton height={40} /></div>
) : boardForbidden ? (
<EmptyState title="홀매니저 전용 보드" description="배정 현황 조회는 킨텍스 홀매니저 계정만 가능합니다. 자동 견적은 아래에서 이용하세요." />
) : assignQ.error ? (
<ErrorState onRetry={() => assignQ.refetch()} />
) : halls.length === 0 ? (
<EmptyState title="홀 마스터가 없습니다." />
) : (
<div className="kx-hall__board">
{halls.map((h: Hall) => {
const rows = byHall.get(h.id) ?? [];
return (
<div className="kx-hall__row" key={h.id}>
<div className="kx-hall__rowhead">
<span className="kx-hall__hallname">{h.label}</span>
<span className="kx-hall__area">{h.areaM2?.toLocaleString('ko-KR')}</span>
</div>
<div className="kx-hall__chips">
{rows.length === 0 ? (
<span className="kx-hall__idle"> </span>
) : (
rows.map((a) => {
const conflict = conflictSet.has(a.id);
return (
<span
key={a.id}
className={`kx-hall__chip${conflict ? ' is-conflict' : ''}${a.primary ? ' is-primary' : ''}`}
title={`${a.occupyStart ?? '?'} ~ ${a.occupyEnd ?? '?'}`}
>
{conflict && <IconWarning size={13} />}
<span className="kx-hall__chiptext">
{a.eventName}
<em>{a.occupyStart} ~ {a.occupyEnd}</em>
</span>
<button
type="button"
className="kx-hall__chipx"
aria-label="배정 해제"
onClick={() => {
if (window.confirm(`${a.eventName}${a.hallLabel} 배정을 해제할까요?`)) {
releaseMut.mutate(a.id);
}
}}
>×</button>
</span>
);
})
)}
</div>
</div>
);
})}
</div>
)}
</section>
{/* ── 자동 견적 패널 ── */}
<QuotePanel halls={halls} />
{/* ── 배정 모달 ── */}
{modalOpen && (
<div className="kx-hall__backdrop" onClick={() => setModalOpen(false)}>
<div className="kx-hall__modal" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
<div className="kx-card__head">
<h2> </h2>
<button type="button" className="kx-hall__chipx" aria-label="닫기" onClick={() => setModalOpen(false)}>×</button>
</div>
<div className="kx-hall__form">
<label>
<select className="kx-select" value={form.eventId} onChange={(e) => onPickEvent(e.target.value)}>
<option value=""> </option>
{(eventsQ.data ?? []).map((e) => (
<option key={e.id} value={e.id}>{e.name}</option>
))}
</select>
</label>
<label>
<select className="kx-select" value={form.hallId} onChange={(e) => setForm((f) => ({ ...f, hallId: e.target.value }))}>
<option value=""> </option>
{halls.map((h) => (
<option key={h.id} value={h.id}>{h.label}</option>
))}
</select>
</label>
<div className="kx-hall__form2">
<label>
<input className="kx-select" type="date" value={form.occupyStart ?? ''} onChange={(e) => setForm((f) => ({ ...f, occupyStart: e.target.value }))} />
</label>
<label>
<input className="kx-select" type="date" value={form.occupyEnd ?? ''} onChange={(e) => setForm((f) => ({ ...f, occupyEnd: e.target.value }))} />
</label>
</div>
<label className="kx-hall__check">
<input type="checkbox" checked={form.primary} onChange={(e) => setForm((f) => ({ ...f, primary: e.target.checked }))} />
(primary)
</label>
<label>
<input className="kx-select" type="text" value={form.note ?? ''} onChange={(e) => setForm((f) => ({ ...f, note: e.target.value }))} placeholder="선택 입력" />
</label>
{assignError && (
<div className="kx-hall__err" role="alert">
<IconWarning size={15} /> {assignError}
{conflictHits.length > 0 && (
<ul className="kx-hall__conflicts">
{conflictHits.map((c) => (
<li key={c.assignmentId}>{c.eventName} · {c.hallLabel} ({c.occupyStart} ~ {c.occupyEnd})</li>
))}
</ul>
)}
</div>
)}
</div>
<div className="kx-hall__modalfoot">
<Button variant="ghost" onClick={() => setModalOpen(false)}></Button>
<Button variant="primary" onClick={submitAssign} disabled={assignMut.isPending}>
{assignMut.isPending ? '배정 중…' : '배정'}
</Button>
</div>
</div>
</div>
)}
</div>
);
}
/** 자동 견적 패널 — 홀 다중 선택 + 기간 + 부스 규모 → 요율 룰셋 라인. */
function QuotePanel({ halls }: { halls: Hall[] }) {
const [picked, setPicked] = useState<string[]>([]);
const [start, setStart] = useState('');
const [end, setEnd] = useState('');
const [boothCount, setBoothCount] = useState('');
const [result, setResult] = useState<QuoteResult | null>(null);
const [err, setErr] = useState<string | null>(null);
const quoteMut = useMutation({
mutationFn: () =>
hallAssignApi.quote({
hallIds: picked,
startDate: start,
endDate: end,
boothCount: boothCount ? Number(boothCount) : undefined,
}),
onSuccess: (r) => { setResult(r); setErr(null); },
onError: (e) => { setErr(e instanceof ApiRequestError ? e.message : '견적 계산에 실패했습니다.'); setResult(null); },
});
function toggleHall(id: string) {
setPicked((p) => (p.includes(id) ? p.filter((x) => x !== id) : [...p, id]));
}
function run() {
if (picked.length === 0 || !start || !end) {
setErr('홀·시작일·종료일을 입력하세요.');
return;
}
setErr(null);
quoteMut.mutate();
}
return (
<section className="kx-card">
<div className="kx-card__head">
<h2> </h2>
<span className="kx-card__hint"> · </span>
</div>
<div className="kx-hall__quoteform">
<div className="kx-hall__hallpick">
{halls.map((h) => (
<label key={h.id} className={`kx-hall__pill${picked.includes(h.id) ? ' is-on' : ''}`}>
<input type="checkbox" checked={picked.includes(h.id)} onChange={() => toggleHall(h.id)} />
{h.label}
</label>
))}
</div>
<div className="kx-hall__quoteinputs">
<label><input className="kx-select" type="date" value={start} onChange={(e) => setStart(e.target.value)} /></label>
<label><input className="kx-select" type="date" value={end} onChange={(e) => setEnd(e.target.value)} /></label>
<label> ()<input className="kx-select" type="number" min={0} value={boothCount} onChange={(e) => setBoothCount(e.target.value)} placeholder="개수" /></label>
<Button variant="ai" onClick={run} disabled={quoteMut.isPending}>{quoteMut.isPending ? '계산 중…' : '견적 계산'}</Button>
</div>
</div>
{err && <div className="kx-hall__err" role="alert"><IconWarning size={15} /> {err}</div>}
{result && (
<div className="kx-hall__quoteresult">
<table className="kx-table">
<thead>
<tr><th></th><th> </th><th className="kx-hall__num"></th></tr>
</thead>
<tbody>
{result.lines.map((l, i) => (
<tr key={`${l.code}-${i}`} className={l.amount < 0 ? 'kx-hall__discount' : ''}>
<td>{l.label}</td>
<td className="kx-hall__basis">{l.basis}</td>
<td className="kx-hall__num">{fmtKrw(l.amount)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr><td colSpan={2}> </td><td className="kx-hall__num">{fmtKrw(result.rentalSubtotal)}</td></tr>
<tr className="kx-hall__total"><td colSpan={2}> ({result.days})</td><td className="kx-hall__num">{fmtKrw(result.total)}</td></tr>
<tr><td colSpan={2}> ()</td><td className="kx-hall__num">{fmtKrw(result.depositMin)} ~ {fmtKrw(result.depositMax)}</td></tr>
</tfoot>
</table>
{result.capacityNote && <p className="kx-hall__note"><IconWarning size={14} /> {result.capacityNote}</p>}
{result.disclaimer && <p className="kx-card__hint">{result.disclaimer} ( {result.rulesetVersion})</p>}
</div>
)}
</section>
);
}

View File

@ -0,0 +1,115 @@
/*
* M1 · (SCR /halls) API .
* (../../api/client) client.ts·endpoints.ts·types.ts .
* 정본: 백엔드 HallAssignController
* - GET /api/halls ApiResponse<Hall[]>
* - GET /api/halls/assignments?from=&to=&eventId= ApiResponse<HallAssignment[]> ()
* - GET /api/halls/conflicts?hallId=&from=&to=&excludeEventId= ApiResponse<Conflict[]> ()
* - POST /api/halls/assignments ApiResponse<HallAssignment> ( 409 CONFLICT)
* - DELETE /api/halls/assignments/{id} ApiResponse<void> ()
* - POST /api/halls/quote ApiResponse<QuoteResult> ()
*/
import { api } from '../../api/client';
export interface Hall {
id: string;
exhibitionCenter: number;
label: string;
areaM2: number;
floorType: string | null;
boothCapacity: number | null;
}
export interface HallAssignment {
id: string;
eventId: string;
eventName: string;
hallId: string;
hallLabel: string;
primary: boolean;
occupyStart: string | null;
occupyEnd: string | null;
status: string;
note: string | null;
}
export interface Conflict {
assignmentId: string;
eventId: string;
eventName: string;
hallId: string;
hallLabel: string;
occupyStart: string;
occupyEnd: string;
}
export interface AssignmentBody {
eventId: string;
hallId: string;
primary: boolean;
occupyStart?: string;
occupyEnd?: string;
note?: string;
}
export interface QuoteLine {
code: string;
label: string;
basis: string;
amount: number;
}
export interface QuoteResult {
rulesetVersion: string;
currency: string;
days: number;
lines: QuoteLine[];
rentalSubtotal: number;
total: number;
depositMin: number;
depositMax: number;
capacityNote: string | null;
disclaimer: string | null;
}
export interface QuoteBody {
hallIds: string[];
startDate: string;
endDate: string;
boothCount?: number;
}
/** 배정 모달 행사 선택용(카탈로그 /api/events 재사용, id·name·기간만 취함). */
export interface EventOption {
id: string;
name: string;
startDate: string | null;
endDate: string | null;
}
export const hallAssignApi = {
halls: () => api.get<Hall[]>('/api/halls'),
assignments: (from?: string, to?: string, eventId?: string) => {
const qs = new URLSearchParams();
if (from) qs.set('from', from);
if (to) qs.set('to', to);
if (eventId) qs.set('eventId', eventId);
const q = qs.toString();
return api.get<HallAssignment[]>(`/api/halls/assignments${q ? `?${q}` : ''}`);
},
conflicts: (hallId: string, from: string, to: string, excludeEventId?: string) => {
const qs = new URLSearchParams({ hallId, from, to });
if (excludeEventId) qs.set('excludeEventId', excludeEventId);
return api.get<Conflict[]>(`/api/halls/conflicts?${qs.toString()}`);
},
assign: (body: AssignmentBody) => api.post<HallAssignment>('/api/halls/assignments', body),
release: (id: string) => api.del<void>(`/api/halls/assignments/${encodeURIComponent(id)}`),
quote: (body: QuoteBody) => api.post<QuoteResult>('/api/halls/quote', body),
events: () => api.get<EventOption[]>('/api/events?size=500'),
};

View File

@ -0,0 +1,261 @@
/*
* SCR /halls M1 배정·자동 견적 전용 스타일.
* design.md §1 토큰만 참조. shared.css 프리미티브(kx-page·kx-card·kx-select·kx-table) 위에 얹는다.
* 클래스는 kx-hall__* 네임스페이스.
*/
@import '../shared.css';
.kx-hall__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
flex-wrap: wrap;
}
.kx-hall__top h1 {
font-size: var(--fs-h2);
color: var(--color-neutral-900);
}
.kx-hall__filter {
display: flex;
align-items: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
}
.kx-hall__filter label,
.kx-hall__form label,
.kx-hall__quoteinputs label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
/* 배정 보드 */
.kx-hall__board {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-hall__rows {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-hall__row {
display: grid;
grid-template-columns: 180px 1fr;
gap: var(--space-3);
align-items: center;
padding: var(--space-2) 0;
border-bottom: var(--border-card);
}
.kx-hall__rowhead {
display: flex;
flex-direction: column;
gap: 2px;
}
.kx-hall__hallname {
font-weight: 600;
color: var(--color-neutral-900);
font-size: var(--fs-body);
}
.kx-hall__area {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-hall__chips {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.kx-hall__idle {
font-size: var(--fs-caption);
color: var(--color-neutral-400);
}
.kx-hall__chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 6px 4px 10px;
border-radius: var(--radius-sm);
background: var(--color-neutral-100);
border: 1px solid var(--color-neutral-200);
font-size: var(--fs-caption);
color: var(--color-neutral-700);
}
.kx-hall__chip.is-primary {
border-color: var(--color-brand-500, #1f29fc);
}
.kx-hall__chip.is-conflict {
background: #fef3f2;
border-color: var(--color-error);
color: var(--color-error);
}
.kx-hall__chiptext {
display: flex;
flex-direction: column;
line-height: 1.25;
}
.kx-hall__chiptext em {
font-style: normal;
font-size: 11px;
color: var(--color-neutral-500);
}
.kx-hall__chip.is-conflict .kx-hall__chiptext em {
color: var(--color-error);
}
.kx-hall__chipx {
border: none;
background: transparent;
cursor: pointer;
font-size: 16px;
line-height: 1;
color: var(--color-neutral-400);
padding: 0 2px;
}
.kx-hall__chipx:hover {
color: var(--color-error);
}
/* 자동 견적 */
.kx-hall__quoteform {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-hall__hallpick {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.kx-hall__pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border-radius: var(--radius-pill);
border: var(--border-card);
font-size: var(--fs-caption);
color: var(--color-neutral-600);
cursor: pointer;
}
.kx-hall__pill.is-on {
border-color: var(--color-ai-accent);
background: #eef8ff;
color: var(--color-neutral-900);
}
.kx-hall__pill input {
margin: 0;
}
.kx-hall__quoteinputs {
display: flex;
align-items: flex-end;
gap: var(--space-3);
flex-wrap: wrap;
}
.kx-hall__quoteresult {
margin-top: var(--space-3);
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-hall__num {
text-align: right;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.kx-hall__basis {
font-size: var(--fs-caption);
color: var(--color-neutral-500);
}
.kx-hall__discount td {
color: var(--color-success);
}
.kx-hall__total td {
font-weight: 700;
color: var(--color-neutral-900);
border-top: 2px solid var(--color-neutral-200);
}
.kx-hall__note {
display: flex;
align-items: center;
gap: 6px;
font-size: var(--fs-caption);
color: var(--color-warning);
}
/* 모달 */
.kx-hall__backdrop {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 40;
padding: var(--space-4);
}
.kx-hall__modal {
background: var(--color-white);
border-radius: var(--radius-lg);
padding: var(--space-4);
width: 100%;
max-width: 440px;
max-height: 90vh;
overflow-y: auto;
box-shadow: var(--shadow-lg, 0 12px 32px rgba(0, 0, 0, 0.18));
}
.kx-hall__form {
display: flex;
flex-direction: column;
gap: var(--space-3);
}
.kx-hall__form2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-3);
}
.kx-hall__check {
flex-direction: row !important;
align-items: center;
gap: 8px !important;
color: var(--color-neutral-700) !important;
font-size: var(--fs-body) !important;
}
.kx-hall__modalfoot {
display: flex;
justify-content: flex-end;
gap: var(--space-2);
margin-top: var(--space-4);
}
.kx-hall__err {
display: flex;
flex-direction: column;
gap: 4px;
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: #fef3f2;
color: var(--color-error);
font-size: var(--fs-caption);
}
.kx-hall__conflicts {
margin: 4px 0 0;
padding-left: 18px;
}
/* 토스트 */
.kx-hall__toast {
position: fixed;
bottom: var(--space-5);
left: 50%;
transform: translateX(-50%);
background: var(--color-neutral-900);
color: var(--color-white);
padding: 10px 18px;
border-radius: var(--radius-pill);
font-size: var(--fs-caption);
z-index: 50;
cursor: pointer;
}

View File

@ -1,11 +1,9 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { occupancyTone } from '../chartColors';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States'; import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { opsApi } from '../../api/endpoints'; import { opsApiLocal, type OpsDataExt } from './opsApi';
import { ApiRequestError } from '../../api/client'; import { ApiRequestError } from '../../api/client';
import { useResolvedEventId } from '../../hooks/useResolvedEventId'; import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import type { OpsData } from '../../api/types';
import { import {
HALL_CELLS, HALL_CELLS,
HVAC as SAMPLE_HVAC, HVAC as SAMPLE_HVAC,
@ -15,11 +13,13 @@ import {
import './ops.css'; import './ops.css';
/* /*
* SCR-16 (M14). Stitch hall_operations . * SCR-14 (M14). Stitch hall_operations .
* 경로: GET /api/events/{eventId}/ops ( + HVAC//). * 경로: GET /api/events/{eventId}/ops ( OpsDto ).
* ·IoT (PLANNING M14) estPeople=0·hvac=null·/ . * (halls.estPeople/heat) = M10 ().
* UI로 ( ). * (halls.powerKw/powerTotalKw) = M4 (requestedKw) .
* 폴백: NETWORK/NOT_FOUND sampleOps . * checkinSeries = ( ).
* HVAC// = ·IoT (PLANNING M14) null/ "센서(M14) 연동 시 표시".
* 폴백: NETWORK/NOT_FOUND/NOT_IMPLEMENTED sampleOps + "오프라인 — 샘플" .
*/ */
export function HallOperationsPage() { export function HallOperationsPage() {
const [mode, setMode] = useState<'live' | 'history'>('live'); const [mode, setMode] = useState<'live' | 'history'>('live');
@ -27,26 +27,31 @@ export function HallOperationsPage() {
const q = useQuery({ const q = useQuery({
queryKey: ['ops', eventId], queryKey: ['ops', eventId],
queryFn: () => opsApi.get(eventId as string), queryFn: () => opsApiLocal.get(eventId as string),
enabled: !!eventId, enabled: !!eventId,
retry: false, retry: false,
}); });
const degraded = isDegradable(q.error); const degraded = isDegradable(q.error);
const data: OpsData | null = q.data ?? (degraded ? FALLBACK : null); const data: OpsDataExt | null = q.data ?? (degraded ? FALLBACK : null);
const hardError = q.isError && !degraded; const hardError = q.isError && !degraded;
const hvac = data?.hvac; const hvac = data?.hvac;
const hasHvac = !!hvac && (hvac.tempC != null || hvac.humidityPercent != null || hvac.co2Ppm != null); const hasHvac = !!hvac && (hvac.tempC != null || hvac.humidityPercent != null || hvac.co2Ppm != null);
const totalPeople = data?.halls.reduce((s, h) => s + h.estPeople, 0) ?? 0; const onSite = data?.onSiteCount ?? 0;
const hasPeople = totalPeople > 0; const hasPeople = onSite > 0;
const powerTotal = data?.powerTotalKw ?? null;
const powerHalls = (data?.halls ?? []).filter((h) => h.powerKw != null && h.powerKw > 0);
const maxPowerKw = powerHalls.reduce((m, h) => Math.max(m, h.powerKw ?? 0), 0);
const series = data?.checkinSeries ?? [];
const maxSeries = series.reduce((m, p) => Math.max(m, p.count), 0);
return ( return (
<div className="kx-ops"> <div className="kx-ops">
<header className="kx-ops__head"> <header className="kx-ops__head">
<div> <div>
<h1 className="kx-ops__title"> </h1> <h1 className="kx-ops__title"> </h1>
<p className="kx-ops__subtitle"> </p> <p className="kx-ops__subtitle">· </p>
</div> </div>
<div className="kx-seg" role="tablist" aria-label="조회 모드"> <div className="kx-seg" role="tablist" aria-label="조회 모드">
<button role="tab" aria-selected={mode === 'live'} className={`kx-seg__btn ${mode === 'live' ? 'is-active' : ''}`} onClick={() => setMode('live')}> <button role="tab" aria-selected={mode === 'live'} className={`kx-seg__btn ${mode === 'live' ? 'is-active' : ''}`} onClick={() => setMode('live')}>
@ -77,13 +82,13 @@ export function HallOperationsPage() {
{data && ( {data && (
<div className="kx-ops__grid"> <div className="kx-ops__grid">
{/* 히어로: 홀 혼잡 */} {/* 히어로: 홀 혼잡(체크인 파생) */}
<section className="kx-card kx-ops__heat" aria-label="홀별 현황"> <section className="kx-card kx-ops__heat" aria-label="홀별 현황">
<div className="kx-card__head"> <div className="kx-card__head">
<h2> </h2> <h2> </h2>
</div> </div>
<span className="kx-ops__sensor-badge"> <span className="kx-ops__sensor-badge">
{hasPeople ? '체크인(M10) 파생' : '센서 미연동 · 배정 홀 목록'} {hasPeople ? '체크인(M10) 파생 · 면적 비중 분배' : '센서 미연동 · 배정 홀 목록'}
</span> </span>
{data.halls.length === 0 ? ( {data.halls.length === 0 ? (
@ -109,9 +114,9 @@ export function HallOperationsPage() {
</ul> </ul>
<span className="kx-ops__total"> <span className="kx-ops__total">
{hasPeople ? ( {hasPeople ? (
<> <strong className="tnum">{totalPeople.toLocaleString()}</strong></> <> <strong className="tnum">{onSite.toLocaleString()}</strong></>
) : ( ) : (
<> </> <> </>
)} )}
</span> </span>
</div> </div>
@ -119,15 +124,80 @@ export function HallOperationsPage() {
)} )}
</section> </section>
{/* 우측: HVAC + 조명존 */} {/* 우측: 전력 부하(M4 실집계) + 시간대 추이 */}
<aside className="kx-ops__aside"> <aside className="kx-ops__aside">
<section className="kx-card" aria-label="공조(HVAC)"> <section className="kx-card" aria-label="전력 부하">
<div className="kx-card__head"> <div className="kx-card__head">
<h2> (HVAC)</h2> <h2> </h2>
{powerTotal != null && (
<span className="kx-ops__total"> <strong className="tnum">{formatKw(powerTotal)}</strong>kW</span>
)}
</div> </div>
{!hasHvac ? ( <span className="kx-ops__sensor-badge"> (M4) </span>
<EmptyState title="공조 계측 미연동" description="HVAC 센서가 연동되면 온·습도·CO₂가 표시됩니다." /> {powerHalls.length === 0 ? (
<EmptyState title="신청 부하 없음" description="전기 유틸리티 신청이 접수되면 홀별 부하가 표시됩니다." />
) : ( ) : (
<ul className="kx-barlist">
{powerHalls.map((h) => {
const kw = h.powerKw ?? 0;
const pct = maxPowerKw > 0 ? Math.round((kw / maxPowerKw) * 100) : 0;
return (
<li key={h.hallId} className="kx-barlist__item">
<div className="kx-barlist__top">
<span>{h.label}</span>
<span className="tnum">{formatKw(kw)}kW</span>
</div>
<div className="kx-barlist__track">
<span className="kx-barlist__fill" style={{ width: `${pct}%` }} />
</div>
</li>
);
})}
</ul>
)}
</section>
<section className="kx-card" aria-label="시간대별 체크인">
<div className="kx-card__head">
<h2> </h2>
</div>
<span className="kx-ops__sensor-badge">(M10) </span>
{series.length === 0 ? (
<EmptyState title="체크인 추이 없음" description="현장 체크인이 기록되면 시간대별 추이가 표시됩니다." />
) : (
<ul className="kx-barlist kx-ops__series">
{series.map((p) => {
const pct = maxSeries > 0 ? Math.round((p.count / maxSeries) * 100) : 0;
return (
<li key={p.hour} className="kx-barlist__item">
<div className="kx-barlist__top">
<span className="tnum">{p.hour}</span>
<span className="tnum">{p.count.toLocaleString()}</span>
</div>
<div className="kx-barlist__track">
<span className="kx-barlist__fill" style={{ width: `${pct}%` }} />
</div>
</li>
);
})}
</ul>
)}
</section>
</aside>
{/* 하단: 시설 센서(미연동) — 정직한 빈 상태 */}
<section className="kx-card kx-ops__parking" aria-label="시설 센서 연동">
<div className="kx-card__head">
<h2> (··)</h2>
</div>
{!hasHvac && data.lighting.length === 0 && data.parking.length === 0 ? (
<EmptyState
title="시설 센서 미연동"
description="공조(HVAC)·조명 IoT·주차(iparking) 연계는 센서(M14) 연동 시 표시됩니다."
/>
) : (
<div className="kx-ops__sensor-grid">
{hasHvac && (
<div className="kx-ops__hvac-meta"> <div className="kx-ops__hvac-meta">
<div> <div>
<span className="kx-ops__meta-k"></span> <span className="kx-ops__meta-k"></span>
@ -143,59 +213,14 @@ export function HallOperationsPage() {
</div> </div>
</div> </div>
)} )}
</section>
<section className="kx-card" aria-label="조명 존">
<div className="kx-card__head">
<h2> </h2>
</div>
{data.lighting.length === 0 ? (
<EmptyState title="조명 IoT 미연동" description="조명 제어 존이 연동되면 점등률이 표시됩니다." />
) : (
<ul className="kx-barlist">
{data.lighting.map((z) => ( {data.lighting.map((z) => (
<li key={z.zone} className="kx-barlist__item"> <div key={z.zone} className="kx-ops__meta-k">{z.zone}: {z.onPercent}%</div>
<div className="kx-barlist__top">
<span>{z.zone}</span>
<span className="tnum">{z.onPercent}%</span>
</div>
<div className="kx-barlist__track">
<span className="kx-barlist__fill" style={{ width: `${z.onPercent}%` }} />
</div>
</li>
))} ))}
</ul> {data.parking.map((z) => (
)} <div key={z.zone} className="kx-ops__meta-k">
</section> {z.zone}: {z.occupancy.toLocaleString()}/{z.capacity.toLocaleString()}
</aside>
{/* 하단: 주차 점유 */}
<section className="kx-card kx-ops__parking" aria-label="주차 점유 현황">
<div className="kx-card__head">
<h2> </h2>
</div> </div>
{data.parking.length === 0 ? ( ))}
<EmptyState title="주차 연계 미연동" description="iparking 연계가 활성화되면 존별 점유가 표시됩니다." />
) : (
<div className="kx-ops__zones">
{data.parking.map((z) => {
const pct = z.capacity > 0 ? Math.round((z.occupancy / z.capacity) * 100) : 0;
const tone = occupancyTone(pct);
return (
<div key={z.zone} className="kx-ops__zone">
<div className="kx-ops__zone-top">
<span className="kx-ops__zone-label">{z.zone}</span>
<strong className={`kx-ops__zone-pct tnum is-${tone}`}>{pct}%</strong>
</div>
<div className="kx-barlist__track">
<span className={`kx-barlist__fill is-${tone}`} style={{ width: `${pct}%` }} />
</div>
<span className="kx-ops__zone-count tnum">
{z.occupancy.toLocaleString()} / {z.capacity.toLocaleString()}
</span>
</div>
);
})}
</div> </div>
)} )}
</section> </section>
@ -219,6 +244,10 @@ function heatClass(heat: string): string {
return 'smooth'; return 'smooth';
} }
function formatKw(kw: number): string {
return kw % 1 === 0 ? kw.toLocaleString() : kw.toFixed(1);
}
function isDegradable(error: unknown): boolean { function isDegradable(error: unknown): boolean {
return ( return (
error instanceof ApiRequestError && error instanceof ApiRequestError &&
@ -227,12 +256,21 @@ function isDegradable(error: unknown): boolean {
} }
/** /**
* ( shape ) API. NETWORK/NOT_FOUND . * ( shape ) API. NETWORK/NOT_FOUND/NOT_IMPLEMENTED .
* 근거: 기존 sampleOps.ts(·IoT ) OpsData . * 근거: 기존 sampleOps.ts( ) OpsDataExt . .
*/ */
const FALLBACK: OpsData = { const FALLBACK: OpsDataExt = {
halls: HALL_CELLS.map((c) => ({ hallId: c.id, label: c.label, estPeople: c.estPeople, heat: c.level })), halls: HALL_CELLS.map((c) => ({
hallId: c.id,
label: c.label,
estPeople: c.estPeople,
heat: c.level,
powerKw: null,
})),
hvac: { tempC: SAMPLE_HVAC.avgTempC, humidityPercent: null, co2Ppm: null }, hvac: { tempC: SAMPLE_HVAC.avgTempC, humidityPercent: null, co2Ppm: null },
lighting: LIGHTING_ZONES.map((z) => ({ zone: z.label, onPercent: z.pct })), lighting: LIGHTING_ZONES.map((z) => ({ zone: z.label, onPercent: z.pct })),
parking: PARKING_ZONES.map((z) => ({ zone: z.label, occupancy: z.occupied, capacity: z.total })), parking: PARKING_ZONES.map((z) => ({ zone: z.label, occupancy: z.occupied, capacity: z.total })),
onSiteCount: HALL_CELLS.reduce((s, c) => s + c.estPeople, 0),
powerTotalKw: null,
checkinSeries: [],
}; };

View File

@ -222,6 +222,15 @@
font-size: var(--fs-h3); font-size: var(--fs-h3);
color: var(--color-neutral-900); color: var(--color-neutral-900);
} }
.kx-ops__sensor-grid {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.kx-ops__series {
max-height: 320px;
overflow-y: auto;
}
.kx-ops__pill { .kx-ops__pill {
font-size: var(--fs-caption); font-size: var(--fs-caption);
font-weight: 600; font-weight: 600;

View File

@ -0,0 +1,39 @@
/*
* SCR-14 API ( ).
* api/types.ts OpsData(halls/hvac/lighting/parking)
* (powerKw·onSiteCount·powerTotalKw·checkinSeries) .
* GET /api/events/{eventId}/ops OpsDto() .
*/
import { api } from '../../api/client';
export interface OpsHallExt {
hallId: string;
label: string;
estPeople: number;
heat: 'smooth' | 'moderate' | 'busy' | string;
/** M4 유틸리티 신청 부하 합(kW). 신청 없으면 null. */
powerKw: number | null;
}
export interface OpsCheckinPoint {
hour: string; // 'HH:00'
count: number;
}
export interface OpsDataExt {
halls: OpsHallExt[];
hvac: { tempC: number | null; humidityPercent: number | null; co2Ppm: number | null };
lighting: { zone: string; onPercent: number }[];
parking: { zone: string; occupancy: number; capacity: number }[];
/** 체크인 완료 현장 인원(M10 파생). */
onSiteCount: number;
/** 신청 전력 총합(kW). 신청 없으면 null. */
powerTotalKw: number | null;
/** 시간대별 체크인 추이. */
checkinSeries: OpsCheckinPoint[];
}
export const opsApiLocal = {
get: (eventId: string) =>
api.get<OpsDataExt>(`/api/events/${encodeURIComponent(eventId)}/ops`),
};

View File

@ -0,0 +1,608 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from 'recharts';
import { Button } from '../../components/ui/Button';
import { EmptyState, ErrorState, Skeleton } from '../../components/ui/States';
import { CHART } from '../chartColors';
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
import { useAuthStore } from '../../store/authStore';
import { settlementApi } from './settlementApi';
import type { InvoiceStatus, StatusBucket } from './settlementApi';
import './settlement.css';
/*
* SCR /settlement M9 · .
* GET /api/settlement/dashboard (// KPI + ),
* GET /api/settlement/invoices ( ·), (+ ),
* ·· ( ).
* . PG PG .
*/
const PAGE_SIZE = 20;
const STATUS_META: Record<InvoiceStatus, { label: string; color: string; tone: string }> = {
pending: { label: '미발행', color: CHART.slate, tone: 'muted' },
invoiced: { label: '청구됨', color: CHART.primary600, tone: 'info' },
paid: { label: '완납', color: CHART.success, tone: 'ok' },
overdue: { label: '연체', color: CHART.error, tone: 'error' },
};
const STATUS_FILTERS: Array<{ value: '' | InvoiceStatus; label: string }> = [
{ value: '', label: '전체' },
{ value: 'pending', label: '미발행' },
{ value: 'invoiced', label: '청구됨' },
{ value: 'paid', label: '완납' },
{ value: 'overdue', label: '연체' },
];
export function SettlementDashboardPage() {
const eventId = useResolvedEventId();
const role = useAuthStore((s) => s.roleForCurrent());
const canManage = role === 'ORGANIZER' || role === 'HALL_MANAGER';
const [statusFilter, setStatusFilter] = useState<'' | InvoiceStatus>('');
const [page, setPage] = useState(0);
const [selectedId, setSelectedId] = useState<string | null>(null);
const qc = useQueryClient();
const dashboardQ = useQuery({
queryKey: ['settlement', 'dashboard', eventId],
queryFn: () => settlementApi.dashboard(eventId as string),
enabled: !!eventId,
retry: false,
});
const listQ = useQuery({
queryKey: ['settlement', 'invoices', eventId, statusFilter, page],
queryFn: () => settlementApi.invoices(eventId as string, statusFilter || null, page, PAGE_SIZE),
enabled: !!eventId,
retry: false,
});
const generateM = useMutation({
mutationFn: () => settlementApi.generate(eventId as string),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['settlement'] });
},
});
const refetchAll = () => {
qc.invalidateQueries({ queryKey: ['settlement'] });
};
if (!eventId) {
return (
<div className="kx-settle">
<PageHeader canManage={false} onGenerate={() => {}} generating={false} />
<EmptyState title="행사를 선택해 주세요" description="정산 현황을 조회할 행사가 지정되지 않았습니다." />
</div>
);
}
const dash = dashboardQ.data;
const list = listQ.data;
const totalPages = list ? Math.max(1, Math.ceil(list.total / PAGE_SIZE)) : 1;
return (
<div className="kx-settle">
<PageHeader
canManage={canManage}
onGenerate={() => generateM.mutate()}
generating={generateM.isPending}
/>
{generateM.isSuccess && generateM.data && (
<div className="kx-settle__notice" role="status">
{generateM.data.created}, {generateM.data.skipped}.
</div>
)}
{generateM.isError && (
<div className="kx-settle__notice kx-settle__notice--error" role="alert">
. .
</div>
)}
{/* KPI 밴드 */}
{dashboardQ.isLoading ? (
<div className="kx-settle__kpis" aria-hidden="true">
{[0, 1, 2, 3].map((i) => (
<Skeleton key={i} height={92} radius={12} />
))}
</div>
) : dashboardQ.isError ? (
<ErrorState message="정산 현황 집계를 불러오지 못했습니다." onRetry={() => dashboardQ.refetch()} />
) : dash ? (
<>
<section className="kx-settle__kpis" aria-label="정산 핵심 지표">
<KpiCard label="총 청구액" value={formatWon(dash.billed)} tone="info" sub={`${dash.invoiceCount}`} />
<KpiCard
label="수납액"
value={formatWon(dash.collected)}
tone="ok"
sub={`수납률 ${dash.collectionRate.toFixed(1)}%`}
/>
<KpiCard label="미수금" value={formatWon(dash.outstanding)} tone="muted" sub="미수 잔액" />
<KpiCard
label="연체 청구"
value={`${dash.overdueCount}`}
tone={dash.overdueCount > 0 ? 'error' : 'muted'}
sub="납부 기한 경과"
/>
</section>
{/* 상태 분포 도넛 + 범례 */}
<section className="kx-card kx-settle__dist" aria-label="청구 상태 분포">
<div className="kx-card__head">
<h2> </h2>
</div>
<StatusDistribution buckets={dash.statusDistribution} />
<p className="kx-settle__paynote">{dash.paymentNote}</p>
</section>
</>
) : null}
{/* 청구서 목록 */}
<section className="kx-card kx-settle__list-card" aria-label="청구서 목록">
<div className="kx-card__head kx-settle__list-head">
<h2></h2>
<div className="kx-seg" role="tablist" aria-label="상태 필터">
{STATUS_FILTERS.map((f) => (
<button
key={f.value || 'all'}
role="tab"
aria-selected={statusFilter === f.value}
className={`kx-seg__btn ${statusFilter === f.value ? 'is-active' : ''}`}
onClick={() => {
setStatusFilter(f.value);
setPage(0);
}}
>
{f.label}
</button>
))}
</div>
</div>
{listQ.isLoading ? (
<div style={{ display: 'grid', gap: 8 }} aria-hidden="true">
{[0, 1, 2, 3, 4].map((i) => (
<Skeleton key={i} height={44} radius={8} />
))}
</div>
) : listQ.isError ? (
<ErrorState message="청구서 목록을 불러오지 못했습니다." onRetry={() => listQ.refetch()} />
) : !list || list.items.length === 0 ? (
<EmptyState
title="청구서가 없습니다"
description={canManage ? '상단의 청구서 생성으로 참가업체별 청구를 집계하세요.' : '아직 발행된 청구서가 없습니다.'}
/>
) : (
<>
<div className="kx-table-scroll">
<table className="kx-table kx-table--zebra">
<thead>
<tr>
<th></th>
<th></th>
<th className="kx-num"></th>
<th className="kx-num"></th>
<th className="kx-num"></th>
<th></th>
<th></th>
<th aria-label="상세" />
</tr>
</thead>
<tbody>
{list.items.map((inv) => (
<tr key={inv.id} className="kx-settle__row" onClick={() => setSelectedId(inv.id)}>
<td className="tnum">{inv.invoiceNo}</td>
<td>{inv.exhibitorName}</td>
<td className="kx-num tnum">{formatWon(inv.total)}</td>
<td className="kx-num tnum">{formatWon(inv.paidAmount)}</td>
<td className="kx-num tnum">{formatWon(inv.outstanding)}</td>
<td>{inv.dueDate ?? '-'}</td>
<td>
<StatusPill status={inv.status} />
</td>
<td>
<button
className="kx-settle__link"
onClick={(e) => {
e.stopPropagation();
setSelectedId(inv.id);
}}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="kx-settle__pager">
<Button
variant="secondary"
disabled={page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
</Button>
<span className="kx-settle__pageinfo tnum">
{page + 1} / {totalPages} · {list.total}
</span>
<Button
variant="secondary"
disabled={page + 1 >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
</Button>
</div>
</>
)}
</section>
{selectedId && (
<InvoiceDetailModal
invoiceId={selectedId}
onClose={() => setSelectedId(null)}
onChanged={refetchAll}
/>
)}
</div>
);
}
// ── 헤더 ─────────────────────────────────────────────────────────────────────
function PageHeader({
canManage,
onGenerate,
generating,
}: {
canManage: boolean;
onGenerate: () => void;
generating: boolean;
}) {
return (
<header className="kx-settle__head">
<div>
<h1 className="kx-settle__title">·</h1>
<p className="kx-settle__subtitle"> · </p>
</div>
{canManage && (
<div className="kx-settle__head-actions">
<Button variant="primary" onClick={onGenerate} disabled={generating}>
{generating ? '생성 중…' : '청구서 생성'}
</Button>
</div>
)}
</header>
);
}
// ── KPI 카드 ─────────────────────────────────────────────────────────────────
function KpiCard({
label,
value,
sub,
tone,
}: {
label: string;
value: string;
sub?: string;
tone: 'info' | 'ok' | 'error' | 'muted';
}) {
return (
<div className={`kx-kpi kx-settle__kpi kx-settle__kpi--${tone}`}>
<span className="kx-kpi__label">{label}</span>
<strong className="kx-kpi__value tnum">{value}</strong>
{sub && <span className="kx-settle__kpi-sub">{sub}</span>}
</div>
);
}
// ── 상태 분포(도넛) ───────────────────────────────────────────────────────────
function StatusDistribution({ buckets }: { buckets: StatusBucket[] }) {
const data = useMemo(
() =>
buckets
.filter((b) => b.count > 0)
.map((b) => ({
key: b.status,
name: STATUS_META[b.status]?.label ?? b.status,
value: b.totalAmount,
count: b.count,
color: STATUS_META[b.status]?.color ?? CHART.slate,
})),
[buckets],
);
if (data.length === 0) {
return <EmptyState title="집계된 청구가 없습니다" />;
}
return (
<div className="kx-settle__dist-body">
<div className="kx-settle__donut">
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={data} dataKey="value" nameKey="name" innerRadius={58} outerRadius={88} paddingAngle={2}>
{data.map((d) => (
<Cell key={d.key} fill={d.color} />
))}
</Pie>
<Tooltip
contentStyle={TOOLTIP_STYLE}
formatter={(value, _name, item) => {
const p = (item?.payload ?? {}) as { count?: number; name?: string };
return [`${formatWon(Number(value))} · ${p.count ?? 0}`, p.name ?? ''];
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
<ul className="kx-settle__legend">
{data.map((d) => (
<li key={d.key} className="kx-settle__legend-item">
<span className="kx-settle__legend-dot" style={{ background: d.color }} aria-hidden="true" />
<span className="kx-settle__legend-name">{d.name}</span>
<span className="kx-settle__legend-val tnum">
{d.count} · {formatWon(d.value)}
</span>
</li>
))}
</ul>
</div>
);
}
// ── 상태 표시 ─────────────────────────────────────────────────────────────────
function StatusPill({ status }: { status: InvoiceStatus }) {
const meta = STATUS_META[status] ?? { label: status, tone: 'muted', color: CHART.slate };
return <span className={`kx-settle__pill kx-settle__pill--${meta.tone}`}>{meta.label}</span>;
}
// ── 상세 모달(라인아이템 + 수납 이력 + 수납 기록) ─────────────────────────────
function InvoiceDetailModal({
invoiceId,
onClose,
onChanged,
}: {
invoiceId: string;
onClose: () => void;
onChanged: () => void;
}) {
const qc = useQueryClient();
const [amount, setAmount] = useState('');
const [method, setMethod] = useState<'manual' | 'transfer' | 'card'>('transfer');
const [memo, setMemo] = useState('');
const [formError, setFormError] = useState<string | null>(null);
const detailQ = useQuery({
queryKey: ['settlement', 'detail', invoiceId],
queryFn: () => settlementApi.detail(invoiceId),
retry: false,
});
const invalidate = () => {
qc.invalidateQueries({ queryKey: ['settlement', 'detail', invoiceId] });
onChanged();
};
const payM = useMutation({
mutationFn: () =>
settlementApi.recordPayment(invoiceId, { amount: Number(amount), method, memo: memo || undefined }),
onSuccess: () => {
setAmount('');
setMemo('');
setFormError(null);
invalidate();
},
onError: () => setFormError('수납 기록에 실패했습니다. 금액(총액 이내)·권한을 확인해 주세요.'),
});
const issueM = useMutation({
mutationFn: () => settlementApi.issue(invoiceId),
onSuccess: invalidate,
});
const d = detailQ.data;
const submitPayment = () => {
const n = Number(amount);
if (!Number.isFinite(n) || n <= 0) {
setFormError('0보다 큰 금액을 입력해 주세요.');
return;
}
if (d && n > d.outstanding) {
setFormError('미수 잔액을 초과할 수 없습니다.');
return;
}
setFormError(null);
payM.mutate();
};
return (
<div className="kx-settle__overlay" role="dialog" aria-modal="true" aria-label="청구서 상세" onClick={onClose}>
<div className="kx-settle__modal" onClick={(e) => e.stopPropagation()}>
<div className="kx-settle__modal-head">
<h2>{d ? d.invoiceNo : '청구서'}</h2>
<button className="kx-settle__close" aria-label="닫기" onClick={onClose}>
</button>
</div>
{detailQ.isLoading ? (
<div style={{ display: 'grid', gap: 8, padding: 16 }} aria-hidden="true">
<Skeleton height={24} radius={6} />
<Skeleton height={120} radius={8} />
<Skeleton height={80} radius={8} />
</div>
) : detailQ.isError || !d ? (
<ErrorState message="청구서 상세를 불러오지 못했습니다." onRetry={() => detailQ.refetch()} />
) : (
<div className="kx-settle__modal-body">
<div className="kx-settle__meta">
<div>
<span className="kx-settle__meta-k"></span>
<span className="kx-settle__meta-v">{d.exhibitorName}</span>
</div>
<div>
<span className="kx-settle__meta-k"></span>
<StatusPill status={d.status} />
</div>
<div>
<span className="kx-settle__meta-k"></span>
<span className="kx-settle__meta-v">{d.dueDate ?? '-'}</span>
</div>
</div>
{/* 라인아이템 */}
<table className="kx-table kx-settle__lines">
<thead>
<tr>
<th></th>
<th className="kx-num"></th>
<th className="kx-num"></th>
<th className="kx-num"></th>
</tr>
</thead>
<tbody>
{d.lineItems.length === 0 ? (
<tr>
<td colSpan={4} className="kx-settle__muted">
</td>
</tr>
) : (
d.lineItems.map((li, i) => (
<tr key={`${li.category}-${i}`}>
<td>
<span className="kx-settle__cat">{li.category}</span> {li.description}
</td>
<td className="kx-num tnum">{li.quantity.toLocaleString()}</td>
<td className="kx-num tnum">{li.unitPrice.toLocaleString()}</td>
<td className="kx-num tnum">{li.amount.toLocaleString()}</td>
</tr>
))
)}
</tbody>
<tfoot>
<tr>
<td colSpan={3}></td>
<td className="kx-num tnum">{d.subtotal.toLocaleString()}</td>
</tr>
<tr>
<td colSpan={3}>(10%)</td>
<td className="kx-num tnum">{d.vat.toLocaleString()}</td>
</tr>
<tr className="kx-settle__total-row">
<td colSpan={3}> </td>
<td className="kx-num tnum">{d.total.toLocaleString()}</td>
</tr>
<tr>
<td colSpan={3}> / </td>
<td className="kx-num tnum">
{d.paidAmount.toLocaleString()} / {d.outstanding.toLocaleString()}
</td>
</tr>
</tfoot>
</table>
{/* 수납 이력 */}
<div className="kx-settle__pay-hist">
<h3> </h3>
{d.payments.length === 0 ? (
<p className="kx-settle__muted"> .</p>
) : (
<ul>
{d.payments.map((p) => (
<li key={p.id}>
<span className="tnum">{p.amount.toLocaleString()}</span>
<span className="kx-settle__pay-method">{p.method}</span>
{p.memo && <span className="kx-settle__pay-memo">{p.memo}</span>}
<span className="kx-settle__pay-date">{formatDate(p.recordedAt)}</span>
</li>
))}
</ul>
)}
</div>
{/* 매니저 액션 */}
{d.canManage ? (
<div className="kx-settle__actions">
{d.status === 'pending' && (
<Button variant="secondary" onClick={() => issueM.mutate()} disabled={issueM.isPending}>
{issueM.isPending ? '발행 중…' : '청구서 발행'}
</Button>
)}
{d.outstanding > 0 && (
<div className="kx-settle__pay-form">
<div className="kx-settle__pay-inputs">
<input
className="kx-input"
type="number"
min={1}
placeholder="수납 금액(원)"
value={amount}
onChange={(e) => setAmount(e.target.value)}
aria-label="수납 금액"
/>
<select
className="kx-select"
value={method}
onChange={(e) => setMethod(e.target.value as 'manual' | 'transfer' | 'card')}
aria-label="수납 방법"
>
<option value="transfer"></option>
<option value="card"></option>
<option value="manual"></option>
</select>
<input
className="kx-input"
type="text"
placeholder="메모(선택)"
value={memo}
onChange={(e) => setMemo(e.target.value)}
aria-label="메모"
/>
<Button variant="primary" onClick={submitPayment} disabled={payM.isPending}>
{payM.isPending ? '기록 중…' : '수납 기록'}
</Button>
</div>
{formError && <p className="kx-settle__form-error" role="alert">{formError}</p>}
<p className="kx-settle__muted">
PG로 . .
</p>
</div>
)}
</div>
) : (
<p className="kx-settle__muted">· / .</p>
)}
</div>
)}
</div>
</div>
);
}
const TOOLTIP_STYLE = {
fontSize: 12,
borderRadius: 8,
border: '1px solid #E4E7EC',
boxShadow: '0 4px 12px rgba(16,24,40,0.1)',
} as const;
/** 원 금액 → 억/만 단위 축약(표시 전용, 서버 값 재계산 아님). */
function formatWon(v: number): string {
if (v >= 1e8) return `${(v / 1e8).toFixed(2)}`;
if (v >= 1e4) return `${Math.round(v / 1e4).toLocaleString()}`;
return `${v.toLocaleString()}`;
}
function formatDate(iso: string | null): string {
if (!iso) return '-';
return iso.slice(0, 10);
}

View File

@ -0,0 +1,337 @@
/*
* M9 정산·결제 화면 스타일 규칙 .kx-settle 스코프 격리(전역 재정의 방지).
* design.md §1 토큰(·radius·spacing) 사용. SVG/텍스트만 이모지 장식 없음.
*/
.kx-settle {
display: grid;
gap: 16px;
}
/* 헤더 */
.kx-settle__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.kx-settle__title {
margin: 0;
font-size: 22px;
font-weight: 700;
color: var(--color-neutral-900);
}
.kx-settle__subtitle {
margin: 4px 0 0;
font-size: 13px;
color: var(--color-neutral-500);
}
.kx-settle__head-actions {
display: flex;
gap: 8px;
}
/* 공지 배너 */
.kx-settle__notice {
padding: 10px 14px;
border-radius: var(--radius-sm);
background: var(--color-primary-050);
border: 1px solid var(--color-primary-100);
color: var(--color-primary-700);
font-size: 13px;
}
.kx-settle__notice--error {
background: #fef3f2;
border-color: #fecdca;
color: var(--color-error);
}
/* KPI 밴드 */
.kx-settle__kpis {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.kx-settle__kpi {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px;
border: var(--border-card);
border-radius: var(--radius-lg);
background: var(--color-white);
}
.kx-settle__kpi-sub {
font-size: 12px;
color: var(--color-neutral-500);
}
.kx-settle__kpi--info {
border-left: 4px solid var(--color-primary-600);
}
.kx-settle__kpi--ok {
border-left: 4px solid var(--color-success);
}
.kx-settle__kpi--error {
border-left: 4px solid var(--color-error);
}
.kx-settle__kpi--muted {
border-left: 4px solid var(--color-neutral-200);
}
/* 상태 분포 */
.kx-settle__dist-body {
display: grid;
grid-template-columns: 240px 1fr;
gap: 20px;
align-items: center;
}
.kx-settle__legend {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 8px;
}
.kx-settle__legend-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
}
.kx-settle__legend-dot {
width: 12px;
height: 12px;
border-radius: 3px;
flex: 0 0 auto;
}
.kx-settle__legend-name {
color: var(--color-neutral-700);
min-width: 60px;
}
.kx-settle__legend-val {
color: var(--color-neutral-500);
margin-left: auto;
}
.kx-settle__paynote {
margin: 12px 0 0;
font-size: 12px;
color: var(--color-neutral-500);
}
/* 목록 */
.kx-settle__list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.kx-settle__row {
cursor: pointer;
}
.kx-settle__row:hover {
background: var(--color-primary-050);
}
.kx-settle__link {
border: none;
background: none;
color: var(--color-primary-600);
font-size: 13px;
cursor: pointer;
padding: 0;
}
.kx-settle__link:hover {
text-decoration: underline;
}
.kx-settle__pager {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin-top: 12px;
}
.kx-settle__pageinfo {
font-size: 13px;
color: var(--color-neutral-500);
}
/* 상태 pill */
.kx-settle__pill {
display: inline-block;
padding: 2px 10px;
border-radius: var(--radius-pill);
font-size: 12px;
font-weight: 600;
line-height: 18px;
}
.kx-settle__pill--muted {
background: var(--color-neutral-100);
color: var(--color-neutral-500);
}
.kx-settle__pill--info {
background: var(--color-primary-050);
color: var(--color-primary-700);
}
.kx-settle__pill--ok {
background: #ecfdf3;
color: var(--color-success);
}
.kx-settle__pill--error {
background: #fef3f2;
color: var(--color-error);
}
/* 상세 모달 */
.kx-settle__overlay {
position: fixed;
inset: 0;
background: rgba(16, 24, 40, 0.45);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 16px;
z-index: 50;
overflow-y: auto;
}
.kx-settle__modal {
width: 100%;
max-width: 720px;
background: var(--color-white);
border-radius: var(--radius-lg);
box-shadow: 0 12px 40px rgba(16, 24, 40, 0.2);
}
.kx-settle__modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: var(--border-card);
}
.kx-settle__modal-head h2 {
margin: 0;
font-size: 16px;
}
.kx-settle__close {
border: none;
background: none;
font-size: 16px;
color: var(--color-neutral-500);
cursor: pointer;
}
.kx-settle__modal-body {
padding: 20px;
display: grid;
gap: 16px;
}
.kx-settle__meta {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.kx-settle__meta > div {
display: flex;
flex-direction: column;
gap: 4px;
}
.kx-settle__meta-k {
font-size: 12px;
color: var(--color-neutral-500);
}
.kx-settle__meta-v {
font-size: 14px;
color: var(--color-neutral-900);
font-weight: 600;
}
.kx-settle__lines {
width: 100%;
}
.kx-settle__lines tfoot td {
font-size: 13px;
color: var(--color-neutral-700);
}
.kx-settle__total-row td {
font-weight: 700;
color: var(--color-neutral-900);
border-top: 1px solid var(--color-neutral-200);
}
.kx-settle__cat {
display: inline-block;
font-size: 11px;
font-weight: 600;
color: var(--color-primary-700);
background: var(--color-primary-050);
border-radius: var(--radius-sm);
padding: 1px 6px;
margin-right: 4px;
}
.kx-settle__pay-hist h3 {
margin: 0 0 8px;
font-size: 14px;
}
.kx-settle__pay-hist ul {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 6px;
}
.kx-settle__pay-hist li {
display: flex;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--color-neutral-700);
padding: 6px 10px;
background: var(--color-neutral-050);
border-radius: var(--radius-sm);
}
.kx-settle__pay-method {
font-size: 11px;
color: var(--color-neutral-500);
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
padding: 0 6px;
}
.kx-settle__pay-memo {
color: var(--color-neutral-500);
}
.kx-settle__pay-date {
margin-left: auto;
color: var(--color-neutral-500);
}
.kx-settle__actions {
display: grid;
gap: 12px;
border-top: var(--border-card);
padding-top: 16px;
}
.kx-settle__pay-inputs {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.kx-settle .kx-input {
padding: 8px 10px;
border: 1px solid var(--color-neutral-200);
border-radius: var(--radius-sm);
font-size: 13px;
color: var(--color-neutral-900);
}
.kx-settle .kx-input:focus {
outline: none;
border-color: var(--color-primary-600);
}
.kx-settle__form-error {
margin: 0;
font-size: 12px;
color: var(--color-error);
}
.kx-settle__muted {
font-size: 12px;
color: var(--color-neutral-500);
text-align: left;
}

View File

@ -0,0 +1,122 @@
/*
* M9 · API client(../../api/client) api .
* 정본: 백엔드 com.zioinfo.kintex.settlement (SettlementController) .
*
* (long) ( ).
* PG PG . .
*/
import { api } from '../../api/client';
export type InvoiceStatus = 'pending' | 'invoiced' | 'paid' | 'overdue';
export interface StatusBucket {
status: InvoiceStatus;
count: number;
totalAmount: number;
paidAmount: number;
}
export interface SettlementDashboard {
eventId: string;
billed: number;
collected: number;
outstanding: number;
invoiceCount: number;
overdueCount: number;
collectionRate: number;
statusDistribution: StatusBucket[];
paymentNote: string;
}
export interface InvoiceSummary {
id: string;
eventId: string;
invoiceNo: string;
exhibitorName: string;
subtotal: number;
vat: number;
total: number;
paidAmount: number;
outstanding: number;
status: InvoiceStatus;
dueDate: string | null;
issuedAt: string | null;
}
export interface LineItem {
category: string; // RENTAL | UTILITY | OTHER
description: string;
quantity: number;
unitPrice: number;
amount: number;
}
export interface PaymentRecord {
id: string;
amount: number;
method: string; // manual | transfer | card
memo: string | null;
recordedAt: string;
}
export interface InvoiceDetail {
id: string;
eventId: string;
invoiceNo: string;
exhibitorName: string;
subtotal: number;
vat: number;
total: number;
paidAmount: number;
outstanding: number;
status: InvoiceStatus;
dueDate: string | null;
issuedAt: string | null;
paidAt: string | null;
note: string | null;
lineItems: LineItem[];
payments: PaymentRecord[];
canManage: boolean;
}
export interface PageResponse<T> {
items: T[];
page: number;
size: number;
total: number;
}
export interface GenerateResult {
eventId: string;
created: number;
skipped: number;
invoices: InvoiceSummary[];
}
export interface RecordPaymentInput {
amount: number;
method?: 'manual' | 'transfer' | 'card';
memo?: string;
}
export const settlementApi = {
dashboard: (eventId: string) =>
api.get<SettlementDashboard>(`/api/settlement/dashboard?eventId=${encodeURIComponent(eventId)}`),
invoices: (eventId: string, status: string | null, page: number, size: number) => {
const params = new URLSearchParams({ eventId, page: String(page), size: String(size) });
if (status) params.set('status', status);
return api.get<PageResponse<InvoiceSummary>>(`/api/settlement/invoices?${params.toString()}`);
},
detail: (id: string) => api.get<InvoiceDetail>(`/api/settlement/invoices/${encodeURIComponent(id)}`),
generate: (eventId: string) =>
api.post<GenerateResult>(`/api/settlement/invoices/generate?eventId=${encodeURIComponent(eventId)}`),
issue: (id: string) =>
api.post<InvoiceDetail>(`/api/settlement/invoices/${encodeURIComponent(id)}/issue`),
recordPayment: (id: string, input: RecordPaymentInput) =>
api.post<InvoiceDetail>(`/api/settlement/invoices/${encodeURIComponent(id)}/payments`, input),
};

View File

@ -16,6 +16,8 @@ interface AuthState {
logout: () => void; logout: () => void;
currentWorkspace: () => WorkspaceDto | null; currentWorkspace: () => WorkspaceDto | null;
roleForCurrent: () => EventRole | null; roleForCurrent: () => EventRole | null;
// 전역 역할(app_user.role_code — 'ADMIN' | 'MANAGER' | …). 없으면 null. 관리자 판정(AdminGuard) 등에 사용.
globalRole: () => string | null;
} }
export const useAuthStore = create<AuthState>((set, get) => ({ export const useAuthStore = create<AuthState>((set, get) => ({
@ -46,4 +48,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}, },
roleForCurrent: () => get().currentWorkspace()?.myRole ?? null, roleForCurrent: () => get().currentWorkspace()?.myRole ?? null,
globalRole: () => get().user?.roleCode ?? null,
})); }));