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:
parent
8a49a68a6a
commit
4049b7c222
@ -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 | **실데이터·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 | **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 | **도메인 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 경유) |
|
||||
|
||||
@ -65,11 +65,13 @@ public class AuthServiceImpl implements AuthService {
|
||||
}
|
||||
// 멤버십 반영 후 갱신된 역할로 새 토큰 재발급(현재 역할 기준 재조립).
|
||||
// ★초대코드→행사/부스/역할 매핑(invite 테이블)은 db-engineer 인수 대기 — 매핑 매퍼 제공 시 역할 upsert 배선.
|
||||
return buildLoginResponse(principal.userId(), principal.displayName(), principal.hallManager());
|
||||
return buildLoginResponse(principal.userId(), principal.displayName(), principal.hallManager(),
|
||||
principal.roleCode());
|
||||
}
|
||||
|
||||
/** 사용자 역할을 조회해 토큰 + 워크스페이스 카드를 조립한다. */
|
||||
private LoginResponse buildLoginResponse(String userId, String displayName, boolean hallManager) {
|
||||
/** 사용자 역할을 조회해 토큰 + 워크스페이스 카드를 조립한다. globalRoleCode=app_user.role_code(null 허용). */
|
||||
private LoginResponse buildLoginResponse(String userId, String displayName, boolean hallManager,
|
||||
String globalRoleCode) {
|
||||
List<Map<String, Object>> roleRows = userMapper.findEventRoles(userId);
|
||||
Map<String, EventRole> eventRoles = new LinkedHashMap<>();
|
||||
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);
|
||||
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) {
|
||||
|
||||
@ -31,25 +31,35 @@ public class JwtService {
|
||||
this.ttlSeconds = props.getAccessTokenTtlSeconds();
|
||||
}
|
||||
|
||||
/** 액세스 토큰 발급. eventRoles: 행사ID→역할. */
|
||||
/** 액세스 토큰 발급. eventRoles: 행사ID→역할. roleCode: 전역 역할(app_user.role_code, null 허용). */
|
||||
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();
|
||||
Map<String, String> rolesClaim = new LinkedHashMap<>();
|
||||
if (eventRoles != null) {
|
||||
eventRoles.forEach((eid, role) -> rolesClaim.put(eid, role.name()));
|
||||
}
|
||||
return Jwts.builder()
|
||||
var builder = Jwts.builder()
|
||||
.subject(userId)
|
||||
.claim("name", displayName)
|
||||
.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))
|
||||
.expiration(Date.from(now.plusSeconds(ttlSeconds)))
|
||||
.signWith(key)
|
||||
.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. */
|
||||
@SuppressWarnings("unchecked")
|
||||
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))));
|
||||
}
|
||||
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) {
|
||||
throw new ApiException(ErrorCode.UNAUTHORIZED, "토큰이 유효하지 않거나 만료되었습니다.");
|
||||
}
|
||||
|
||||
@ -9,14 +9,22 @@ import java.util.Map;
|
||||
* @param displayName 표시 이름
|
||||
* @param eventRoles 행사ID → 역할 (행사 단위 RBAC). 홀매니저는 전체 열람이므로 별도 플래그.
|
||||
* @param hallManager 킨텍스 내부 홀매니저 여부(전체 행사 열람+승인)
|
||||
* @param roleCode 전역 역할(app_user.role_code — ADMIN/MANAGER/…, 없으면 null). 순증 필드(하위호환).
|
||||
*/
|
||||
public record KintexPrincipal(
|
||||
String userId,
|
||||
String displayName,
|
||||
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로 간주. */
|
||||
public EventRole roleFor(String eventId) {
|
||||
if (hallManager) {
|
||||
|
||||
@ -12,6 +12,14 @@ public record LoginResponse(
|
||||
UserSummary user,
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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, "홀 배정은 킨텍스 홀매니저만 변경할 수 있습니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
package com.zioinfo.kintex.hallassign.dto;
|
||||
|
||||
/**
|
||||
* 견적 라인 항목 — 코드·라벨·산출근거·금액(KRW 정수). 음수 가능(할인).
|
||||
*/
|
||||
public record QuoteLine(
|
||||
String code,
|
||||
String label,
|
||||
String basis,
|
||||
long amount
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
@ -8,19 +8,65 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 홀 현장 운영 매퍼 — hall_assignment·hall read-only(행사에 배정된 홀 목록).
|
||||
* 혼잡/HVAC/조명/주차 센서 원천이 없으므로 홀 목록만 조회한다. 마이그레이션 불변.
|
||||
* 홀 현장 운영 매퍼 — read-only 집계. 마이그레이션 불변(기존 테이블 집계만).
|
||||
*
|
||||
* <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
|
||||
public interface OpsMapper {
|
||||
|
||||
/** 행사 배정 홀 목록(대표 홀 우선 정렬). */
|
||||
/**
|
||||
* 행사 배정 홀 목록 + 판매면적(salesArea, ㎡) + 신청 전력(powerKw, kW).
|
||||
* powerKw 는 유틸리티 신청이 없으면 null. 대표 홀 우선 정렬.
|
||||
*/
|
||||
@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
|
||||
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}
|
||||
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);
|
||||
}
|
||||
|
||||
@ -8,11 +8,19 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 홀 현장 운영 서비스 — 배정 홀 목록만 실 조회. 혼잡/HVAC/조명/주차는 센서 부재 → 0/null/빈배열(정직).
|
||||
* 홀 현장 운영 서비스.
|
||||
*
|
||||
* <p>실집계: ①혼잡 = M10 체크인(현장 인원)을 홀 판매면적 비중으로 분배한 추정치 + 시간대 추이,
|
||||
* ②전력 = M4 유틸리티 신청 부하(requestedKw) 홀별 합. HVAC/조명/주차는 센서·IoT 미연동 → null/빈배열.
|
||||
* 사람 계측·전력계 하드웨어 코드는 없다(표시 전용, PLANNING §2.6).
|
||||
*/
|
||||
@Service
|
||||
public class OpsService {
|
||||
|
||||
/** 홀 인원 밀도(명/㎡) 혼잡 임계 — 전시 통로 기준 근사치. */
|
||||
private static final double DENSITY_BUSY = 0.7;
|
||||
private static final double DENSITY_MODERATE = 0.3;
|
||||
|
||||
private final OpsMapper mapper;
|
||||
|
||||
public OpsService(OpsMapper mapper) {
|
||||
@ -20,20 +28,103 @@ public class OpsService {
|
||||
}
|
||||
|
||||
public OpsDto getOps(String eventId) {
|
||||
List<OpsDto.Hall> halls = new ArrayList<>();
|
||||
List<Map<String, Object>> rows = mapper.findAssignedHalls(eventId);
|
||||
if (rows != null) {
|
||||
long onSite = mapper.countCheckedIn(eventId);
|
||||
|
||||
List<Map<String, Object>> rows = mapper.findHallOps(eventId);
|
||||
if (rows == null) {
|
||||
rows = new ArrayList<>();
|
||||
}
|
||||
|
||||
double totalArea = 0;
|
||||
for (Map<String, Object> r : rows) {
|
||||
// 혼잡 센서(M14) 미연동 → estPeople=0, heat=smooth.
|
||||
halls.add(new OpsDto.Hall(str(r.get("hallId")), str(r.get("label")), 0L, "smooth"));
|
||||
totalArea += dbl(r.get("salesArea"));
|
||||
}
|
||||
|
||||
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);
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,29 +3,43 @@ package com.zioinfo.kintex.ops.dto;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 홀 현장 운영 현황 (SCR-16 / 06_backend_api_gaps §5).
|
||||
* 집계원: hall_assignment·hall(실 홀 목록). 혼잡/HVAC/조명/주차는 센서·IoT 미연동(PLANNING M14) → 0/null/빈배열(정직).
|
||||
* <p>하드웨어 연동 코드 없음 — 표시 전용. 실시간성 필요 시 WebSocket /topic/ops/{eventId} 확장 검토(현재 폴링 GET).
|
||||
* 홀 현장 운영 현황 (SCR-14 / 06_backend_api_gaps §5).
|
||||
*
|
||||
* <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(
|
||||
List<Hall> halls,
|
||||
Hvac hvac,
|
||||
List<LightingZone> lighting,
|
||||
List<ParkingZone> parking
|
||||
List<ParkingZone> parking,
|
||||
long onSiteCount,
|
||||
Double powerTotalKw,
|
||||
List<CheckinPoint> checkinSeries
|
||||
) {
|
||||
/** 홀 혼잡(estPeople·heat) — 센서 부재 시 0/smooth. */
|
||||
public record Hall(String hallId, String label, long estPeople, String heat) {
|
||||
/** 홀 혼잡/전력 — estPeople·heat(체크인 파생), powerKw(유틸리티 신청 부하, 신청 없으면 null). */
|
||||
public record Hall(String hallId, String label, long estPeople, String heat, Double powerKw) {
|
||||
}
|
||||
|
||||
/** 공조 계측(센서 부재 시 null). */
|
||||
public record Hvac(Double tempC, Integer humidityPercent, Integer co2Ppm) {
|
||||
}
|
||||
|
||||
/** 조명 존 점등률. */
|
||||
/** 조명 존 점등률(IoT 부재 시 빈배열). */
|
||||
public record LightingZone(String zone, int onPercent) {
|
||||
}
|
||||
|
||||
/** 주차 존 점유·수용. */
|
||||
/** 주차 존 점유·수용(연계 부재 시 빈배열). */
|
||||
public record ParkingZone(String zone, int occupancy, int capacity) {
|
||||
}
|
||||
|
||||
/** 시간대별 체크인 수(hour = 'HH:00'). */
|
||||
public record CheckinPoint(String hour, long count) {
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,10 +15,12 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
public class OtpChallengeStore {
|
||||
|
||||
private static final long TTL_MILLIS = 5 * 60 * 1000L;
|
||||
/** 챌린지당 코드 검증 실패 허용 횟수 — 초과 시 챌린지 무효화(브루트포스 방어). */
|
||||
private static final int MAX_FAILURES = 5;
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
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회성 챌린지 토큰 발급. */
|
||||
@ -27,7 +29,7 @@ public class OtpChallengeStore {
|
||||
byte[] buf = new byte[24];
|
||||
random.nextBytes(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;
|
||||
}
|
||||
|
||||
@ -55,6 +57,28 @@ public class OtpChallengeStore {
|
||||
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() {
|
||||
long now = Instant.now().toEpochMilli();
|
||||
store.entrySet().removeIf(en -> en.getValue().expiresAt() < now);
|
||||
|
||||
@ -73,20 +73,30 @@ public class TwoFactorService {
|
||||
&& requiresOtp(userId, hallManager, str(row.get("roleCode")))) {
|
||||
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) {
|
||||
String userId = challengeStore.consume(challengeToken);
|
||||
String userId = challengeStore.peek(challengeToken);
|
||||
if (userId == null) {
|
||||
throw new ApiException(ErrorCode.OTP_REQUIRED, "2차 인증 세션이 만료되었습니다. 다시 로그인해 주세요.");
|
||||
}
|
||||
Map<String, Object> sec = securityMapper.findSecurityById(userId);
|
||||
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 반환. */
|
||||
@ -100,18 +110,23 @@ public class TwoFactorService {
|
||||
return setupOtp(userId, label != null ? label : userId);
|
||||
}
|
||||
|
||||
/** 로그인 중 등록 강제 — verify 단계(챌린지 소비·활성화 후 최종 토큰). */
|
||||
/** 로그인 중 등록 강제 — verify 단계(검증 성공 시에만 챌린지 소비·활성화 후 최종 토큰). */
|
||||
public LoginResponse enrollVerify(String challengeToken, String code) {
|
||||
String userId = challengeStore.consume(challengeToken);
|
||||
String userId = challengeStore.peek(challengeToken);
|
||||
if (userId == null) {
|
||||
throw new ApiException(ErrorCode.OTP_REQUIRED, "2차 인증 세션이 만료되었습니다. 다시 로그인해 주세요.");
|
||||
}
|
||||
Map<String, Object> sec = securityMapper.findSecurityById(userId);
|
||||
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);
|
||||
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 필요). */
|
||||
@ -162,8 +177,13 @@ public class TwoFactorService {
|
||||
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);
|
||||
Map<String, EventRole> eventRoles = new LinkedHashMap<>();
|
||||
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<>();
|
||||
if (roleRows != null) {
|
||||
for (Map<String, Object> r : roleRows) {
|
||||
@ -185,7 +205,7 @@ public class TwoFactorService {
|
||||
}
|
||||
}
|
||||
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) {
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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 >= 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);
|
||||
}
|
||||
@ -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 연동은 없다. 상태 전이(pending→invoiced→paid/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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
) {
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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);
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
@ -51,6 +51,8 @@ import { SystemSettingsPage } from './screens/admin/SystemSettingsPage';
|
||||
import { RulesetVersionsPage } from './screens/admin/RulesetVersionsPage';
|
||||
import { TenantAdminPage } from './screens/admin/TenantAdminPage';
|
||||
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 { RoleAdminPage } from './screens/admin/RoleAdminPage';
|
||||
import { CommonCodeAdminPage } from './screens/admin/CommonCodeAdminPage';
|
||||
@ -150,12 +152,15 @@ export function App() {
|
||||
<Route path="/ops/operations" element={<HallOperationsPage />} />
|
||||
{/* SCR-15 전시 일정 관리 (M1·M6) */}
|
||||
<Route path="/schedule" element={<ExhibitionSchedulePage />} />
|
||||
{/* M9 정산 대시보드 · M1 홀 배정+자동견적 */}
|
||||
<Route path="/settlement" element={<SettlementDashboardPage />} />
|
||||
<Route path="/halls" element={<HallAssignmentPage />} />
|
||||
{/* SCR-16 관리자 대시보드 (M18 랜딩) */}
|
||||
<Route path="/admin" element={<AdminGuard><AdminDashboardPage /></AdminGuard>} />
|
||||
{/* 관리자 — 로그인 슬라이드 관리 (M18) */}
|
||||
<Route path="/admin/login-slides" element={<AdminGuard><LoginSlideAdminPage /></AdminGuard>} />
|
||||
{/* SCR-17 컴포넌트 가이드 (내부 전용 · 네비 비노출) */}
|
||||
<Route path="/_styleguide" element={<ComponentGuidePage />} />
|
||||
{import.meta.env.DEV && <Route path="/_styleguide" element={<ComponentGuidePage />} />}
|
||||
|
||||
{/* §5B 공통 업무 기능 (SCR-39~48) — 역할 공통 영역 */}
|
||||
<Route path="/work/worklog" element={<WorklogPage />} />
|
||||
|
||||
@ -69,6 +69,8 @@ export interface AuthUser {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
hallManager: boolean;
|
||||
// 전역 역할(app_user.role_code — 'ADMIN' | 'MANAGER' | …). 관람객 등 미지정 계정은 null/undefined.
|
||||
roleCode?: string | null;
|
||||
}
|
||||
export interface LoginResponse {
|
||||
accessToken: string;
|
||||
@ -136,6 +138,8 @@ export interface KintexPrincipal {
|
||||
displayName: string;
|
||||
eventRoles: Record<string, EventRole>;
|
||||
hallManager: boolean;
|
||||
// 전역 역할(app_user.role_code, JWT rc 클레임 복원). 미지정 시 null/undefined.
|
||||
roleCode?: string | null;
|
||||
}
|
||||
|
||||
// ── 3. M2 플로어플랜 (SCR-03/04) ──
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||
import { DdayChip } from '../ui/Badge';
|
||||
import {
|
||||
IconAnalytics,
|
||||
@ -21,24 +22,46 @@ import {
|
||||
import './app-shell.css';
|
||||
|
||||
/**
|
||||
* 사이드바 항목. `to`가 있으면 react-router NavLink(활성 표시), 없으면 플레이스홀더(#).
|
||||
* 플레이스홀더 항목(대시보드·플로어플랜 등)은 행사 스코프(:eventId) 라우트라 셸 밖에서 진입한다.
|
||||
* 신규 4항목(경영분석·현장운영·전시 일정·관리자)은 테넌트 전역 라우트로 직접 배선.
|
||||
* 사이드바 항목. `to`가 있으면 react-router NavLink(활성 표시).
|
||||
* `scoped: true` 항목(대시보드·플로어플랜·참가업체)은 행사 스코프(:eventId) 라우트라
|
||||
* 정적 to 로 배선할 수 없다 → 클릭 시 useResolvedEventId 로 대상 행사를 해소해 계산된 경로로 이동하고,
|
||||
* 해소 불가(참여 행사 없음) 시 행사 선택 화면(/login 워크스페이스 그리드)으로 보낸다.
|
||||
*/
|
||||
const NAV_ITEMS: { key: string; label: string; Icon: ComponentType<IconProps>; to?: string }[] = [
|
||||
{ key: 'dashboard', label: '대시보드', Icon: IconDashboard },
|
||||
{ key: 'floorplan', label: '플로어플랜 스튜디오', Icon: IconFloorplan },
|
||||
{ key: 'exhibitors', label: '참가업체 관리', Icon: IconExhibitors },
|
||||
const NAV_ITEMS: {
|
||||
key: string;
|
||||
label: string;
|
||||
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: 'operations', label: '현장운영', Icon: IconOperations, to: '/ops/operations' },
|
||||
{ key: 'analytics', label: '경영분석', Icon: IconAnalytics, to: '/analytics' },
|
||||
{ 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' },
|
||||
];
|
||||
|
||||
/**
|
||||
* 행사 스코프 항목 → 실제 경로 빌더(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 이식분) — 백엔드 미구현 모듈은 화면에 "샘플 데이터" 배지 표기. */
|
||||
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: 'auctions', label: '공사 옥션', Icon: IconSettlement, to: '/auctions' },
|
||||
{ key: 'visitors', label: '관람객', Icon: IconUsers, to: '/visitors' },
|
||||
@ -70,6 +93,17 @@ export function AppShell() {
|
||||
const workspace = useAuthStore((s) => s.currentWorkspace());
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
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 (
|
||||
<div className="kx-shell">
|
||||
@ -103,10 +137,14 @@ export function AppShell() {
|
||||
{item.label}
|
||||
</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>
|
||||
{item.label}
|
||||
</a>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@ -95,6 +95,15 @@
|
||||
color: var(--color-primary-700);
|
||||
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 {
|
||||
width: 20px;
|
||||
display: inline-flex;
|
||||
|
||||
@ -3,11 +3,12 @@
|
||||
* 백엔드 SystemAccessGuard.requireAdmin 이 최종 권위(홀매니저 || app_user.role_code=ADMIN)이며,
|
||||
* 이 컴포넌트는 비관리자에게 관리자 화면 UI 를 노출하지 않기 위한 프론트 게이트다.
|
||||
*
|
||||
* ★ 판정 근거: 프론트 authStore 는 전역 role_code(ADMIN/MANAGER)를 노출하지 않고
|
||||
* AuthUser.hallManager(플랫폼 운영자 플래그)와 행사 스코프 역할(EventRole)만 보유한다.
|
||||
* 따라서 프론트에서 확인 가능한 관리자 신호 = hallManager===true, 보조로 현재 행사 역할 HALL_MANAGER.
|
||||
* 전역 ADMIN/MANAGER 계정이라도 hallManager=false 이면 프론트에선 구분 불가 → 이 경우 백엔드가 통과시키므로
|
||||
* 화면 진입 후 데이터는 정상 로드된다(게이트는 명백한 비관리자 차단 목적). 이 한계는 _workspace 노트에 기록.
|
||||
* ★ 판정 근거: authStore(AuthUser)는 이제 전역 role_code(app_user.role_code)를 순증 노출한다.
|
||||
* 프론트 관리자 신호 = roleCode==='ADMIN' || roleCode==='MANAGER' || hallManager===true,
|
||||
* 보조 폴백으로 현재 행사 역할 HALL_MANAGER. roleCode 는 로그인 응답 user.roleCode 로 전달된다
|
||||
* (관람객 등 미지정 계정은 null → 비관리자). 백엔드 SystemAccessGuard.requireAdmin 이 최종 권위이며,
|
||||
* 이 게이트는 UI 노출 차단(방어 심층화) 목적이다. (기존 hallManager 단독 판정의 전역 ADMIN/MANAGER
|
||||
* 식별 불가 한계는 roleCode 순증으로 해소.)
|
||||
*
|
||||
* 사용(통합자 배선): 관리자 라우트 element 를 <AdminGuard> 로 감싼다. App.tsx 는 이 트랙에서 수정하지 않는다.
|
||||
*/
|
||||
@ -21,8 +22,10 @@ export function useIsAdmin(): boolean {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const roleForCurrent = useAuthStore((s) => s.roleForCurrent);
|
||||
if (!user) return false;
|
||||
const role = (user.roleCode ?? '').toUpperCase();
|
||||
if (role === 'ADMIN' || role === 'MANAGER') return true;
|
||||
if (user.hallManager) return true;
|
||||
return roleForCurrent() === 'HALL_MANAGER';
|
||||
return roleForCurrent() === 'HALL_MANAGER'; // 폴백: 현재 행사 홀매니저 역할
|
||||
}
|
||||
|
||||
export function AdminGuard({ children }: { children: ReactNode }) {
|
||||
|
||||
400
src/frontend/src/screens/hallassign/HallAssignmentPage.tsx
Normal file
400
src/frontend/src/screens/hallassign/HallAssignmentPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
115
src/frontend/src/screens/hallassign/hallAssignApi.ts
Normal file
115
src/frontend/src/screens/hallassign/hallAssignApi.ts
Normal 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'),
|
||||
};
|
||||
261
src/frontend/src/screens/hallassign/hallassign.css
Normal file
261
src/frontend/src/screens/hallassign/hallassign.css
Normal 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;
|
||||
}
|
||||
@ -1,11 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { occupancyTone } from '../chartColors';
|
||||
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 { useResolvedEventId } from '../../hooks/useResolvedEventId';
|
||||
import type { OpsData } from '../../api/types';
|
||||
import {
|
||||
HALL_CELLS,
|
||||
HVAC as SAMPLE_HVAC,
|
||||
@ -15,11 +13,13 @@ import {
|
||||
import './ops.css';
|
||||
|
||||
/*
|
||||
* SCR-16 홀 현장운영 대시보드 (M14). Stitch hall_operations 이식.
|
||||
* 정상 경로: GET /api/events/{eventId}/ops (배정 홀 실목록 + HVAC/조명/주차).
|
||||
* ★ 센서·IoT 미연동(PLANNING M14) → 백엔드가 estPeople=0·hvac=null·조명/주차 빈배열을 정직 반환.
|
||||
* 따라서 실 경로에서도 대부분 빈 상태 UI로 표기(가짜 센서값으로 덮지 않음).
|
||||
* 폴백: NETWORK/NOT_FOUND 시에만 sampleOps 로 강등.
|
||||
* SCR-14 홀 현장운영 대시보드 (M14). Stitch hall_operations 이식.
|
||||
* 실데이터 경로: GET /api/events/{eventId}/ops (백엔드 OpsDto 확장).
|
||||
* ├ 혼잡(halls.estPeople/heat) = M10 체크인 실집계 → 홀 판매면적 비중 분배(추정).
|
||||
* ├ 전력(halls.powerKw/powerTotalKw) = M4 유틸리티 신청 부하(requestedKw) 홀별 합.
|
||||
* └ checkinSeries = 시간대별 체크인 추이(혼잡 프로파일).
|
||||
* ★ HVAC/조명/주차 = 센서·IoT 미연동(PLANNING M14) → null/빈배열 정직 반환 → "센서(M14) 연동 시 표시".
|
||||
* 폴백: NETWORK/NOT_FOUND/NOT_IMPLEMENTED 시에만 sampleOps 로 강등 + "오프라인 — 샘플" 표기.
|
||||
*/
|
||||
export function HallOperationsPage() {
|
||||
const [mode, setMode] = useState<'live' | 'history'>('live');
|
||||
@ -27,26 +27,31 @@ export function HallOperationsPage() {
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['ops', eventId],
|
||||
queryFn: () => opsApi.get(eventId as string),
|
||||
queryFn: () => opsApiLocal.get(eventId as string),
|
||||
enabled: !!eventId,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
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 hvac = data?.hvac;
|
||||
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 hasPeople = totalPeople > 0;
|
||||
const onSite = data?.onSiteCount ?? 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 (
|
||||
<div className="kx-ops">
|
||||
<header className="kx-ops__head">
|
||||
<div>
|
||||
<h1 className="kx-ops__title">홀 현장운영</h1>
|
||||
<p className="kx-ops__subtitle">실시간 시설 모니터링 및 자원 최적화</p>
|
||||
<p className="kx-ops__subtitle">체크인·유틸리티 실집계 기반 현황</p>
|
||||
</div>
|
||||
<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')}>
|
||||
@ -77,13 +82,13 @@ export function HallOperationsPage() {
|
||||
|
||||
{data && (
|
||||
<div className="kx-ops__grid">
|
||||
{/* 히어로: 홀 혼잡도 */}
|
||||
{/* 히어로: 홀 혼잡(체크인 파생) */}
|
||||
<section className="kx-card kx-ops__heat" aria-label="홀별 현황">
|
||||
<div className="kx-card__head">
|
||||
<h2>홀별 현황</h2>
|
||||
</div>
|
||||
<span className="kx-ops__sensor-badge">
|
||||
{hasPeople ? '체크인(M10) 파생' : '센서 미연동 · 배정 홀 목록'}
|
||||
{hasPeople ? '체크인(M10) 파생 · 면적 비중 분배' : '센서 미연동 · 배정 홀 목록'}
|
||||
</span>
|
||||
|
||||
{data.halls.length === 0 ? (
|
||||
@ -109,9 +114,9 @@ export function HallOperationsPage() {
|
||||
</ul>
|
||||
<span className="kx-ops__total">
|
||||
{hasPeople ? (
|
||||
<>총 추정 인원 <strong className="tnum">{totalPeople.toLocaleString()}</strong>명</>
|
||||
<>현장 체크인 <strong className="tnum">{onSite.toLocaleString()}</strong>명</>
|
||||
) : (
|
||||
<>인원 계측 미연동</>
|
||||
<>체크인 집계 없음</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@ -119,15 +124,80 @@ export function HallOperationsPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 우측: HVAC + 조명존 */}
|
||||
{/* 우측: 전력 부하(M4 실집계) + 시간대 추이 */}
|
||||
<aside className="kx-ops__aside">
|
||||
<section className="kx-card" aria-label="공조(HVAC)">
|
||||
<section className="kx-card" aria-label="전력 부하">
|
||||
<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>
|
||||
{!hasHvac ? (
|
||||
<EmptyState title="공조 계측 미연동" description="HVAC 센서가 연동되면 온·습도·CO₂가 표시됩니다." />
|
||||
<span className="kx-ops__sensor-badge">유틸리티 신청(M4) 부하 집계</span>
|
||||
{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>
|
||||
<span className="kx-ops__meta-k">온도</span>
|
||||
@ -143,59 +213,14 @@ export function HallOperationsPage() {
|
||||
</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) => (
|
||||
<li key={z.zone} className="kx-barlist__item">
|
||||
<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>
|
||||
<div key={z.zone} className="kx-ops__meta-k">{z.zone}: {z.onPercent}%</div>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
{/* 하단: 주차 점유 */}
|
||||
<section className="kx-card kx-ops__parking" aria-label="주차 점유 현황">
|
||||
<div className="kx-card__head">
|
||||
<h2>주차 점유 현황</h2>
|
||||
{data.parking.map((z) => (
|
||||
<div key={z.zone} className="kx-ops__meta-k">
|
||||
{z.zone}: {z.occupancy.toLocaleString()}/{z.capacity.toLocaleString()}면
|
||||
</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>
|
||||
)}
|
||||
</section>
|
||||
@ -219,6 +244,10 @@ function heatClass(heat: string): string {
|
||||
return 'smooth';
|
||||
}
|
||||
|
||||
function formatKw(kw: number): string {
|
||||
return kw % 1 === 0 ? kw.toLocaleString() : kw.toFixed(1);
|
||||
}
|
||||
|
||||
function isDegradable(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof ApiRequestError &&
|
||||
@ -227,12 +256,21 @@ function isDegradable(error: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* 샘플 폴백(백엔드 shape 매핑) — 정상 경로는 실 API. NETWORK/NOT_FOUND 시에만 사용.
|
||||
* 근거: 기존 sampleOps.ts(센서·IoT 미연동 시연 데이터)를 OpsData 로 강등 변환.
|
||||
* 샘플 폴백(백엔드 확장 shape 매핑) — 정상 경로는 실 API. NETWORK/NOT_FOUND/NOT_IMPLEMENTED 시에만 사용.
|
||||
* 근거: 기존 sampleOps.ts(시연 데이터)를 OpsDataExt 로 강등 변환. 하드웨어 위젯은 미연동 상태를 보존.
|
||||
*/
|
||||
const FALLBACK: OpsData = {
|
||||
halls: HALL_CELLS.map((c) => ({ hallId: c.id, label: c.label, estPeople: c.estPeople, heat: c.level })),
|
||||
const FALLBACK: OpsDataExt = {
|
||||
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 },
|
||||
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 })),
|
||||
onSiteCount: HALL_CELLS.reduce((s, c) => s + c.estPeople, 0),
|
||||
powerTotalKw: null,
|
||||
checkinSeries: [],
|
||||
};
|
||||
|
||||
@ -222,6 +222,15 @@
|
||||
font-size: var(--fs-h3);
|
||||
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 {
|
||||
font-size: var(--fs-caption);
|
||||
font-weight: 600;
|
||||
|
||||
39
src/frontend/src/screens/ops/opsApi.ts
Normal file
39
src/frontend/src/screens/ops/opsApi.ts
Normal 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`),
|
||||
};
|
||||
608
src/frontend/src/screens/settlement/SettlementDashboardPage.tsx
Normal file
608
src/frontend/src/screens/settlement/SettlementDashboardPage.tsx
Normal 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);
|
||||
}
|
||||
337
src/frontend/src/screens/settlement/settlement.css
Normal file
337
src/frontend/src/screens/settlement/settlement.css
Normal 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;
|
||||
}
|
||||
122
src/frontend/src/screens/settlement/settlementApi.ts
Normal file
122
src/frontend/src/screens/settlement/settlementApi.ts
Normal 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),
|
||||
};
|
||||
@ -16,6 +16,8 @@ interface AuthState {
|
||||
logout: () => void;
|
||||
currentWorkspace: () => WorkspaceDto | null;
|
||||
roleForCurrent: () => EventRole | null;
|
||||
// 전역 역할(app_user.role_code — 'ADMIN' | 'MANAGER' | …). 없으면 null. 관리자 판정(AdminGuard) 등에 사용.
|
||||
globalRole: () => string | null;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
@ -46,4 +48,6 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
},
|
||||
|
||||
roleForCurrent: () => get().currentWorkspace()?.myRole ?? null,
|
||||
|
||||
globalRole: () => get().user?.roleCode ?? null,
|
||||
}));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user