diff --git a/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminRulesetController.java b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminRulesetController.java new file mode 100644 index 0000000..93a5d2b --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminRulesetController.java @@ -0,0 +1,46 @@ +package com.zioinfo.kintex.admin; + +import com.fasterxml.jackson.databind.JsonNode; +import com.zioinfo.kintex.admin.dto.RulesetSummaryDto; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * SCR-A8 규정 룰셋 버전 조회 API (M18). 시스템관리자(홀매니저/ADMIN)만 접근. 읽기 전용. + * 룰셋은 classpath 리소스로 버전 관리되며 편집 엔드포인트는 제공하지 않는다(규정 개정 = 리소스 교체). + */ +@RestController +@RequestMapping("/api/admin/rulesets") +public class AdminRulesetController { + + private final AdminRulesetService service; + private final SystemAccessGuard guard; + + public AdminRulesetController(AdminRulesetService service, SystemAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** GET — 룰셋 파일 요약 목록(파일명·버전·규칙 수·모듈 분포). */ + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal KintexPrincipal principal) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.list()); + } + + /** GET — 지정 룰셋의 규칙 전문(JSON 원문). */ + @GetMapping("/{name}") + public ApiResponse detail(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String name) { + guard.requireAdmin(principal); + return ApiResponse.ok(service.detail(name)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminRulesetService.java b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminRulesetService.java new file mode 100644 index 0000000..1a4e1b8 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/admin/AdminRulesetService.java @@ -0,0 +1,113 @@ +package com.zioinfo.kintex.admin; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zioinfo.kintex.admin.dto.RulesetSummaryDto; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * 관리자 룰셋 조회 서비스 — classpath {@code rulesets/*.json}(규정·요율)을 열거·파싱해 버전 이력과 규칙 전문을 제공한다. + * 룰셋은 코드가 아닌 데이터로 버전 관리되므로(규정 개정 대응) DB 없이 리소스만 조회한다. 편집 없음(읽기 전용). + */ +@Service +public class AdminRulesetService { + + private static final Logger log = LoggerFactory.getLogger(AdminRulesetService.class); + + /** 파일명 화이트리스트(경로 트래버설 차단) — 열거된 리소스명과 이중 대조한다. */ + private static final Pattern SAFE_NAME = Pattern.compile("[A-Za-z0-9._-]+\\.json"); + + private final ObjectMapper objectMapper; + private final PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); + + public AdminRulesetService(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + /** 룰셋 파일 요약 목록(파일명·버전·규칙 수·모듈 분포). */ + public List list() { + List out = new ArrayList<>(); + for (Resource r : enumerate()) { + String name = r.getFilename(); + if (name == null) { + continue; + } + JsonNode root = readTree(r, name); + if (root == null) { + continue; + } + out.add(summarize(name, root)); + } + out.sort((a, b) -> b.name().compareTo(a.name())); // 최신 버전 파일이 위로 + return out; + } + + /** 지정 룰셋 파일의 규칙 전문(JSON 원문 파싱 트리). 존재하지 않으면 404. */ + public JsonNode detail(String name) { + if (name == null || !SAFE_NAME.matcher(name).matches()) { + throw new ApiException(ErrorCode.VALIDATION, "룰셋 파일명이 유효하지 않습니다."); + } + for (Resource r : enumerate()) { + if (name.equals(r.getFilename())) { + JsonNode root = readTree(r, name); + if (root == null) { + throw new ApiException(ErrorCode.NOT_FOUND, "룰셋을 읽을 수 없습니다."); + } + return root; + } + } + throw new ApiException(ErrorCode.NOT_FOUND, "룰셋을 찾을 수 없습니다."); + } + + private RulesetSummaryDto summarize(String name, JsonNode root) { + String version = root.path("rulesetVersion").asText(name); + String effectiveDate = root.hasNonNull("effectiveDate") ? root.get("effectiveDate").asText() : null; + JsonNode rules = root.path("rules"); + int ruleCount = rules.isArray() ? rules.size() : 0; + + Map dist = new LinkedHashMap<>(); + if (rules.isArray()) { + for (JsonNode rule : rules) { + JsonNode modules = rule.path("module"); + if (modules.isArray()) { + for (JsonNode m : modules) { + dist.merge(m.asText(), 1, Integer::sum); + } + } + } + } + return new RulesetSummaryDto(name, version, effectiveDate, ruleCount, dist); + } + + private Resource[] enumerate() { + try { + return resolver.getResources("classpath:rulesets/*.json"); + } catch (IOException e) { + log.error("룰셋 리소스 열거 실패: {}", e.getMessage()); + return new Resource[0]; + } + } + + private JsonNode readTree(Resource r, String name) { + try (InputStream in = r.getInputStream()) { + return objectMapper.readTree(in); + } catch (IOException e) { + log.error("룰셋 파싱 실패({}): {}", name, e.getMessage()); + return null; + } + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/admin/dto/RulesetSummaryDto.java b/src/backend/src/main/java/com/zioinfo/kintex/admin/dto/RulesetSummaryDto.java new file mode 100644 index 0000000..4647c8f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/admin/dto/RulesetSummaryDto.java @@ -0,0 +1,21 @@ +package com.zioinfo.kintex.admin.dto; + +import java.util.Map; + +/** + * 규정/요율 룰셋 파일 요약(SCR-A8 룰셋 버전 조회). 편집이 아닌 조회 전용 — 룰셋은 classpath 리소스로 버전 관리된다. + * + * @param name 리소스 파일명 (예: compliance-v1.json) + * @param version rulesetVersion 필드 (없으면 파일명) + * @param effectiveDate 발효일(있는 경우) + * @param ruleCount rules 배열 규칙 수(요율 마스터는 0) + * @param moduleDistribution 규칙이 참조하는 모듈(M2·M3 …)별 개수 분포 + */ +public record RulesetSummaryDto( + String name, + String version, + String effectiveDate, + int ruleCount, + Map moduleDistribution +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java new file mode 100644 index 0000000..9917f10 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionController.java @@ -0,0 +1,84 @@ +package com.zioinfo.kintex.auction; + +import com.zioinfo.kintex.auction.dto.AuctionDtos.*; +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.audit.Audited; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * M15 공사/장치 옥션 API (SCR-26·27·29). + * 인증 필수. 개설=발주자(주최자/참가업체/홀매니저), 응찰=등록 장치업체, 낙찰/전체비교=발주자. + *

봉인 입찰·등록업체 게이트는 {@link AuctionService}가 서버에서 강제한다(컨트롤러는 인증만 확인). + */ +@RestController +@RequestMapping("/api/auctions") +public class AuctionController { + + private final AuctionService service; + private final EventAccessGuard guard; + + public AuctionController(AuctionService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** GET /api/auctions?eventId=&page=&size= — 옥션 목록(카드). */ + @GetMapping + public ApiResponse> list(@AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false) String eventId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "100") int size) { + guard.require(principal); + return ApiResponse.ok(service.list(eventId, page, size)); + } + + /** POST /api/auctions — 옥션 개설(발주자 권한, 서비스에서 검증). */ + @Audited(action = "AUCTION_CREATE", targetType = "auction") + @PostMapping + public ApiResponse create(@AuthenticationPrincipal KintexPrincipal principal, + @RequestBody CreateAuctionRequest req) { + guard.require(principal); + return ApiResponse.ok(service.create(principal, req)); + } + + /** GET /api/auctions/{id} — 상세·봉인 순위. 타사 금액·업체명은 서버가 마스킹. */ + @GetMapping("/{id}") + public ApiResponse detail(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + return ApiResponse.ok(service.getDetail(id, principal)); + } + + /** POST /api/auctions/{id}/bids — 응찰(견적서 제출). 등록업체만·마감 후 409. */ + @Audited(action = "AUCTION_BID", targetType = "bid") + @PostMapping("/{id}/bids") + public ApiResponse bid(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @RequestBody BidRequest req) { + guard.require(principal); + return ApiResponse.ok(service.placeBid(id, principal, req)); + } + + /** POST /api/auctions/{id}/award — 낙찰(발주자·마감 후만). 사유 필수·감사 추적. */ + @Audited(action = "AUCTION_AWARD", targetType = "award") + @PostMapping("/{id}/award") + public ApiResponse award(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @RequestBody AwardRequest req) { + guard.require(principal); + return ApiResponse.ok(service.award(id, principal, req)); + } + + /** GET /api/auctions/{id}/award-view — 마감 후 발주자 전용 전체 견적 비교(봉인 해제). */ + @GetMapping("/{id}/award-view") + public ApiResponse awardView(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + return ApiResponse.ok(service.awardView(id, principal)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java new file mode 100644 index 0000000..1429d2e --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionMapper.java @@ -0,0 +1,228 @@ +package com.zioinfo.kintex.auction; + +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; + +/** + * M15 옥션 매퍼 — auction·auction_invite·bid·award·company_reputation. + *

★ Map 반환 @Select 별칭은 반드시 쌍따옴표(AS "x") — PG는 따옴표 없는 별칭을 소문자로 접어 + * Map 키가 전부 null이 된다(WORK_STATUS §7). 신규 매퍼 필수 점검. + *

봉인 입찰 보안은 서비스 레이어에서 강제한다 — 매퍼는 원천 데이터를 반환하되, + * 응찰자 뷰로 나가는 순위/상세는 서비스가 타사 금액·업체명을 마스킹한다. + */ +@Mapper +public interface AuctionMapper { + + String AUCTION_COLS = """ + SELECT a.id, + a.event_id AS "eventId", + a.title, + a.category, + a.auction_type AS "auctionType", + a.award_criteria AS "awardCriteria", + a.weight_price AS "weightPrice", + a.weight_reputation AS "weightReputation", + a.weight_delivery AS "weightDelivery", + a.round, + to_char(a.deadline AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "deadline", + CEIL(EXTRACT(EPOCH FROM (a.deadline - now())) / 86400.0)::int AS "dday", + (a.deadline < now()) AS "closed", + a.materials, + a.accent, + a.material_package_id AS "materialPackageId", + (SELECT count(*) FROM bid b WHERE b.auction_id = a.id)::int AS "bidderCount", + (SELECT min(b.total) FROM bid b WHERE b.auction_id = a.id) AS "lowestPrice", + aw.bid_id AS "awardBidId", + wc.name AS "awardedCompany", + wb.total AS "finalPrice" + FROM auction a + LEFT JOIN award aw ON aw.auction_id = a.id + LEFT JOIN bid wb ON wb.id = aw.bid_id + LEFT JOIN company wc ON wc.id = wb.company_id + """; + + /** 옥션 목록 — eventId 선택 필터. 마감 임박(deadline) 우선 정렬. */ + @Select("") + List> listAuctions(@Param("eventId") String eventId, + @Param("limit") int limit, + @Param("offset") int offset); + + /** 옥션 단건. */ + @Select("") + Map findAuction(@Param("id") String id); + + /** 순위용 원천 응찰 목록(총액 오름차순). 서비스가 봉인 마스킹한다. */ + @Select(""" + SELECT b.company_id AS "companyId", + b.total, + b.lead_days AS "leadDays" + FROM bid b + WHERE b.auction_id = #{auctionId} + ORDER BY b.total ASC, b.submitted_at ASC + """) + List> listRanking(@Param("auctionId") String auctionId); + + /** 발주자 뷰(마감 후) — 전 견적 + 업체명 + 평판 공개. */ + @Select(""" + SELECT b.id AS "bidId", + b.company_id AS "companyId", + c.name AS "companyName", + b.total, + b.lead_days AS "leadDays", + COALESCE(r.rating, 4.0) AS "rating", + COALESCE(r.jobs_done, 0) AS "jobsDone", + COALESCE(r.claim_rate, 0) AS "claimRate" + FROM bid b + JOIN company c ON c.id = b.company_id + LEFT JOIN company_reputation r ON r.company_id = b.company_id + WHERE b.auction_id = #{auctionId} + ORDER BY b.total ASC + """) + List> listQuotes(@Param("auctionId") String auctionId); + + /** 사용자의 CONTRACTOR 소속 등록업체(행사 스코프). 봉인/응찰 게이트용. */ + @Select(""" + SELECT em.company_id AS "companyId", + c.name AS "companyName", + c.registered AS "registered" + FROM event_member em + JOIN company c ON c.id = em.company_id + WHERE em.user_id = #{userId} + AND em.event_id = #{eventId} + AND em.role_code = 'CONTRACTOR' + AND em.company_id IS NOT NULL + LIMIT 1 + """) + Map findContractorCompany(@Param("userId") String userId, + @Param("eventId") String eventId); + + /** 옥션 초대(등록업체) 총 수 — 0이면 공개 옥션으로 간주. */ + @Select("SELECT count(*) FROM auction_invite WHERE auction_id = #{auctionId}") + int countInvites(@Param("auctionId") String auctionId); + + @Select("SELECT count(*) FROM auction_invite WHERE auction_id = #{auctionId} AND company_id = #{companyId}") + int isInvited(@Param("auctionId") String auctionId, @Param("companyId") String companyId); + + @Select(""" + SELECT b.id, b.total, b.version, b.lead_days AS "leadDays" + FROM bid b WHERE b.auction_id = #{auctionId} AND b.company_id = #{companyId} + """) + Map findBid(@Param("auctionId") String auctionId, @Param("companyId") String companyId); + + @Insert(""" + INSERT INTO bid (id, auction_id, company_id, bidder_user_id, round, + subtotal, vat, total, lead_days, valid_until, terms, lines) + VALUES (#{id}, #{auctionId}, #{companyId}, #{bidderUserId}, #{round}, + #{subtotal}, #{vat}, #{total}, #{leadDays}, + CAST(#{validUntil} AS date), #{terms}, CAST(#{lines} AS jsonb)) + """) + int insertBid(Map p); + + @Update(""" + UPDATE bid SET subtotal=#{subtotal}, vat=#{vat}, total=#{total}, lead_days=#{leadDays}, + valid_until=CAST(#{validUntil} AS date), terms=#{terms}, lines=CAST(#{lines} AS jsonb), + round=#{round}, version = version + 1, bidder_user_id=#{bidderUserId}, updated_at=now() + WHERE auction_id=#{auctionId} AND company_id=#{companyId} + """) + int updateBid(Map p); + + @Select(""" + SELECT aw.id, aw.bid_id AS "bidId", aw.reason, + c.name AS "companyName", + to_char(aw.awarded_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "awardedAt" + FROM award aw + JOIN bid b ON b.id = aw.bid_id + JOIN company c ON c.id = b.company_id + WHERE aw.auction_id = #{auctionId} + """) + Map findAward(@Param("auctionId") String auctionId); + + @Select("SELECT auction_id FROM bid WHERE id = #{bidId}") + String findAuctionIdOfBid(@Param("bidId") String bidId); + + @Insert(""" + INSERT INTO award (id, auction_id, bid_id, reason, awarded_by) + VALUES (#{id}, #{auctionId}, #{bidId}, #{reason}, #{awardedBy}) + """) + int insertAward(Map p); + + @Insert(""" + INSERT INTO auction (id, event_id, title, category, auction_type, award_criteria, + weight_price, weight_reputation, weight_delivery, round, deadline, + material_package_id, materials, accent, created_by) + VALUES (#{id}, #{eventId}, #{title}, #{category}, #{auctionType}, #{awardCriteria}, + #{weightPrice}, #{weightReputation}, #{weightDelivery}, #{round}, + CAST(#{deadline} AS timestamptz), #{materialPackageId}, #{materials}, #{accent}, #{createdBy}) + """) + int insertAuction(Map p); + + @Insert(""" + INSERT INTO auction_invite (id, auction_id, company_id) + VALUES (#{id}, #{auctionId}, #{companyId}) + ON CONFLICT (auction_id, company_id) DO NOTHING + """) + int insertInvite(Map p); + + /** 초대 후보 검증 — 등록업체(registered=true)만 통과. 미등록 id는 반환되지 않는다. */ + @Select("") + List filterRegistered(@Param("ids") List ids); + + // ── 업체 포털 대시보드(SCR-38) ── + /** 사용자의 CONTRACTOR 소속 등록업체(행사 무관 — 대시보드용). */ + @Select(""" + SELECT em.company_id AS "companyId", c.name AS "companyName", c.registered AS "registered" + FROM event_member em + JOIN company c ON c.id = em.company_id + WHERE em.user_id = #{userId} AND em.role_code = 'CONTRACTOR' AND em.company_id IS NOT NULL + ORDER BY em.created_at DESC + LIMIT 1 + """) + Map findUserCompany(@Param("userId") String userId); + + /** 내 업체의 응찰 목록(진행중 옥션) — 자사 금액만. */ + @Select(""" + SELECT a.id AS "auctionId", a.title AS "auctionTitle", + b.total AS "myPrice", + CEIL(EXTRACT(EPOCH FROM (a.deadline - now())) / 86400.0)::int AS "dday", + (a.deadline < now()) AS "closed" + FROM bid b + JOIN auction a ON a.id = b.auction_id + WHERE b.company_id = #{companyId} + ORDER BY a.deadline ASC + """) + List> listMyBids(@Param("companyId") String companyId); + + /** 특정 옥션에서 내 총액보다 낮은 응찰 수(내 순위 = cnt + 1). 봉인 안전(카운트만). */ + @Select(""" + SELECT count(*)::int FROM bid b + WHERE b.auction_id = #{auctionId} AND b.total < #{myTotal} + """) + int lowerBidCount(@Param("auctionId") String auctionId, @Param("myTotal") long myTotal); + + /** 내 업체가 낙찰받은 부스(수주). */ + @Select(""" + SELECT a.id AS "auctionId", a.title AS "auctionTitle", a.category, e.name AS "eventName", + CEIL(EXTRACT(EPOCH FROM (a.deadline - now())) / 86400.0)::int AS "dday" + FROM award aw + JOIN bid b ON b.id = aw.bid_id + JOIN auction a ON a.id = aw.auction_id + JOIN event e ON e.id = a.event_id + WHERE b.company_id = #{companyId} + ORDER BY aw.awarded_at DESC + """) + List> listMyAwardedBooths(@Param("companyId") String companyId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionScoring.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionScoring.java new file mode 100644 index 0000000..707387c --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionScoring.java @@ -0,0 +1,53 @@ +package com.zioinfo.kintex.auction; + +import com.zioinfo.kintex.auction.dto.AuctionDtos.Weights; + +/** + * 낙찰 종합점수 산식(순수 함수 — 단위 테스트 대상). + * + *

세 축을 0~100 으로 정규화한 뒤 가중 평균한다. 모두 높을수록 우수: + *

    + *
  • 가격: minTotal/total × 100 — 최저가가 100점, 비쌀수록 감점.
  • + *
  • 평판: rating/5 × 100 — 5점 만점 평점.
  • + *
  • 납기: minLead/lead × 100 — 가장 빠른 납기가 100점.
  • + *
+ * 낙찰기준 {@code lowest}는 가격 점수만 사용, {@code comprehensive}는 가중 평균. + * 가중치 합이 0이면 분모를 1로 보정한다. + */ +public final class AuctionScoring { + + private AuctionScoring() { + } + + public static double priceScore(long total, long minTotal) { + if (total <= 0) return 0.0; + return minTotal > 0 ? (double) minTotal / total * 100.0 : 100.0; + } + + public static double reputationScore(double rating) { + double clamped = Math.max(0.0, Math.min(rating, 5.0)); + return clamped / 5.0 * 100.0; + } + + public static double deliveryScore(int lead, int minLead) { + return lead > 0 && minLead > 0 ? (double) minLead / lead * 100.0 : 100.0; + } + + /** 종합점수(소수 1자리 반올림). */ + public static double score(String criteria, Weights w, + long total, long minTotal, int lead, int minLead, double rating) { + double price = priceScore(total, minTotal); + if ("lowest".equals(criteria)) { + return round1(price); + } + double rep = reputationScore(rating); + double delivery = deliveryScore(lead, minLead); + int denom = Math.max(w.price() + w.reputation() + w.delivery(), 1); + double weighted = (price * w.price() + rep * w.reputation() + delivery * w.delivery()) / denom; + return round1(weighted); + } + + public static double round1(double v) { + return Math.round(v * 10.0) / 10.0; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java new file mode 100644 index 0000000..f978961 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/AuctionService.java @@ -0,0 +1,529 @@ +package com.zioinfo.kintex.auction; + +import com.zioinfo.kintex.auction.dto.AuctionDtos.*; +import com.zioinfo.kintex.auth.EventRole; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * M15 옥션 서비스 — 목록·상세·응찰·낙찰·발주자 비교·업체 대시보드. + * + *

봉인 입찰 보안(서버 강제): + *

    + *
  • 마감 전 상세/순위 응답은 ①내 입찰가 ②현재 최저가(금액만, 업체 비식별) ③참여 업체 수·내 순위만 노출. + * 타사 입찰가·업체명은 절대 반환하지 않는다({@link #buildRanking}).
  • + *
  • 전 견적 공개({@link #awardView})는 마감 후 + 발주자만 가능.
  • + *
  • 응찰은 등록업체(company.registered=true) + (초대 옥션이면) 초대된 업체만.
  • + *
+ */ +@Service +public class AuctionService { + + private static final int MAX_LIMIT = 500; + + private final AuctionMapper mapper; + + public AuctionService(AuctionMapper mapper) { + this.mapper = mapper; + } + + // ── 목록(SCR-26) ────────────────────────────────────────────────────────── + public List list(String eventId, int page, int size) { + int limit = Math.min(Math.max(size, 1), MAX_LIMIT); + int offset = Math.max(page, 0) * limit; + List> rows = mapper.listAuctions(nullIfBlank(eventId), limit, offset); + List out = new ArrayList<>(); + for (Map r : rows) { + out.add(toSummary(r)); + } + return out; + } + + // ── 상세·봉인 순위(SCR-27) ───────────────────────────────────────────────── + public AuctionDetail getDetail(String id, KintexPrincipal principal) { + Map r = mapper.findAuction(id); + if (r == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + String eventId = str(r.get("eventId")); + boolean closed = bool(r.get("closed")); + boolean sealed = !closed; + + String myCompanyId = contractorCompanyId(principal, eventId); + boolean orderer = isOrderer(principal, eventId); + + List> rankRows = mapper.listRanking(id); + List ranking = buildRanking(rankRows, myCompanyId, sealed); + + Integer myRank = null; + Long myPrice = null; + for (RankRow row : ranking) { + if (row.isMe()) { + myRank = row.rank(); + myPrice = row.price(); + } + } + + boolean registered = myCompanyId != null && contractorRegistered(principal, eventId); + boolean invitedOk = myCompanyId != null + && (mapper.countInvites(id) == 0 || mapper.isInvited(id, myCompanyId) > 0); + boolean canBid = !closed && registered && invitedOk; + boolean canViewAward = closed && orderer; + + List materials = splitMaterials(r.get("materials")); + MaterialPackage pkg = materialPackage(str(r.get("materialPackageId")), materials); + + return new AuctionDetail( + id, eventId, str(r.get("title")), str(r.get("category")), + mapType(str(r.get("auctionType"))), status(r), + intOr(r.get("round"), 1), intOr(r.get("dday"), 0), str(r.get("deadline")), + intOr(r.get("bidderCount"), 0), lng(r.get("lowestPrice")), + str(r.get("awardCriteria")), + new Weights(intOr(r.get("weightPrice"), 0), intOr(r.get("weightReputation"), 0), + intOr(r.get("weightDelivery"), 0)), + materials, pkg, sealed, myRank, myPrice, ranking, canBid, canViewAward, + str(r.get("awardedCompany")), lng(r.get("finalPrice"))); + } + + /** + * 봉인 마스킹. 노출 대상 = 내 응찰 + 현재 최저가(금액만, 위치 라벨). 그 외 타사는 금액·업체명 미반환. + * (마감 후에도 상세 순위는 봉인 유지 — 전체 공개는 발주자 award-view 전용.) + */ + private List buildRanking(List> rows, String myCompanyId, boolean sealed) { + List out = new ArrayList<>(); + int rank = 0; + for (Map row : rows) { + rank++; + boolean isMe = myCompanyId != null && myCompanyId.equals(str(row.get("companyId"))); + boolean isLowest = rank == 1; + long total = lngPrim(row.get("total")); + // 봉인 규칙: 내 응찰과 최저가만 금액 공개. 그 외는 마스킹(업체명·금액 미반환). + boolean reveal = isMe || isLowest; + String alias = isMe ? "나의 응찰" : (isLowest ? "현재 최저가" : "업체 " + rank); + out.add(new RankRow(rank, alias, isMe, isLowest, !reveal, reveal ? total : null)); + } + return out; + } + + // ── 응찰(견적서 제출) ────────────────────────────────────────────────────── + @Transactional + public BidResult placeBid(String id, KintexPrincipal principal, BidRequest req) { + Map a = mapper.findAuction(id); + if (a == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + if (bool(a.get("closed"))) { + throw new ApiException(ErrorCode.CONFLICT); // 마감 후 응찰 불가(409) + } + boolean hasLines = req != null && req.lines() != null && !req.lines().isEmpty(); + if (req == null || (!hasLines && (req.total() == null || req.total() <= 0))) { + throw new ApiException(ErrorCode.VALIDATION); + } + String eventId = str(a.get("eventId")); + Map co = mapper.findContractorCompany(principal.userId(), eventId); + if (co == null) { + throw new ApiException(ErrorCode.FORBIDDEN); // 이 행사의 장치업체 멤버가 아님 + } + if (!bool(co.get("registered"))) { + throw new ApiException(ErrorCode.NOT_REGISTERED_COMPANY); // 미등록 업체 차단(403) + } + String companyId = str(co.get("companyId")); + if (mapper.countInvites(id) > 0 && mapper.isInvited(id, companyId) == 0) { + throw new ApiException(ErrorCode.FORBIDDEN); // 초대 옥션 — 미초대 차단 + } + + // 라인아이템이 있으면 서버가 소계 재계산(신뢰 경계). 없으면 total(부가세 별도)로 폴백. + long subtotal = hasLines + ? req.lines().stream().mapToLong(l -> nz(l.qty()) * nz(l.unitPrice())).sum() + : req.total(); + if (subtotal <= 0) { + throw new ApiException(ErrorCode.VALIDATION); + } + long vat = Math.round(subtotal * 0.1); + long total = subtotal; // 경쟁 순위 금액 = 부가세 별도 소계 + int leadDays = req.leadDays() == null ? 0 : req.leadDays(); + String linesJson = hasLines ? toJson(req.lines()) : null; + + Map p = new java.util.HashMap<>(); + p.put("auctionId", id); + p.put("companyId", companyId); + p.put("bidderUserId", principal.userId()); + p.put("round", intOr(a.get("round"), 1)); + p.put("subtotal", subtotal); + p.put("vat", vat); + p.put("total", total); + p.put("leadDays", leadDays); + p.put("validUntil", nullIfBlank(req.validUntil())); + p.put("terms", req.terms()); + p.put("lines", linesJson); + + Map existing = mapper.findBid(id, companyId); + int version; + String bidId; + if (existing == null) { + bidId = "bid-" + UUID.randomUUID().toString().substring(0, 12); + p.put("id", bidId); + mapper.insertBid(p); + version = 1; + } else { + bidId = str(existing.get("id")); + mapper.updateBid(p); // version = version + 1 + version = intOr(existing.get("version"), 1) + 1; + } + + int myRank = mapper.lowerBidCount(id, total) + 1; + Long lowest = lng(mapper.findAuction(id).get("lowestPrice")); + return new BidResult(bidId, intOr(a.get("round"), 1), total, vat, total, version, myRank, lowest); + } + + // ── 낙찰(Award) ──────────────────────────────────────────────────────────── + @Transactional + public AwardResult award(String id, KintexPrincipal principal, AwardRequest req) { + Map a = mapper.findAuction(id); + if (a == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + if (!isOrderer(principal, str(a.get("eventId")))) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + if (!bool(a.get("closed"))) { + throw new ApiException(ErrorCode.CONFLICT); // 마감 후에만 낙찰 가능(409) + } + if (req == null || req.bidId() == null || req.reason() == null || req.reason().isBlank()) { + throw new ApiException(ErrorCode.VALIDATION); // 사유 필수 + } + String bidAuction = mapper.findAuctionIdOfBid(req.bidId()); + if (bidAuction == null || !bidAuction.equals(id)) { + throw new ApiException(ErrorCode.VALIDATION); // 이 옥션의 응찰이 아님 + } + if (mapper.findAward(id) != null) { + throw new ApiException(ErrorCode.CONFLICT); // 이미 낙찰됨 + } + String awardId = "aw-" + UUID.randomUUID().toString().substring(0, 12); + Map p = new java.util.HashMap<>(); + p.put("id", awardId); + p.put("auctionId", id); + p.put("bidId", req.bidId()); + p.put("reason", req.reason()); + p.put("awardedBy", principal.userId()); + mapper.insertAward(p); + Map aw = mapper.findAward(id); + return new AwardResult(awardId, req.bidId(), str(aw.get("companyName")), str(aw.get("awardedAt"))); + } + + // ── 발주자 비교(SCR-29 · 마감 후 봉인 해제) ──────────────────────────────── + public AwardView awardView(String id, KintexPrincipal principal) { + Map a = mapper.findAuction(id); + if (a == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + if (!isOrderer(principal, str(a.get("eventId")))) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + if (!bool(a.get("closed"))) { + throw new ApiException(ErrorCode.FORBIDDEN); // 마감 전 봉인 유지 — 발주자도 전체 열람 불가 + } + List> rows = mapper.listQuotes(id); + String criteria = str(a.get("awardCriteria")); + Weights w = new Weights(intOr(a.get("weightPrice"), 0), intOr(a.get("weightReputation"), 0), + intOr(a.get("weightDelivery"), 0)); + + long minTotal = Long.MAX_VALUE; + int minLead = Integer.MAX_VALUE; + for (Map r : rows) { + minTotal = Math.min(minTotal, lngPrim(r.get("total"))); + minLead = Math.min(minLead, intOr(r.get("leadDays"), 0)); + } + + List quotes = new ArrayList<>(); + double bestScore = -1; + int bestIdx = -1; + // 1차: 스코어 계산 + double[] scores = new double[rows.size()]; + for (int i = 0; i < rows.size(); i++) { + Map r = rows.get(i); + long total = lngPrim(r.get("total")); + int lead = intOr(r.get("leadDays"), 0); + double rating = dbl(r.get("rating"), 4.0); + double priceScore = minTotal > 0 ? (double) minTotal / total * 100.0 : 100.0; + double repScore = rating / 5.0 * 100.0; + double leadScore = lead > 0 && minLead > 0 ? (double) minLead / lead * 100.0 : 100.0; + double score; + if ("lowest".equals(criteria)) { + score = priceScore; + } else { + int denom = Math.max(w.price() + w.reputation() + w.delivery(), 1); + score = (priceScore * w.price() + repScore * w.reputation() + leadScore * w.delivery()) / denom; + } + scores[i] = round1(score); + if (scores[i] > bestScore) { + bestScore = scores[i]; + bestIdx = i; + } + } + // 2차: DTO 조립 + for (int i = 0; i < rows.size(); i++) { + Map r = rows.get(i); + long total = lngPrim(r.get("total")); + int lead = intOr(r.get("leadDays"), 0); + boolean rec = i == bestIdx; + double delta = minTotal > 0 ? round1((double) (total - minTotal) / minTotal * 100.0) : 0.0; + quotes.add(new Quote( + str(r.get("bidId")), str(r.get("companyId")), str(r.get("companyName")), + "종합 " + (i + 1) + "위" + (rec ? " (추천)" : ""), rec, + total, delta, total == minTotal, lead, lead == minLead && lead > 0, + round1(dbl(r.get("rating"), 4.0)), scores[i])); + } + + List riskBars = riskBars(bestIdx >= 0 ? rows.get(bestIdx) : null); + + Map awRow = mapper.findAward(id); + AwardResult awarded = awRow == null ? null + : new AwardResult(str(awRow.get("id")), str(awRow.get("bidId")), + str(awRow.get("companyName")), str(awRow.get("awardedAt"))); + + return new AwardView(id, str(a.get("title")), rows.size(), criteria, w, quotes, riskBars, awarded); + } + + private List riskBars(Map recommended) { + double rating = recommended == null ? 4.0 : dbl(recommended.get("rating"), 4.0); + double claim = recommended == null ? 2.0 : dbl(recommended.get("claimRate"), 2.0); + double delayRisk = round1(Math.max(claim, 0)); + double budgetRisk = round1(Math.max(0, (5.0 - rating) * 8.0)); + double quality = round1(rating / 5.0 * 100.0); + List bars = new ArrayList<>(); + bars.add(new RiskBar("시공 지연 확률", delayRisk, delayRisk <= 5 ? "success" : "ai")); + bars.add(new RiskBar("예산 초과 위험", budgetRisk, budgetRisk <= 10 ? "success" : "ai")); + bars.add(new RiskBar("자재 품질 신뢰도", quality, quality >= 90 ? "success" : "ai")); + return bars; + } + + // ── 개설(SCR-26) ─────────────────────────────────────────────────────────── + @Transactional + public AuctionSummary create(KintexPrincipal principal, CreateAuctionRequest req) { + if (req == null || req.eventId() == null || req.eventId().isBlank() + || req.title() == null || req.title().isBlank() + || req.deadline() == null || req.deadline().isBlank()) { + throw new ApiException(ErrorCode.VALIDATION); + } + if (!isOrderer(principal, req.eventId())) { + throw new ApiException(ErrorCode.FORBIDDEN); + } + String id = "AUC-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); + Weights w = req.weights() != null ? req.weights() : new Weights(60, 25, 15); + List mats = req.materials() == null ? List.of() : req.materials(); + + Map p = new java.util.HashMap<>(); + p.put("id", id); + p.put("eventId", req.eventId()); + p.put("title", req.title()); + p.put("category", req.category() == null ? "전시디자인설치" : req.category()); + p.put("auctionType", "RFQ".equalsIgnoreCase(req.type()) ? "rfq" : "reverse"); + p.put("awardCriteria", "lowest".equals(req.awardCriteria()) ? "lowest" : "comprehensive"); + p.put("weightPrice", w.price()); + p.put("weightReputation", w.reputation()); + p.put("weightDelivery", w.delivery()); + p.put("round", req.round() == null ? 1 : req.round()); + p.put("deadline", req.deadline()); + p.put("materialPackageId", nullIfBlank(req.materialPackageId())); + p.put("materials", String.join(",", mats)); + p.put("accent", "primary"); + p.put("createdBy", principal.userId()); + mapper.insertAuction(p); + + // 초대: 등록업체(registered=true)만 통과 — 미등록 id는 서버가 걸러낸다(게이트 불변). + if (req.invitedCompanyIds() != null && !req.invitedCompanyIds().isEmpty()) { + List registeredIds = mapper.filterRegistered(req.invitedCompanyIds()); + for (String cid : registeredIds) { + Map inv = new java.util.HashMap<>(); + inv.put("id", "ai-" + UUID.randomUUID().toString().substring(0, 12)); + inv.put("auctionId", id); + inv.put("companyId", cid); + mapper.insertInvite(inv); + } + } + return toSummary(mapper.findAuction(id)); + } + + // ── 업체 포털 대시보드(SCR-38) ───────────────────────────────────────────── + public ContractorDashboard contractorDashboard(KintexPrincipal principal) { + Map co = mapper.findUserCompany(principal.userId()); + if (co == null) { + return new ContractorDashboard(null, List.of( + new Kpi("수주 부스", 0, "normal"), new Kpi("진행 옥션", 0, "normal"), + new Kpi("응찰 대기", 0, "normal"), new Kpi("시공 진행", 0, "success"), + new Kpi("마감 임박", 0, "error")), + List.of(), List.of(), List.of()); + } + String companyId = str(co.get("companyId")); + String companyName = str(co.get("companyName")); + + List> bidRows = mapper.listMyBids(companyId); + List myBids = new ArrayList<>(); + int liveCount = 0, imminent = 0; + for (Map b : bidRows) { + long myPrice = lngPrim(b.get("myPrice")); + int dday = intOr(b.get("dday"), 0); + boolean closed = bool(b.get("closed")); + int rank = mapper.lowerBidCount(str(b.get("auctionId")), myPrice) + 1; + myBids.add(new MyBid(str(b.get("auctionId")), str(b.get("auctionTitle")), myPrice, rank, dday)); + if (!closed) { + liveCount++; + if (dday >= 0 && dday <= 1) imminent++; + } + } + + List> awRows = mapper.listMyAwardedBooths(companyId); + List booths = new ArrayList<>(); + for (Map b : awRows) { + String title = str(b.get("auctionTitle")); + booths.add(new AwardedBooth(str(b.get("auctionId")), str(b.get("eventName")), + title, boothCode(title), str(b.get("category")), null, "시공")); + } + + List feed = new ArrayList<>(); + for (AwardedBooth b : booths) { + feed.add(new FeedItem("낙찰 확정: " + b.name(), b.event() + " · 발주 전환 대기", "ok")); + } + for (MyBid b : myBids) { + if (b.myRank() != null && b.myRank() == 1) { + feed.add(new FeedItem("현재 1위 응찰: " + b.auctionTitle(), "D-" + Math.max(b.dday(), 0), "info")); + } + } + + List kpis = List.of( + new Kpi("수주 부스", booths.size(), "normal"), + new Kpi("진행 옥션", liveCount, "normal"), + new Kpi("응찰", myBids.size(), "normal"), + new Kpi("시공 진행", booths.size(), "success"), + new Kpi("마감 임박", imminent, imminent > 0 ? "error" : "normal")); + + return new ContractorDashboard(companyName, kpis, booths, myBids, feed); + } + + // ── 권한/매핑 헬퍼 ───────────────────────────────────────────────────────── + private boolean isOrderer(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.EXHIBITOR || role == EventRole.HALL_MANAGER; + } + + private String contractorCompanyId(KintexPrincipal principal, String eventId) { + if (principal == null) return null; + Map co = mapper.findContractorCompany(principal.userId(), eventId); + return co == null ? null : str(co.get("companyId")); + } + + private boolean contractorRegistered(KintexPrincipal principal, String eventId) { + Map co = mapper.findContractorCompany(principal.userId(), eventId); + return co != null && bool(co.get("registered")); + } + + private AuctionSummary toSummary(Map r) { + return new AuctionSummary( + str(r.get("id")), str(r.get("eventId")), str(r.get("title")), str(r.get("category")), + mapType(str(r.get("auctionType"))), status(r), intOr(r.get("round"), 1), intOr(r.get("dday"), 0), + intOr(r.get("bidderCount"), 0), lng(r.get("lowestPrice")), str(r.get("awardedCompany")), + lng(r.get("finalPrice")), splitMaterials(r.get("materials")), str(r.get("accent"))); + } + + /** 표시 상태: 낙찰됨→정산중, 마감경과·미낙찰→마감, 그 외→진행중. */ + private String status(Map r) { + if (r.get("awardBidId") != null) return "정산중"; + if (bool(r.get("closed"))) return "마감"; + return "진행중"; + } + + private String mapType(String t) { + return "rfq".equalsIgnoreCase(t) ? "RFQ" : "역경매"; + } + + private MaterialPackage materialPackage(String pkgId, List mats) { + // 실제 URL 은 M2~M5 자료 스토리지 연동(후속). 현재는 존재 여부만 표기(없으면 null → 플레이스홀더). + return new MaterialPackage( + mats.contains("layout") ? placeholder(pkgId, "layout") : null, + mats.contains("design") ? placeholder(pkgId, "design") : null, + mats.contains("boq") ? placeholder(pkgId, "boq") : null, + mats.contains("aiimage") ? placeholder(pkgId, "aiimage") : null); + } + + /** 제목에서 부스 코드 추출(예: "A-102 독립부스 시공" → "A-102"). 없으면 앞 단어. */ + private String boothCode(String title) { + if (title == null || title.isBlank()) return ""; + java.util.regex.Matcher m = java.util.regex.Pattern.compile("([A-Za-z]-?\\d{1,4})").matcher(title); + if (m.find()) return m.group(1); + return title.split("\\s+")[0]; + } + + private String placeholder(String pkgId, String kind) { + return pkgId == null ? null : "/api/materials/" + pkgId + "/" + kind; + } + + private List splitMaterials(Object o) { + String s = str(o); + if (s == null || s.isBlank()) return List.of(); + return Arrays.stream(s.split(",")).map(String::trim).filter(x -> !x.isEmpty()).toList(); + } + + 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 boolean bool(Object o) { + if (o instanceof Boolean b) return b; + return o != null && Boolean.parseBoolean(String.valueOf(o)); + } + + private static Integer intOr(Object o, int def) { + if (o == null) return def; + if (o instanceof Number n) return n.intValue(); + try { + return Integer.valueOf(String.valueOf(o)); + } catch (NumberFormatException e) { + return def; + } + } + + private static Long lng(Object o) { + if (o == null) return null; + if (o instanceof Number n) return n.longValue(); + try { + return Long.valueOf(String.valueOf(o)); + } catch (NumberFormatException e) { + return null; + } + } + + private static long lngPrim(Object o) { + Long v = lng(o); + return v == null ? 0L : v; + } + + private static double dbl(Object o, double def) { + if (o == null) return def; + if (o instanceof Number n) return n.doubleValue(); + try { + return Double.parseDouble(String.valueOf(o)); + } catch (NumberFormatException e) { + return def; + } + } + + private static double round1(double v) { + return Math.round(v * 10.0) / 10.0; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/ContractorController.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/ContractorController.java new file mode 100644 index 0000000..9c2c743 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/ContractorController.java @@ -0,0 +1,34 @@ +package com.zioinfo.kintex.auction; + +import com.zioinfo.kintex.auction.dto.AuctionDtos.ContractorDashboard; +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 업체 포털(SCR-38) — 장치·공사업체 수주 부스·진행 옥션·내 응찰 요약. + * 자사 응찰 금액만 반환(타사 금액 비노출 — 봉인 규칙 준수). + */ +@RestController +@RequestMapping("/api/contractor") +public class ContractorController { + + private final AuctionService service; + private final EventAccessGuard guard; + + public ContractorController(AuctionService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** GET /api/contractor/dashboard — 내 업체 대시보드(소속 없으면 빈 지표). */ + @GetMapping("/dashboard") + public ApiResponse dashboard(@AuthenticationPrincipal KintexPrincipal principal) { + guard.require(principal); + return ApiResponse.ok(service.contractorDashboard(principal)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/QuotationCalc.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/QuotationCalc.java new file mode 100644 index 0000000..cc43581 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/QuotationCalc.java @@ -0,0 +1,64 @@ +package com.zioinfo.kintex.auction; + +import com.zioinfo.kintex.auction.dto.AuctionDtos.QuoteLine; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; + +import java.util.List; + +/** + * 견적서 금액 서버 재계산(순수 함수 — 단위 테스트 대상). + * + *

클라이언트가 보낸 금액을 신뢰하지 않고 서버가 라인아이템(수량×단가)으로 소계를 재계산한다. + *

    + *
  • subtotal(소계) = Σ(수량 × 단가) — 부가세 제외. 옥션 경쟁/순위 금액이며 {@code bid.total}에 저장된다 + * (기존 시드·순위 로직과 정합: 순위·최저가는 부가세 제외 소계 기준).
  • + *
  • vat(부가세) = round(subtotal × 10%).
  • + *
  • grandTotal(총액) = subtotal + vat — 화면 표기용 부가세 포함 총액.
  • + *
+ * 음수 수량·단가, 소계 0 이하는 검증 오류(400). + */ +public final class QuotationCalc { + + public static final double VAT_RATE = 0.1; + + private QuotationCalc() { + } + + /** subtotal=경쟁 금액(부가세 별도), vat=10%, grandTotal=subtotal+vat. */ + public record Totals(long subtotal, long vat, long grandTotal) { + } + + /** 라인아이템 합산 재계산. */ + public static Totals fromLines(List lines) { + if (lines == null || lines.isEmpty()) { + throw new ApiException(ErrorCode.VALIDATION); + } + long subtotal = 0; + for (QuoteLine l : lines) { + long qty = l.qty() == null ? 0 : l.qty(); + long unit = l.unitPrice() == null ? 0 : l.unitPrice(); + if (qty < 0 || unit < 0) { + throw new ApiException(ErrorCode.VALIDATION); + } + subtotal += qty * unit; + } + return finish(subtotal); + } + + /** 라인아이템 미전송 시 폴백 — total 은 경쟁 금액(부가세 별도)으로 간주. */ + public static Totals fromTotal(Long total) { + if (total == null) { + throw new ApiException(ErrorCode.VALIDATION); + } + return finish(total); + } + + private static Totals finish(long subtotal) { + if (subtotal <= 0) { + throw new ApiException(ErrorCode.VALIDATION); + } + long vat = Math.round(subtotal * VAT_RATE); + return new Totals(subtotal, vat, subtotal + vat); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java new file mode 100644 index 0000000..2198934 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/auction/dto/AuctionDtos.java @@ -0,0 +1,120 @@ +package com.zioinfo.kintex.auction.dto; + +import java.util.List; + +/** + * M15 옥션 응답/요청 DTO 모음(프론트 auctionApi.ts 계약 정본과 정합). + *

봉인 입찰 보안: 응찰자 뷰 {@link RankRow}는 마감 전 타사 금액을 {@code price=null,priceMasked=true}로 + * 마스킹하며 업체명을 노출하지 않는다. 전체 공개({@link AwardView})는 마감 후 발주자 전용. + */ +public final class AuctionDtos { + + private AuctionDtos() { + } + + /** 목록·카드(SCR-26). status/type 는 화면 표시 문자열(진행중|마감|정산중 / 역경매|RFQ). */ + public record AuctionSummary( + String id, String eventId, String title, String category, + String type, String status, int round, int dday, + int bidderCount, Long lowestPrice, String awardedCompany, Long finalPrice, + List materials, String accent) { + } + + /** 종합평가 가중치. */ + public record Weights(int price, int reputation, int delivery) { + } + + /** M2~M5 AI 자료 패키지 참조 URL(없으면 null → 플레이스홀더). */ + public record MaterialPackage(String layoutUrl, String designUrl, String boqUrl, String aiImageUrl) { + } + + /** + * 실시간 순위 행(응찰자 뷰 · 봉인). priceMasked=true 이면 price 는 null(타사 비공개). + * alias 는 위치 라벨(업체명 아님) — 업체 식별 불가. + */ + public record RankRow(int rank, String alias, boolean isMe, boolean isLowest, + boolean priceMasked, Long price) { + } + + /** 상세(SCR-27). ranking 은 봉인 마스킹 적용본. canBid/canViewAward 로 UI 게이팅. */ + public record AuctionDetail( + String id, String eventId, String title, String category, String type, String status, + int round, int dday, String deadline, int bidderCount, Long lowestPrice, + String awardCriteria, Weights weights, List materials, MaterialPackage materialPackage, + boolean sealed, Integer myRank, Long myPrice, + List ranking, boolean canBid, boolean canViewAward, + String awardedCompany, Long finalPrice) { + } + + /** 옥션 개설 요청(SCR-26). */ + public record CreateAuctionRequest( + String eventId, String title, String category, String type, + String awardCriteria, Weights weights, Integer round, String deadline, + String materialPackageId, List materials, List invitedCompanyIds) { + } + + /** 견적서 라인아이템(SCR-28) — 공종·자재·수량·단가. 금액=수량×단가는 서버 계산. */ + public record QuoteLine(String trade, String material, Long qty, Long unitPrice) { + } + + /** + * 응찰(견적서 제출) 요청. lines(라인아이템)가 있으면 서버가 소계/부가세/총액을 재계산한다. + * lines 미전송 시 total(경쟁 금액, 부가세 별도)만으로 폴백. 둘 다 없으면 검증 오류. + */ + public record BidRequest(Long total, Integer leadDays, String validUntil, String terms, + List lines) { + } + + /** + * 응찰 결과(자사 확인용). total=경쟁 순위 금액(부가세 별도, 최저가 비교 기준), + * grandTotal=부가세 포함 총액(소계+부가세). + */ + public record BidResult(String id, int round, long subtotal, long vat, long total, long grandTotal, + int version, Integer myRank, Long lowestPrice) { + } + + /** 낙찰 요청 — reason 필수. */ + public record AwardRequest(String bidId, String reason) { + } + + /** 낙찰 결과. */ + public record AwardResult(String id, String bidId, String companyName, String awardedAt) { + } + + /** 발주자 비교 컬럼(SCR-29 · 마감 후 봉인 해제). alias=업체명. */ + public record Quote( + String bidId, String companyId, String alias, String subtitle, boolean recommended, + long totalPrice, double priceDeltaPct, boolean isLowestPrice, + int leadDays, boolean isShortestLead, double reputation, double score) { + } + + /** AI 리스크 바(발주자 뷰). */ + public record RiskBar(String label, double pct, String tone) { + } + + /** 낙찰 비교·선정 화면 페이로드(마감 후 발주자 전용). */ + public record AwardView( + String auctionId, String auctionTitle, int bidderCount, + String awardCriteria, Weights weights, List quotes, + List riskBars, AwardResult awarded) { + } + + // ── 업체 포털 대시보드(SCR-38) ── + public record Kpi(String label, int value, String tone) { + } + + public record AwardedBooth(String auctionId, String event, String name, String booth, + String client, Integer dday, String stage) { + } + + public record MyBid(String auctionId, String auctionTitle, long myPrice, Integer myRank, int dday) { + } + + public record FeedItem(String text, String meta, String tone) { + } + + public record ContractorDashboard( + String companyName, List kpis, List awardedBooths, + List myAuctionBids, List feed) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsContentController.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsContentController.java new file mode 100644 index 0000000..d35646f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsContentController.java @@ -0,0 +1,82 @@ +package com.zioinfo.kintex.cms; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.cms.dto.CmsContentCreateRequest; +import com.zioinfo.kintex.cms.dto.CmsContentDto; +import com.zioinfo.kintex.cms.dto.CmsTranslationDto; +import com.zioinfo.kintex.cms.dto.CmsTranslationSaveRequest; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.PageResponse; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * M17 CMS 콘텐츠 API (SCR-35·37). 인증 필수. + *

    + *
  • GET/POST /api/cms/contents — 목록/신규(초안)
  • + *
  • PATCH /api/cms/contents/{id}/status?value= — 게시 전이(전진만·역전이 400, 승인/게시는 매니저↑)
  • + *
  • GET/PUT /api/cms/contents/{id}/translations — 언어별 번역 upsert(ko/en/zh/ja)
  • + *
+ */ +@RestController +@RequestMapping("/api/cms/contents") +public class CmsContentController { + + private final CmsService service; + private final EventAccessGuard guard; + + public CmsContentController(CmsService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping + public ApiResponse> list( + @AuthenticationPrincipal KintexPrincipal principal, + @RequestParam(required = false) String eventId, + @RequestParam(required = false) String status, + @RequestParam(required = false) String type, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + guard.require(principal); + return ApiResponse.ok(service.list(eventId, status, type, keyword, page, size)); + } + + @PostMapping + public ApiResponse create(@AuthenticationPrincipal KintexPrincipal principal, + @Valid @RequestBody CmsContentCreateRequest req) { + guard.require(principal); + return ApiResponse.ok(service.create(principal, req)); + } + + /** 게시 상태 전이. value=draft|review|approved|published(전진만). 역전이/무효 → 400. */ + @PatchMapping("/{id}/status") + public ApiResponse transition(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @RequestParam String value) { + guard.require(principal); + return ApiResponse.ok(service.transition(principal, id, value)); + } + + @GetMapping("/{id}/translations") + public ApiResponse> translations( + @AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id) { + guard.require(principal); + return ApiResponse.ok(service.translations(id)); + } + + @PutMapping("/{id}/translations") + public ApiResponse> saveTranslation( + @AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String id, + @Valid @RequestBody CmsTranslationSaveRequest req) { + guard.require(principal); + return ApiResponse.ok(service.saveTranslation(id, req)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java new file mode 100644 index 0000000..7caebcd --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsMapper.java @@ -0,0 +1,100 @@ +package com.zioinfo.kintex.cms; + +import org.apache.ibatis.annotations.*; + +import java.util.List; +import java.util.Map; + +/** + * M17 CMS 매퍼 — 콘텐츠 + 번역. ★Map 반환 @Select 별칭은 반드시 쌍따옴표(예: {@code AS "eventId"}). + * 게시 상태(draft→review→approved→published)·예약 게시(scheduled_at)·번역 상태(none/ai/reviewed). + */ +@Mapper +public interface CmsMapper { + + // ── 콘텐츠 ─────────────────────────────────────────────────────────────── + @Select(""" + + """) + List> findContents(Map q); + + @Select(""" + + """) + long countContents(Map q); + + @Select(""" + SELECT id, event_id AS "eventId", content_type AS "contentType", title, body, status, lang, + to_char(scheduled_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "scheduledAt", + signage, mailing, author_name AS "authorName", + to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt", + to_char(created_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "createdAt", + to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt" + FROM cms_content WHERE id = #{id} + """) + Map findContentById(@Param("id") String id); + + @Insert(""" + INSERT INTO cms_content (id, event_id, content_type, title, body, status, lang, author_id, author_name) + VALUES (#{id}, #{eventId}, COALESCE(#{contentType},'PAGE'), #{title}, #{body}, + 'draft', COALESCE(#{lang},'ko'), #{authorId}, #{authorName}) + """) + int insertContent(Map p); + + /** 상태 전이(전이 검증은 서비스). published 전이 시 published_at=now(). */ + @Update(""" + UPDATE cms_content + SET status = #{status}, + published_at = CASE WHEN #{status} = 'published' THEN now() ELSE published_at END, + updated_at = now() + WHERE id = #{id} + """) + int updateStatus(@Param("id") String id, @Param("status") String status); + + // ── 번역 ───────────────────────────────────────────────────────────────── + @Select(""" + SELECT content_id AS "contentId", lang, title, body, trans_status AS "transStatus", + to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt" + FROM cms_translation + WHERE content_id = #{contentId} + ORDER BY lang + """) + List> findTranslations(@Param("contentId") String contentId); + + /** 언어별 upsert(멱등) — (content_id, lang) 유니크 충돌 시 갱신. */ + @Insert(""" + INSERT INTO cms_translation (id, content_id, lang, title, body, trans_status, updated_at) + VALUES (#{id}, #{contentId}, #{lang}, #{title}, #{body}, + COALESCE(#{transStatus},'none'), now()) + ON CONFLICT (content_id, lang) DO UPDATE + SET title = EXCLUDED.title, + body = EXCLUDED.body, + trans_status = EXCLUDED.trans_status, + updated_at = now() + """) + int upsertTranslation(Map p); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsService.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsService.java new file mode 100644 index 0000000..bb9c493 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/CmsService.java @@ -0,0 +1,160 @@ +package com.zioinfo.kintex.cms; + +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.cms.dto.CmsContentCreateRequest; +import com.zioinfo.kintex.cms.dto.CmsContentDto; +import com.zioinfo.kintex.cms.dto.CmsTranslationDto; +import com.zioinfo.kintex.cms.dto.CmsTranslationSaveRequest; +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.system.SystemAccessGuard; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +/** + * M17 CMS 서비스 — 콘텐츠 게시 워크플로 + 다국어 번역. + *

상태 전이: draft(0)→review(1)→approved(2)→published(3) 전진만 허용. 역전이/동일 전이 → 400(VALIDATION). + * approved·published 전이는 관리자/주최자(매니저 이상) 권한 필수(kintex-admin-dev RBAC 정합). + */ +@Service +public class CmsService { + + private static final List FLOW = List.of("draft", "review", "approved", "published"); + private static final Set TRANS_STATUS = Set.of("none", "ai", "reviewed"); + private static final Set LANGS = Set.of("ko", "en", "zh", "ja"); + + private final CmsMapper mapper; + private final SystemAccessGuard scope; + + public CmsService(CmsMapper mapper, SystemAccessGuard scope) { + this.mapper = mapper; + this.scope = scope; + } + + public PageResponse list(String eventId, String status, String type, String keyword, + int page, int size) { + int p = Math.max(page, 0); + int s = size <= 0 ? 50 : Math.min(size, 200); + Map q = new HashMap<>(); + q.put("eventId", blankToNull(eventId)); + q.put("status", blankToNull(status)); + q.put("type", blankToNull(type)); + q.put("keyword", blankToNull(keyword)); + q.put("size", s); + q.put("offset", p * s); + List> rows = mapper.findContents(q); + long total = mapper.countContents(q); + return PageResponse.of(rows.stream().map(CmsService::toContentDto).toList(), p, s, total); + } + + public CmsContentDto get(String id) { + Map r = mapper.findContentById(id); + if (r == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + return toContentDto(r); + } + + @Transactional + public CmsContentDto create(KintexPrincipal principal, CmsContentCreateRequest req) { + String id = "cms-" + UUID.randomUUID().toString().substring(0, 12); + Map pm = new HashMap<>(); + pm.put("id", id); + pm.put("eventId", blankToNull(req.eventId())); + pm.put("contentType", blankToNull(req.contentType())); + pm.put("title", req.title()); + pm.put("body", req.body()); + pm.put("lang", blankToNull(req.lang())); + pm.put("authorId", principal.userId()); + pm.put("authorName", principal.displayName()); + mapper.insertContent(pm); + return get(id); + } + + /** 상태 전이 — value 는 목표 상태. 전진만 허용, 역전이/무효 값 → 400. */ + @Transactional + public CmsContentDto transition(KintexPrincipal principal, String id, String value) { + Map r = mapper.findContentById(id); + if (r == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + String from = String.valueOf(r.get("status")); + String to = value == null ? "" : value.trim(); + int fromIdx = FLOW.indexOf(from); + int toIdx = FLOW.indexOf(to); + if (toIdx < 0) { + throw new ApiException(ErrorCode.VALIDATION, "알 수 없는 게시 상태입니다: " + to); + } + if (toIdx <= fromIdx) { + throw new ApiException(ErrorCode.VALIDATION, + "게시 워크플로는 전진만 가능합니다(" + from + " → " + to + " 불가)."); + } + // 승인·게시는 관리자/주최자(매니저 이상) 권한 + if ("approved".equals(to) || "published".equals(to)) { + scope.requireManager(principal); + } + mapper.updateStatus(id, to); + return get(id); + } + + // ── 번역 ───────────────────────────────────────────────────────────────── + public List translations(String contentId) { + if (mapper.findContentById(contentId) == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + return mapper.findTranslations(contentId).stream().map(CmsService::toTranslationDto).toList(); + } + + @Transactional + public List saveTranslation(String contentId, CmsTranslationSaveRequest req) { + if (mapper.findContentById(contentId) == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + String lang = req.lang() == null ? "" : req.lang().trim(); + if (!LANGS.contains(lang)) { + throw new ApiException(ErrorCode.VALIDATION, "지원하지 않는 언어입니다: " + lang); + } + String st = req.transStatus() == null ? "none" : req.transStatus().trim(); + if (!TRANS_STATUS.contains(st)) { + throw new ApiException(ErrorCode.VALIDATION, "알 수 없는 번역 상태입니다: " + st); + } + Map pm = new HashMap<>(); + pm.put("id", "tr-" + UUID.randomUUID().toString().substring(0, 12)); + pm.put("contentId", contentId); + pm.put("lang", lang); + pm.put("title", req.title()); + pm.put("body", req.body()); + pm.put("transStatus", st); + mapper.upsertTranslation(pm); + return translations(contentId); + } + + // ── 매핑 ───────────────────────────────────────────────────────────────── + private static CmsContentDto toContentDto(Map r) { + return new CmsContentDto( + str(r.get("id")), str(r.get("eventId")), str(r.get("contentType")), + str(r.get("title")), str(r.get("body")), str(r.get("status")), str(r.get("lang")), + str(r.get("scheduledAt")), bool(r.get("signage")), bool(r.get("mailing")), + str(r.get("authorName")), str(r.get("publishedAt")), + str(r.get("createdAt")), str(r.get("updatedAt"))); + } + + private static CmsTranslationDto toTranslationDto(Map r) { + return new CmsTranslationDto( + str(r.get("contentId")), str(r.get("lang")), str(r.get("title")), + str(r.get("body")), str(r.get("transStatus")), str(r.get("updatedAt"))); + } + + private static String blankToNull(String s) { + return s == null || s.isBlank() ? null : s; + } + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + private static boolean bool(Object o) { + return o instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(o)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/MicrositeController.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/MicrositeController.java new file mode 100644 index 0000000..f7a025a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/MicrositeController.java @@ -0,0 +1,43 @@ +package com.zioinfo.kintex.cms; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.cms.dto.MicrositeDto; +import com.zioinfo.kintex.cms.dto.MicrositeSaveRequest; +import com.zioinfo.kintex.common.ApiResponse; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * M17 참가업체 마이크로사이트 편집 API (SCR-36). 인증 필수. + * GET/PUT /api/exhibitors/{exhibitorId}/microsite — 섹션 JSON·테마·SEO·상태 저장/조회. + * 공개 읽기는 {@link PublicMicrositeController}(/api/public/microsites, 무인증). + */ +@RestController +@RequestMapping("/api/exhibitors/{exhibitorId}/microsite") +public class MicrositeController { + + private final MicrositeService service; + private final EventAccessGuard guard; + + public MicrositeController(MicrositeService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + @GetMapping + public ApiResponse get(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String exhibitorId) { + guard.require(principal); + return ApiResponse.ok(service.get(exhibitorId)); + } + + @PutMapping + public ApiResponse save(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String exhibitorId, + @Valid @RequestBody MicrositeSaveRequest req) { + guard.require(principal); + return ApiResponse.ok(service.save(exhibitorId, req)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/MicrositeMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/MicrositeMapper.java new file mode 100644 index 0000000..fae0c89 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/MicrositeMapper.java @@ -0,0 +1,50 @@ +package com.zioinfo.kintex.cms; + +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.Map; + +/** + * M17 마이크로사이트 매퍼 — 참가업체별 공개 소개 페이지. ★Map 별칭 쌍따옴표 필수. + * sections 는 jsonb → sections::text 로 원문 문자열 반환(서비스에서 JsonNode 파싱). + */ +@Mapper +public interface MicrositeMapper { + + @Select(""" + SELECT exhibitor_id AS "exhibitorId", event_id AS "eventId", slug, + exhibitor_name AS "exhibitorName", theme, sections::text AS "sections", + seo_title AS "seoTitle", seo_meta AS "seoMeta", langs, status, + to_char(published_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "publishedAt", + to_char(updated_at AT TIME ZONE 'UTC','YYYY-MM-DD"T"HH24:MI:SS"Z"') AS "updatedAt" + FROM microsite WHERE exhibitor_id = #{exhibitorId} + """) + Map findByExhibitor(@Param("exhibitorId") String exhibitorId); + + /** upsert(멱등) — sections 는 jsonb 캐스팅. published 전이 시 published_at=now(). */ + @Insert(""" + INSERT INTO microsite (exhibitor_id, event_id, slug, exhibitor_name, theme, sections, + seo_title, seo_meta, langs, status, published_at, updated_at) + VALUES (#{exhibitorId}, #{eventId}, #{slug}, #{exhibitorName}, COALESCE(#{theme},'blue'), + CAST(COALESCE(#{sectionsJson},'[]') AS jsonb), #{seoTitle}, #{seoMeta}, + COALESCE(#{langs},'ko'), COALESCE(#{status},'draft'), + CASE WHEN #{status} = 'published' THEN now() ELSE NULL END, now()) + ON CONFLICT (exhibitor_id) DO UPDATE + SET event_id = EXCLUDED.event_id, + slug = EXCLUDED.slug, + exhibitor_name = EXCLUDED.exhibitor_name, + theme = EXCLUDED.theme, + sections = EXCLUDED.sections, + seo_title = EXCLUDED.seo_title, + seo_meta = EXCLUDED.seo_meta, + langs = EXCLUDED.langs, + status = EXCLUDED.status, + published_at = CASE WHEN EXCLUDED.status = 'published' THEN now() + ELSE microsite.published_at END, + updated_at = now() + """) + int upsert(Map p); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/MicrositeService.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/MicrositeService.java new file mode 100644 index 0000000..cd87c4f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/MicrositeService.java @@ -0,0 +1,107 @@ +package com.zioinfo.kintex.cms; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.zioinfo.kintex.cms.dto.MicrositeDto; +import com.zioinfo.kintex.cms.dto.MicrositeSaveRequest; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.HashMap; +import java.util.Map; + +/** + * M17 마이크로사이트 서비스 — 참가업체별 공개 소개 페이지(섹션 JSON·테마·SEO). + * sections 는 jsonb 문자열 ↔ JsonNode 변환. 공개 읽기({@code publicView})는 draft 여부 무관 조회(공개 P5 팀이 상태 필터). + */ +@Service +public class MicrositeService { + + private final MicrositeMapper mapper; + private final ObjectMapper objectMapper; + + public MicrositeService(MicrositeMapper mapper, ObjectMapper objectMapper) { + this.mapper = mapper; + this.objectMapper = objectMapper; + } + + /** 조회 — 미존재 시 빈 초안(exhibitorId만) 반환(에디터가 새로 저장 가능). */ + public MicrositeDto get(String exhibitorId) { + Map r = mapper.findByExhibitor(exhibitorId); + if (r == null) { + return new MicrositeDto(exhibitorId, null, null, null, "blue", + objectMapper.createArrayNode(), null, null, "ko", "draft", null, null); + } + return toDto(r); + } + + /** 공개 읽기 — 미존재 시 404(무인증 경로). */ + public MicrositeDto publicView(String exhibitorId) { + Map r = mapper.findByExhibitor(exhibitorId); + if (r == null) { + throw new ApiException(ErrorCode.NOT_FOUND); + } + return toDto(r); + } + + @Transactional + public MicrositeDto save(String exhibitorId, MicrositeSaveRequest req) { + String sectionsJson = "[]"; + if (req.sections() != null && !req.sections().isNull()) { + try { + sectionsJson = objectMapper.writeValueAsString(req.sections()); + } catch (Exception e) { + throw new ApiException(ErrorCode.VALIDATION, "섹션 데이터 형식이 올바르지 않습니다."); + } + } + String status = req.status() == null ? "draft" : req.status().trim(); + if (!"draft".equals(status) && !"published".equals(status)) { + throw new ApiException(ErrorCode.VALIDATION, "알 수 없는 사이트 상태입니다: " + status); + } + Map p = new HashMap<>(); + p.put("exhibitorId", exhibitorId); + p.put("eventId", blankToNull(req.eventId())); + p.put("slug", blankToNull(req.slug())); + p.put("exhibitorName", req.exhibitorName()); + p.put("theme", blankToNull(req.theme())); + p.put("sectionsJson", sectionsJson); + p.put("seoTitle", req.seoTitle()); + p.put("seoMeta", req.seoMeta()); + p.put("langs", blankToNull(req.langs())); + p.put("status", status); + mapper.upsert(p); + return get(exhibitorId); + } + + private MicrositeDto toDto(Map r) { + JsonNode sections = parseSections(str(r.get("sections"))); + return new MicrositeDto( + str(r.get("exhibitorId")), str(r.get("eventId")), str(r.get("slug")), + str(r.get("exhibitorName")), str(r.get("theme")), sections, + str(r.get("seoTitle")), str(r.get("seoMeta")), str(r.get("langs")), + str(r.get("status")), str(r.get("publishedAt")), str(r.get("updatedAt"))); + } + + private JsonNode parseSections(String json) { + if (json == null || json.isBlank()) { + return objectMapper.createArrayNode(); + } + try { + JsonNode node = objectMapper.readTree(json); + return node == null ? objectMapper.createArrayNode() : node; + } catch (Exception e) { + ArrayNode empty = objectMapper.createArrayNode(); + return empty; + } + } + + private static String blankToNull(String s) { + return s == null || s.isBlank() ? null : s; + } + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/PublicMicrositeController.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/PublicMicrositeController.java new file mode 100644 index 0000000..5774519 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/PublicMicrositeController.java @@ -0,0 +1,29 @@ +package com.zioinfo.kintex.cms; + +import com.zioinfo.kintex.cms.dto.MicrositeDto; +import com.zioinfo.kintex.common.ApiResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 공개 마이크로사이트 조회 (일반 대중·관람객·SEO). 무인증 — {@code /api/public/**} permitAll(SecurityConfig). + * GET /api/public/microsites/{exhibitorId} → MicrositeDto(섹션·테마·SEO). 미존재 시 404. + *

공개 P5 렌더 팀(후속)이 slug 라우팅/OG/사이트맵을 이 계약 위에 배선한다(impl_cms.md §공개 계약). + */ +@RestController +@RequestMapping("/api/public/microsites") +public class PublicMicrositeController { + + private final MicrositeService service; + + public PublicMicrositeController(MicrositeService service) { + this.service = service; + } + + @GetMapping("/{exhibitorId}") + public ApiResponse view(@PathVariable String exhibitorId) { + return ApiResponse.ok(service.publicView(exhibitorId)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentCreateRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentCreateRequest.java new file mode 100644 index 0000000..f63e5fb --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentCreateRequest.java @@ -0,0 +1,12 @@ +package com.zioinfo.kintex.cms.dto; + +import jakarta.validation.constraints.NotBlank; + +/** CMS 콘텐츠 신규(초안) 생성 요청. 생성 시 status=draft 고정(서버 권위). */ +public record CmsContentCreateRequest( + String eventId, + String contentType, + @NotBlank String title, + String body, + String lang) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentDto.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentDto.java new file mode 100644 index 0000000..0e637c5 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsContentDto.java @@ -0,0 +1,19 @@ +package com.zioinfo.kintex.cms.dto; + +/** CMS 콘텐츠 항목 — 게시 워크플로(SCR-35). status: draft|review|approved|published. */ +public record CmsContentDto( + String id, + String eventId, + String contentType, + String title, + String body, + String status, + String lang, + String scheduledAt, + boolean signage, + boolean mailing, + String authorName, + String publishedAt, + String createdAt, + String updatedAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsTranslationDto.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsTranslationDto.java new file mode 100644 index 0000000..273a01a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsTranslationDto.java @@ -0,0 +1,11 @@ +package com.zioinfo.kintex.cms.dto; + +/** 콘텐츠×언어 번역본(SCR-37). transStatus: none|ai|reviewed. */ +public record CmsTranslationDto( + String contentId, + String lang, + String title, + String body, + String transStatus, + String updatedAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsTranslationSaveRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsTranslationSaveRequest.java new file mode 100644 index 0000000..13a7805 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/CmsTranslationSaveRequest.java @@ -0,0 +1,11 @@ +package com.zioinfo.kintex.cms.dto; + +import jakarta.validation.constraints.NotBlank; + +/** 번역 upsert 요청(언어별). transStatus 미지정 시 none. */ +public record CmsTranslationSaveRequest( + @NotBlank String lang, + String title, + String body, + String transStatus) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/MicrositeDto.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/MicrositeDto.java new file mode 100644 index 0000000..55421d5 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/MicrositeDto.java @@ -0,0 +1,19 @@ +package com.zioinfo.kintex.cms.dto; + +import com.fasterxml.jackson.databind.JsonNode; + +/** 참가업체 마이크로사이트(SCR-36 / 공개 뷰 SCR-P5). sections 는 JSON 배열 원문. */ +public record MicrositeDto( + String exhibitorId, + String eventId, + String slug, + String exhibitorName, + String theme, + JsonNode sections, + String seoTitle, + String seoMeta, + String langs, + String status, + String publishedAt, + String updatedAt) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/MicrositeSaveRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/MicrositeSaveRequest.java new file mode 100644 index 0000000..1447aa9 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/cms/dto/MicrositeSaveRequest.java @@ -0,0 +1,16 @@ +package com.zioinfo.kintex.cms.dto; + +import com.fasterxml.jackson.databind.JsonNode; + +/** 마이크로사이트 저장 요청 — 섹션/테마/SEO/상태. sections 는 JSON 배열. */ +public record MicrositeSaveRequest( + String eventId, + String slug, + String exhibitorName, + String theme, + JsonNode sections, + String seoTitle, + String seoMeta, + String langs, + String status) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentController.java b/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentController.java new file mode 100644 index 0000000..50b053e --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentController.java @@ -0,0 +1,70 @@ +package com.zioinfo.kintex.document; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.document.dto.DocumentReviewDto; +import com.zioinfo.kintex.document.dto.DocumentTransitionRequest; +import com.zioinfo.kintex.document.dto.MilestoneDto; +import com.zioinfo.kintex.document.dto.RequiredDocumentDto; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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.RestController; + +import java.util.List; + +/** + * M6 서류·마일스톤 API (SCR-22/23). 행사 RBAC 가드. + * HWP/PDF 렌더(파일 큐)는 이번 스코프 제외 — 엔드포인트 미노출. + */ +@RestController +@RequestMapping("/api/events/{eventId}") +public class DocumentController { + + private final DocumentService service; + private final EventAccessGuard guard; + + public DocumentController(DocumentService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** GET /milestones — 전시 마일스톤 진행 노드. */ + @GetMapping("/milestones") + public ApiResponse> milestones(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.getMilestones(eventId)); + } + + /** GET /documents — 신고서류 체크리스트. */ + @GetMapping("/documents") + public ApiResponse> documents(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.getDocuments(eventId)); + } + + /** GET /documents/review — AI 서류 검수 + 전체 공정률. */ + @GetMapping("/documents/review") + public ApiResponse review(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.getReview(eventId)); + } + + /** POST /documents/{docType} — 상태 전이(임시저장 save / 제출 submit). */ + @PostMapping("/documents/{docType}") + public ApiResponse transition(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @PathVariable String docType, + @RequestBody(required = false) DocumentTransitionRequest request) { + guard.requireEventAccess(principal, eventId); + String action = request == null ? null : request.action(); + return ApiResponse.ok(service.transition(eventId, docType, action)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentMapper.java new file mode 100644 index 0000000..e074a91 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentMapper.java @@ -0,0 +1,73 @@ +package com.zioinfo.kintex.document; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; + +import java.util.List; +import java.util.Map; + +/** + * M6 서류·마일스톤 매퍼 — event_milestone·required_document·document_review_issue read + 상태 전이 update. + * camelCase 별칭은 반드시 쌍따옴표(PG 소문자 폴딩 함정) — WORK_STATUS §7. + */ +@Mapper +public interface DocumentMapper { + + /** 행사 마일스톤(정렬 순). */ + @Select(""" + SELECT label, + state, + to_char(due_date, 'YYYY-MM-DD') AS "dueDate", + sort_order AS "sortOrder" + FROM event_milestone + WHERE event_id = #{eventId} + ORDER BY sort_order, due_date NULLS LAST + """) + List> findMilestones(@Param("eventId") String eventId); + + /** 신고서류 체크리스트(정렬 순). */ + @Select(""" + SELECT doc_type AS "docType", + name, + status, + to_char(due_date, 'YYYY-MM-DD') AS "dueDate", + sort_order AS "sortOrder" + FROM required_document + WHERE event_id = #{eventId} + ORDER BY sort_order, doc_type + """) + List> findDocuments(@Param("eventId") String eventId); + + /** 단일 서류(상태 전이 대상 존재 확인). 없으면 null. */ + @Select(""" + SELECT doc_type AS "docType", + name, + status, + to_char(due_date, 'YYYY-MM-DD') AS "dueDate", + sort_order AS "sortOrder" + FROM required_document + WHERE event_id = #{eventId} AND doc_type = #{docType} + """) + Map findDocument(@Param("eventId") String eventId, @Param("docType") String docType); + + /** AI 검수 이슈(정렬 순). */ + @Select(""" + SELECT tone, title, description, sort_order AS "sortOrder" + FROM document_review_issue + WHERE event_id = #{eventId} + ORDER BY sort_order, id + """) + List> findReviewIssues(@Param("eventId") String eventId); + + /** 서류 상태 전이(임시저장/제출). 영향 행수 반환. */ + @Update(""" + UPDATE required_document + SET status = #{status}, updated_at = now() + WHERE event_id = #{eventId} AND doc_type = #{docType} + """) + int updateStatus(@Param("eventId") String eventId, + @Param("docType") String docType, + @Param("status") String status); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentService.java b/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentService.java new file mode 100644 index 0000000..18c9724 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/document/DocumentService.java @@ -0,0 +1,141 @@ +package com.zioinfo.kintex.document; + +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.document.dto.DocumentReviewDto; +import com.zioinfo.kintex.document.dto.MilestoneDto; +import com.zioinfo.kintex.document.dto.RequiredDocumentDto; +import com.zioinfo.kintex.document.dto.ReviewIssueDto; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * M6 서류·마일스톤 서비스 — 실 테이블 집계로 마일스톤·서류·검수·전이를 조립. + * dday·sub·progressPct 는 저장값에서 파생(원천 컬럼 최소화). 상태 전이는 화이트리스트 검증. + */ +@Service +public class DocumentService { + + private final DocumentMapper mapper; + + public DocumentService(DocumentMapper mapper) { + this.mapper = mapper; + } + + public List getMilestones(String eventId) { + List out = new ArrayList<>(); + for (Map r : mapper.findMilestones(eventId)) { + String state = str(r.get("state")); + out.add(new MilestoneDto(str(r.get("label")), subLabel(state), state, str(r.get("dueDate")))); + } + return out; + } + + public List getDocuments(String eventId) { + LocalDate today = LocalDate.now(); + List out = new ArrayList<>(); + for (Map r : mapper.findDocuments(eventId)) { + out.add(toDoc(r, today)); + } + return out; + } + + public DocumentReviewDto getReview(String eventId) { + List issues = new ArrayList<>(); + for (Map r : mapper.findReviewIssues(eventId)) { + issues.add(new ReviewIssueDto(str(r.get("tone")), str(r.get("title")), str(r.get("description")))); + } + return new DocumentReviewDto(issues, progressPct(mapper.findDocuments(eventId))); + } + + /** + * 서류 상태 전이(SCR-23 임시저장/제출). action=save→draft, submit→submitted. + * 대상 서류 미존재 → 404. 잘못된 action → 400. + */ + public RequiredDocumentDto transition(String eventId, String docType, String action) { + String target = switch (action == null ? "" : action.trim().toLowerCase()) { + case "save" -> "draft"; + case "submit" -> "submitted"; + default -> null; + }; + if (target == null) { + throw new ApiException(ErrorCode.VALIDATION, "action 은 save 또는 submit 이어야 합니다."); + } + if (mapper.findDocument(eventId, docType) == null) { + throw new ApiException(ErrorCode.NOT_FOUND, "해당 신고서류를 찾을 수 없습니다."); + } + mapper.updateStatus(eventId, docType, target); + Map updated = mapper.findDocument(eventId, docType); + return toDoc(updated, LocalDate.now()); + } + + // ── 파생 ───────────────────────────────────────────────────────── + private RequiredDocumentDto toDoc(Map r, LocalDate today) { + String status = str(r.get("status")); + String dueDate = str(r.get("dueDate")); + return new RequiredDocumentDto( + str(r.get("docType")), str(r.get("name")), status, dueDate, + dday(dueDate, status, today), intVal(r.get("sortOrder"))); + } + + /** 준비중(pending) 제외한 서류 진척률(%). */ + private static int progressPct(List> docs) { + if (docs == null || docs.isEmpty()) { + return 0; + } + long done = docs.stream().filter(d -> !"pending".equals(String.valueOf(d.get("status")))).count(); + return (int) Math.round(done * 100.0 / docs.size()); + } + + /** 진행 중(pending 아님) 서류에 한해 due_date 까지 남은 일수(과거면 null). */ + private static Integer dday(String dueDate, String status, LocalDate today) { + if (dueDate == null || "pending".equals(status) || "approved".equals(status)) { + return null; + } + LocalDate due = parseDate(dueDate); + if (due == null) { + return null; + } + long days = ChronoUnit.DAYS.between(today, due); + return days < 0 ? null : (int) days; + } + + private static String subLabel(String state) { + return switch (state == null ? "" : state) { + case "done" -> "완료"; + case "active" -> "진행중"; + default -> "대기"; + }; + } + + private static LocalDate parseDate(String s) { + if (s == null || s.isBlank()) { + return null; + } + try { + return LocalDate.parse(s); + } catch (DateTimeParseException e) { + return null; + } + } + + private static int intVal(Object o) { + if (o instanceof Number n) return n.intValue(); + if (o == null) return 0; + try { + return Integer.parseInt(String.valueOf(o)); + } catch (NumberFormatException e) { + return 0; + } + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/document/dto/DocumentReviewDto.java b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/DocumentReviewDto.java new file mode 100644 index 0000000..9878968 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/DocumentReviewDto.java @@ -0,0 +1,13 @@ +package com.zioinfo.kintex.document.dto; + +import java.util.List; + +/** + * AI 서류 검수 결과 + 전체 공정률 (SCR-22 우측 패널). + * progressPct 는 준비중(pending) 제외한 서류 진척률(round). issues 는 규정 룰셋 기반 파생 이슈. + */ +public record DocumentReviewDto( + List issues, + int progressPct +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/document/dto/DocumentTransitionRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/DocumentTransitionRequest.java new file mode 100644 index 0000000..097a2b1 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/DocumentTransitionRequest.java @@ -0,0 +1,10 @@ +package com.zioinfo.kintex.document.dto; + +/** + * 서류 상태 전이 요청 (SCR-23 임시저장/제출). + * action: save→draft, submit→submitted. 그 외 값은 400. + */ +public record DocumentTransitionRequest( + String action +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/document/dto/MilestoneDto.java b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/MilestoneDto.java new file mode 100644 index 0000000..ef44054 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/MilestoneDto.java @@ -0,0 +1,13 @@ +package com.zioinfo.kintex.document.dto; + +/** + * 전시 마일스톤 노드 (SCR-22 상단 진행 바). + * state: done|active|todo. sub 는 state 파생 한글 라벨(완료/진행중/대기). + */ +public record MilestoneDto( + String label, + String sub, + String state, + String dueDate +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/document/dto/RequiredDocumentDto.java b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/RequiredDocumentDto.java new file mode 100644 index 0000000..d93f97f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/RequiredDocumentDto.java @@ -0,0 +1,15 @@ +package com.zioinfo.kintex.document.dto; + +/** + * 신고서류 체크리스트 항목 (SCR-22 좌측 리스트). + * status: pending|draft|submitted|approved|rejected. dday 는 due_date-오늘(미래일 때만, 없으면 null). + */ +public record RequiredDocumentDto( + String docType, + String name, + String status, + String dueDate, + Integer dday, + int sortOrder +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/document/dto/ReviewIssueDto.java b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/ReviewIssueDto.java new file mode 100644 index 0000000..a30469e --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/document/dto/ReviewIssueDto.java @@ -0,0 +1,9 @@ +package com.zioinfo.kintex.document.dto; + +/** AI 서류 검수 이슈 (SCR-22 우측 카드). tone: warn|info. */ +public record ReviewIssueDto( + String tone, + String title, + String description +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/logistics/LogisticsController.java b/src/backend/src/main/java/com/zioinfo/kintex/logistics/LogisticsController.java new file mode 100644 index 0000000..f62cd9a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/logistics/LogisticsController.java @@ -0,0 +1,61 @@ +package com.zioinfo.kintex.logistics; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.logistics.dto.DockBoardDto; +import com.zioinfo.kintex.logistics.dto.DockForecastDto; +import com.zioinfo.kintex.logistics.dto.DockGridReservationDto; +import com.zioinfo.kintex.logistics.dto.DockReservationRequest; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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; + +/** + * M8 반입/반출 도크 슬롯 예약 API (SCR-25). 행사 RBAC 가드. 슬롯 중복은 409 CONFLICT. + */ +@RestController +@RequestMapping("/api/events/{eventId}") +public class LogisticsController { + + private final LogisticsService service; + private final EventAccessGuard guard; + + public LogisticsController(LogisticsService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** GET /docks?direction=in|out&date=YYYY-MM-DD — 도크 현황판(그리드). */ + @GetMapping("/docks") + public ApiResponse docks(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @RequestParam(required = false, defaultValue = "in") String direction, + @RequestParam(required = false) String date) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.getBoard(eventId, direction, date)); + } + + /** POST /dock-reservations — 슬롯 예약(중복 시 409). */ + @PostMapping("/dock-reservations") + public ApiResponse reserve(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @RequestBody DockReservationRequest request) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.createReservation(eventId, request)); + } + + /** GET /dock-forecast?date=YYYY-MM-DD — 철거일 대기열 예측(단순 시간대 집계, 부재 시 폴백). */ + @GetMapping("/dock-forecast") + public ApiResponse forecast(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @RequestParam(required = false) String date) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.getForecast(eventId, date)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/logistics/LogisticsMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/logistics/LogisticsMapper.java new file mode 100644 index 0000000..2558e90 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/logistics/LogisticsMapper.java @@ -0,0 +1,106 @@ +package com.zioinfo.kintex.logistics; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; +import java.util.Map; + +/** + * M8 반입/반출 도크 매퍼 — dock·dock_reservation. camelCase 별칭은 쌍따옴표(PG 소문자 폴딩 함정). + */ +@Mapper +public interface LogisticsMapper { + + /** 행사 배정 홀에 속한 도크 목록(그리드 행 순서). */ + @Select(""" + SELECT d.id, + d.dock_no AS "dockNo", + d.label, + d.heavy_priority AS "heavyPriority" + FROM dock d + JOIN hall_assignment ha ON ha.hall_id = d.hall_id + WHERE ha.event_id = #{eventId} + ORDER BY d.hall_id, d.dock_no + """) + List> findDocks(@Param("eventId") String eventId); + + /** 방향/일자 기준 예약 목록. */ + @Select(""" + SELECT id, + dock_id AS "dockId", + start_hour AS "startHour", + span, + company_name AS "companyName", + vehicle_weight AS "vehicleWeight", + tone + FROM dock_reservation + WHERE event_id = #{eventId} + AND direction = #{direction} + AND reserve_date = CAST(#{date} AS date) + ORDER BY dock_id, start_hour + """) + List> findReservations(@Param("eventId") String eventId, + @Param("direction") String direction, + @Param("date") String date); + + /** 방향 기준 최근 예약 일자(조회 기본값 해소). 없으면 null. */ + @Select(""" + SELECT to_char(min(reserve_date), 'YYYY-MM-DD') + FROM dock_reservation + WHERE event_id = #{eventId} AND direction = #{direction} + """) + String findDefaultDate(@Param("eventId") String eventId, @Param("direction") String direction); + + /** 같은 도크·일자·방향에서 [startHour, endHour) 와 겹치는 예약 수(0=충돌 없음). */ + @Select(""" + SELECT count(*) + FROM dock_reservation + WHERE event_id = #{eventId} + AND dock_id = #{dockId} + AND direction = #{direction} + AND reserve_date = CAST(#{date} AS date) + AND start_hour < #{endHour} + AND (start_hour + span) > #{startHour} + """) + int countOverlaps(@Param("eventId") String eventId, + @Param("dockId") String dockId, + @Param("direction") String direction, + @Param("date") String date, + @Param("startHour") int startHour, + @Param("endHour") int endHour); + + /** 일자 기준(방향 무관) 예약 점유 — 대기열 예측 집계용. */ + @Select(""" + SELECT start_hour AS "startHour", span + FROM dock_reservation + WHERE event_id = #{eventId} AND reserve_date = CAST(#{date} AS date) + """) + List> findOccupancyByDate(@Param("eventId") String eventId, + @Param("date") String date); + + @Insert(""" + INSERT INTO dock_reservation + (id, event_id, dock_id, direction, reserve_date, start_hour, span, + company_name, vehicle_no, vehicle_weight, item, forklift, tone) + VALUES + (#{id}, #{eventId}, #{dockId}, #{direction}, CAST(#{date} AS date), #{startHour}, #{span}, + #{companyName}, #{vehicleNo}, #{vehicleWeight}, #{item}, #{forklift}, #{tone}) + """) + int insertReservation(Map p); + + @Select(""" + SELECT id, + dock_id AS "dockId", + start_hour AS "startHour", + span, + company_name AS "companyName", + vehicle_weight AS "vehicleWeight", + tone + FROM dock_reservation + WHERE id = #{id} + """) + Map findReservationById(@Param("id") String id); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/logistics/LogisticsService.java b/src/backend/src/main/java/com/zioinfo/kintex/logistics/LogisticsService.java new file mode 100644 index 0000000..db5e281 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/logistics/LogisticsService.java @@ -0,0 +1,224 @@ +package com.zioinfo.kintex.logistics; + +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.logistics.dto.DockBoardDto; +import com.zioinfo.kintex.logistics.dto.DockDto; +import com.zioinfo.kintex.logistics.dto.DockForecastDto; +import com.zioinfo.kintex.logistics.dto.DockGridReservationDto; +import com.zioinfo.kintex.logistics.dto.DockReservationRequest; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * M8 반입/반출 도크 서비스 — 도크 현황판·슬롯 예약(중복 409)·대기열 예측(단순 시간대 집계). + * 그리드는 08:00~19:00(12개 시간대). AI 예측은 실 예약 집계, 데이터 부재 시 표준 패턴 폴백. + */ +@Service +public class LogisticsService { + + private static final int GRID_START_HOUR = 8; + private static final int HOURS = 12; // 08:00 ~ 19:00 + private static final int GRID_END_HOUR = GRID_START_HOUR + HOURS; // 20 (exclusive) + private static final int DEFAULT_SPAN = 2; + + /** 예약 데이터 부재 시 표준 대기열 패턴(오후 4시경 피크) — 폴백 명시. */ + private static final List FALLBACK_FORECAST = + List.of(20, 30, 40, 55, 70, 95, 100, 80, 50, 30, 22, 18); + + private final LogisticsMapper mapper; + + public LogisticsService(LogisticsMapper mapper) { + this.mapper = mapper; + } + + /** 도크 현황판 — direction(in|out)/date. date 미지정 시 최근 예약 일자(없으면 오늘)로 해소. */ + public DockBoardDto getBoard(String eventId, String direction, String date) { + String dir = normalizeDirection(direction); + String resolvedDate = resolveDate(eventId, dir, date); + + List> dockRows = mapper.findDocks(eventId); + List docks = new ArrayList<>(dockRows.size()); + Map indexByDockId = new LinkedHashMap<>(); + int i = 0; + for (Map r : dockRows) { + String id = str(r.get("id")); + indexByDockId.put(id, i++); + docks.add(new DockDto(id, intVal(r.get("dockNo")), str(r.get("label")), boolVal(r.get("heavyPriority")))); + } + + List grid = new ArrayList<>(); + for (Map r : mapper.findReservations(eventId, dir, resolvedDate)) { + Integer idx = indexByDockId.get(str(r.get("dockId"))); + if (idx == null) { + continue; // 다른 홀 도크(방어) + } + int startHour = intVal(r.get("startHour")); + int start = startHour - GRID_START_HOUR; + if (start < 0 || start >= HOURS) { + continue; + } + int span = Math.max(1, Math.min(intVal(r.get("span")), HOURS - start)); + grid.add(new DockGridReservationDto( + str(r.get("id")), idx, start, span, + blockLabel(r), str(r.get("tone")))); + } + return new DockBoardDto(dir, resolvedDate, GRID_START_HOUR, HOURS, docks, grid); + } + + /** 슬롯 예약 생성 — 중복 슬롯이면 409 CONFLICT. */ + public DockGridReservationDto createReservation(String eventId, DockReservationRequest req) { + String dir = normalizeDirection(req.direction()); + String date = requireDate(req.date()); + List> dockRows = mapper.findDocks(eventId); + if (dockRows.isEmpty()) { + throw new ApiException(ErrorCode.NOT_FOUND, "예약 가능한 도크가 없습니다."); + } + int dockIndex = req.dockIndex() == null ? -1 : req.dockIndex(); + if (dockIndex < 0 || dockIndex >= dockRows.size()) { + throw new ApiException(ErrorCode.VALIDATION, "도크 선택이 올바르지 않습니다."); + } + String dockId = str(dockRows.get(dockIndex).get("id")); + + int startHour = req.startHour() == null ? -1 : req.startHour(); + int span = req.span() == null || req.span() < 1 ? DEFAULT_SPAN : req.span(); + if (startHour < GRID_START_HOUR || startHour >= GRID_END_HOUR) { + throw new ApiException(ErrorCode.VALIDATION, "예약 시간대가 유효 범위를 벗어났습니다."); + } + int endHour = startHour + span; + if (endHour > GRID_END_HOUR) { + throw new ApiException(ErrorCode.VALIDATION, "예약 시간대가 운영 종료 시각을 초과합니다."); + } + if (mapper.countOverlaps(eventId, dockId, dir, date, startHour, endHour) > 0) { + throw new ApiException(ErrorCode.CONFLICT, "이미 예약된 슬롯과 겹칩니다. 다른 시간대를 선택하세요."); + } + + String id = "dr-" + UUID.randomUUID(); + Map p = new LinkedHashMap<>(); + p.put("id", id); + p.put("eventId", eventId); + p.put("dockId", dockId); + p.put("direction", dir); + p.put("date", date); + p.put("startHour", startHour); + p.put("span", span); + p.put("companyName", req.companyName()); + p.put("vehicleNo", req.vehicleNo()); + p.put("vehicleWeight", req.weight()); + p.put("item", req.item()); + p.put("forklift", req.forklift() != null && req.forklift()); + p.put("tone", "booked"); + mapper.insertReservation(p); + + Map saved = mapper.findReservationById(id); + int start = intVal(saved.get("startHour")) - GRID_START_HOUR; + return new DockGridReservationDto(id, dockIndex, start, + intVal(saved.get("span")), blockLabel(saved), str(saved.get("tone"))); + } + + /** 철거일 대기열 예측 — 일자별 도크 점유를 시간대 밀집도로 집계. 예약 없으면 폴백. */ + public DockForecastDto getForecast(String eventId, String date) { + String resolvedDate = requireDate(date); + List> occ = mapper.findOccupancyByDate(eventId, resolvedDate); + int dockCount = Math.max(1, mapper.findDocks(eventId).size()); + + if (occ.isEmpty()) { + return new DockForecastDto(FALLBACK_FORECAST, true, "16:00"); + } + int[] count = new int[HOURS]; + for (Map r : occ) { + int start = intVal(r.get("startHour")) - GRID_START_HOUR; + int span = Math.max(1, intVal(r.get("span"))); + for (int h = start; h < start + span && h < HOURS; h++) { + if (h >= 0) { + count[h]++; + } + } + } + List hourly = new ArrayList<>(HOURS); + int peakIdx = 0; + for (int h = 0; h < HOURS; h++) { + hourly.add((int) Math.round(count[h] * 100.0 / dockCount)); + if (count[h] > count[peakIdx]) { + peakIdx = h; + } + } + String peak = String.format("%02d:00", GRID_START_HOUR + peakIdx); + return new DockForecastDto(hourly, false, peak); + } + + // ── 헬퍼 ───────────────────────────────────────────────────────── + private String resolveDate(String eventId, String dir, String date) { + String norm = nullIfBlank(date); + if (norm != null) { + return requireDate(norm); + } + String def = mapper.findDefaultDate(eventId, dir); + return def != null ? def : LocalDate.now().toString(); + } + + private static String requireDate(String date) { + String norm = nullIfBlank(date); + if (norm == null) { + return LocalDate.now().toString(); + } + try { + return LocalDate.parse(norm).toString(); + } catch (DateTimeParseException e) { + throw new ApiException(ErrorCode.VALIDATION, "date 형식은 YYYY-MM-DD 이어야 합니다."); + } + } + + private static String normalizeDirection(String direction) { + String d = direction == null ? "in" : direction.trim().toLowerCase(); + if (!"in".equals(d) && !"out".equals(d)) { + throw new ApiException(ErrorCode.VALIDATION, "direction 은 in 또는 out 이어야 합니다."); + } + return d; + } + + /** 그리드 블록 라벨 — 우선 슬롯은 안내문, 그 외 업체·중량 조합. */ + private static String blockLabel(Map r) { + if ("priority".equals(str(r.get("tone")))) { + return "중량물 우선 (5t 이상)"; + } + String company = str(r.get("companyName")); + String weight = str(r.get("vehicleWeight")); + if (company == null && weight == null) { + return "예약됨"; + } + if (company == null) { + return weight; + } + return weight == null ? company : company + " · " + weight; + } + + private static String nullIfBlank(String s) { + return s == null || s.isBlank() ? null : s; + } + + private static int intVal(Object o) { + if (o instanceof Number n) return n.intValue(); + if (o == null) return 0; + try { + return Integer.parseInt(String.valueOf(o)); + } catch (NumberFormatException e) { + return 0; + } + } + + private static boolean boolVal(Object o) { + return o instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(o)); + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockBoardDto.java b/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockBoardDto.java new file mode 100644 index 0000000..8b4dfa9 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockBoardDto.java @@ -0,0 +1,17 @@ +package com.zioinfo.kintex.logistics.dto; + +import java.util.List; + +/** + * 도크 예약 현황판 (SCR-25). direction=in|out, date=조회 일자(서버 해소값). + * gridStartHour=그리드 첫 시간대(08), hours=시간대 수(12). reservations 는 dockIndex 로 docks 에 매핑. + */ +public record DockBoardDto( + String direction, + String date, + int gridStartHour, + int hours, + List docks, + List reservations +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockDto.java b/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockDto.java new file mode 100644 index 0000000..06e2002 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockDto.java @@ -0,0 +1,10 @@ +package com.zioinfo.kintex.logistics.dto; + +/** 하역장 도크(그리드 행). heavyPriority=중량물(5t↑) 우선 도크. */ +public record DockDto( + String id, + int dockNo, + String label, + boolean heavyPriority +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockForecastDto.java b/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockForecastDto.java new file mode 100644 index 0000000..653c507 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockForecastDto.java @@ -0,0 +1,14 @@ +package com.zioinfo.kintex.logistics.dto; + +import java.util.List; + +/** + * 철거일 대기열 예측 (SCR-25 우측 AI 카드). hourly=시간대별 밀집도 %(08:00~19:00, 12개). + * fallback=true 면 예약 데이터 부재로 표준 패턴 폴백(프론트 "AI 예측" 배지 유지·폴백 고지). + */ +public record DockForecastDto( + List hourly, + boolean fallback, + String peakLabel +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockGridReservationDto.java b/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockGridReservationDto.java new file mode 100644 index 0000000..c88558a --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockGridReservationDto.java @@ -0,0 +1,15 @@ +package com.zioinfo.kintex.logistics.dto; + +/** + * 도크 그리드 예약 블록 (SCR-25 좌측 스케줄). dockIndex 는 docks 목록 내 0-based 위치. + * start=시작 시간대(08:00→0), span=점유 칸 수. tone: booked|priority. + */ +public record DockGridReservationDto( + String id, + int dockIndex, + int start, + int span, + String label, + String tone +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockReservationRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockReservationRequest.java new file mode 100644 index 0000000..20297d4 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/logistics/dto/DockReservationRequest.java @@ -0,0 +1,19 @@ +package com.zioinfo.kintex.logistics.dto; + +/** + * 도크 슬롯 예약 요청 (SCR-25 우측 폼). dockIndex=docks 목록 0-based 위치, startHour=시작(8..19). + * span 미지정 시 서버 기본(2). weight/item/forklift/companyName/vehicleNo 는 예약 메타. + */ +public record DockReservationRequest( + String direction, + String date, + Integer dockIndex, + Integer startHour, + Integer span, + String vehicleNo, + String weight, + String item, + Boolean forklift, + String companyName +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingController.java b/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingController.java new file mode 100644 index 0000000..9860b32 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingController.java @@ -0,0 +1,56 @@ +package com.zioinfo.kintex.marketing; + +import com.zioinfo.kintex.auth.EventAccessGuard; +import com.zioinfo.kintex.auth.EventRole; +import com.zioinfo.kintex.auth.KintexPrincipal; +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.common.audit.Audited; +import com.zioinfo.kintex.marketing.dto.MarketingDtos.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * M12 EDM·캠페인 · 스폰서십 API (SCR-33·SCR-34). 모두 인증 필수({@link EventAccessGuard}). + * 캠페인 생성은 주최자/홀매니저 역할 게이트. 실 발송은 미구현(status 전이만). + */ +@RestController +@RequestMapping("/api/events/{eventId}") +public class MarketingController { + + private final MarketingService service; + private final EventAccessGuard guard; + + public MarketingController(MarketingService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** 캠페인 목록(선택 status 필터). */ + @GetMapping("/campaigns") + public ApiResponse> campaigns(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @RequestParam(required = false) String status) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.campaigns(eventId, status)); + } + + /** 캠페인 생성(발송 미구현 — status 전이만). */ + @Audited(action = "CAMPAIGN_CREATE", targetType = "edm_campaign") + @PostMapping("/campaigns") + public ApiResponse createCampaign(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @RequestBody CampaignCreateRequest req) { + guard.requireRole(principal, eventId, EventRole.ORGANIZER, EventRole.HALL_MANAGER); + return ApiResponse.ok(service.createCampaign(eventId, req)); + } + + /** 스폰서십 패키지·판매 현황. */ + @GetMapping("/sponsorship") + public ApiResponse sponsorship(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.sponsorship(eventId)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingMapper.java new file mode 100644 index 0000000..361af7d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingMapper.java @@ -0,0 +1,76 @@ +package com.zioinfo.kintex.marketing; + +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; + +/** + * M12 EDM·캠페인 · 스폰서십 매퍼. Map 반환 @Select 는 별칭에 쌍따옴표(AS "x")로 카멜케이스 키 보존. + */ +@Mapper +public interface MarketingMapper { + + // ── 캠페인 ── + @Select(""" + + """) + List> findCampaigns(@Param("eventId") String eventId, @Param("status") String status); + + @Insert(""" + INSERT INTO edm_campaign (id, event_id, name, status, audience, meta, open_rate, click_rate, scheduled_at) + VALUES (#{id}, #{eventId}, #{name}, #{status}, #{audience}, #{meta}, '-', '-', + CASE WHEN #{scheduledAt} IS NULL OR #{scheduledAt} = '' THEN NULL ELSE CAST(#{scheduledAt} AS timestamptz) END) + """) + int insertCampaign(@Param("id") String id, + @Param("eventId") String eventId, + @Param("name") String name, + @Param("status") String status, + @Param("audience") int audience, + @Param("meta") String meta, + @Param("scheduledAt") String scheduledAt); + + @Select("SELECT count(*) FROM event WHERE id = #{eventId}") + long eventExists(@Param("eventId") String eventId); + + // ── 스폰서십 ── + @Select(""" + SELECT id, code, name, name_ko AS "nameKo", price, accent, + benefits::text AS "benefits", + total_qty AS "totalQty", sold_qty AS "soldQty", dday + FROM sponsorship_package + WHERE event_id = #{eventId} + ORDER BY sort_order, id + """) + List> findPackages(@Param("eventId") String eventId); + + @Select(""" + SELECT id, name, tier_label AS "tierLabel", contract_status AS "contractStatus", + fulfillment::text AS "fulfillment" + FROM sponsorship_sponsor + WHERE event_id = #{eventId} + ORDER BY created_at, id + """) + List> findSponsors(@Param("eventId") String eventId); + + /** 스폰서십 요약 원자 집계(스폰서 수·판매액·잔여 패키지·이행 진행률). */ + @Select(""" + SELECT + (SELECT count(*) FROM sponsorship_sponsor s WHERE s.event_id = #{eventId}) AS "sponsorCount", + (SELECT COALESCE(sum(p.price), 0) FROM sponsorship_package p + WHERE p.event_id = #{eventId}) AS "priceTotalRef", + (SELECT COALESCE(sum(GREATEST(p.total_qty - p.sold_qty, 0)), 0) FROM sponsorship_package p + WHERE p.event_id = #{eventId}) AS "remainingPackages" + """) + Map findSponsorshipCounts(@Param("eventId") String eventId); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingService.java b/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingService.java new file mode 100644 index 0000000..406d84f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/marketing/MarketingService.java @@ -0,0 +1,178 @@ +package com.zioinfo.kintex.marketing; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.marketing.dto.MarketingDtos.*; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * M12 EDM·캠페인 · 스폰서십 서비스. + *

★ 캠페인 실 발송은 미구현 — 생성 시 status 전이만 결정한다(즉시=draft, 예약=scheduled). 실 발송 게이트웨이 연동은 후속 갭. + */ +@Service +public class MarketingService { + + private static final ObjectMapper JSON = new ObjectMapper(); + + private final MarketingMapper mapper; + + public MarketingService(MarketingMapper mapper) { + this.mapper = mapper; + } + + // ── 캠페인 ── + public List campaigns(String eventId, String status) { + List> rows = mapper.findCampaigns(eventId, blankToNull(status)); + List out = new ArrayList<>(rows == null ? 0 : rows.size()); + if (rows != null) { + for (Map r : rows) { + out.add(new CampaignDto( + str(r.get("id")), str(r.get("name")), str(r.get("status")), intVal(r.get("audience")), + str(r.get("meta")), str(r.get("openRate")), str(r.get("clickRate")))); + } + } + return out; + } + + public CampaignDto createCampaign(String eventId, CampaignCreateRequest req) { + String name = trimOrNull(req.name()); + if (name == null) { + throw new ApiException(ErrorCode.VALIDATION, "캠페인 이름을 입력해 주세요."); + } + if (mapper.eventExists(eventId) == 0) { + throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 행사입니다."); + } + boolean later = "later".equalsIgnoreCase(str(req.scheduleType())) + && req.scheduledAt() != null && !req.scheduledAt().isBlank(); + String status = later ? "scheduled" : "draft"; + int audience = req.audience() == null ? 0 : Math.max(req.audience(), 0); + String meta = later ? "발송 예정: " + req.scheduledAt() : "최종 수정: 방금 전"; + String id = "cp-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16); + // 빈 문자열로 전달(널 바인딩 회피) — 매퍼가 '' 를 NULL scheduled_at 으로 처리. + mapper.insertCampaign(id, eventId, name, status, audience, meta, + later ? req.scheduledAt() : ""); + return new CampaignDto(id, name, status, audience, meta, "-", "-"); + } + + // ── 스폰서십 ── + public SponsorshipView sponsorship(String eventId) { + List> pkgRows = mapper.findPackages(eventId); + List> sponsorRows = mapper.findSponsors(eventId); + Map counts = mapper.findSponsorshipCounts(eventId); + if (counts == null) counts = Map.of(); + + List tiers = new ArrayList<>(); + long soldValue = 0; + if (pkgRows != null) { + for (Map p : pkgRows) { + long total = lng(p.get("totalQty")); + long sold = lng(p.get("soldQty")); + long price = lng(p.get("price")); + int remaining = (int) Math.max(total - sold, 0); + soldValue += price * sold; + tiers.add(new TierDto( + str(p.get("id")), str(p.get("name")), str(p.get("nameKo")), + formatWon(price), str(p.get("accent")), parseStrings(p.get("benefits")), + remaining, remaining <= 0 ? "soldout" : "available", + p.get("dday") == null ? null : intVal(p.get("dday")))); + } + } + + List sponsors = new ArrayList<>(); + long fulfillTotal = 0, fulfillDone = 0; + if (sponsorRows != null) { + for (Map s : sponsorRows) { + List f = parseFulfillment(s.get("fulfillment")); + for (Fulfillment item : f) { + fulfillTotal++; + if (item.done()) fulfillDone++; + } + String name = str(s.get("name")); + sponsors.add(new SponsorDto( + str(s.get("id")), name, + (name == null || name.isEmpty()) ? "?" : name.substring(0, 1), + str(s.get("tierLabel")), str(s.get("contractStatus")), f)); + } + } + + List kpis = new ArrayList<>(); + kpis.add(new MktKpi("스폰서", String.valueOf(lng(counts.get("sponsorCount"))), "사", "primary")); + kpis.add(new MktKpi("판매액", formatMillion(soldValue), null, "primary")); + kpis.add(new MktKpi("잔여 패키지", String.valueOf(lng(counts.get("remainingPackages"))), "개", "ai")); + kpis.add(new MktKpi("이행물 진행", fulfillTotal > 0 ? String.valueOf(Math.round(fulfillDone * 100.0 / fulfillTotal)) : "0", + "%", "success")); + + return new SponsorshipView(kpis, tiers, sponsors); + } + + // ── helpers ── + private List parseStrings(Object json) { + if (json == null) return List.of(); + try { + return JSON.readValue(String.valueOf(json), new com.fasterxml.jackson.core.type.TypeReference>() { + }); + } catch (Exception e) { + return List.of(); + } + } + + private List parseFulfillment(Object json) { + if (json == null) return List.of(); + try { + List> raw = JSON.readValue(String.valueOf(json), + new com.fasterxml.jackson.core.type.TypeReference>>() { + }); + List out = new ArrayList<>(raw.size()); + for (Map m : raw) { + out.add(new Fulfillment(str(m.get("id")), str(m.get("label")), + m.get("done") instanceof Boolean b ? b : Boolean.parseBoolean(str(m.get("done"))))); + } + return out; + } catch (Exception e) { + return List.of(); + } + } + + private static String formatWon(long won) { + return "₩" + String.format("%,d", won); + } + + private static String formatMillion(long won) { + double m = won / 1_000_000.0; + return "₩" + String.format("%.1f", m) + "M"; + } + + private static String blankToNull(String s) { + return s == null || s.isBlank() ? null : s; + } + + private static String trimOrNull(String s) { + if (s == null) return null; + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static long lng(Object o) { + if (o == null) return 0; + if (o instanceof Number n) return n.longValue(); + try { + return Long.parseLong(String.valueOf(o)); + } catch (NumberFormatException e) { + return 0; + } + } + + private static int intVal(Object o) { + return (int) lng(o); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/marketing/dto/MarketingDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/marketing/dto/MarketingDtos.java new file mode 100644 index 0000000..89acc7f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/marketing/dto/MarketingDtos.java @@ -0,0 +1,41 @@ +package com.zioinfo.kintex.marketing.dto; + +import java.util.List; + +/** + * M12 EDM·캠페인 · 스폰서십 응답/요청 DTO 모음. + *

스폰서·기업명은 민감정보가 아니다(공개 마케팅 대상). 관람객 PII 는 이 모듈이 다루지 않는다. + */ +public final class MarketingDtos { + + private MarketingDtos() { + } + + // ── SCR-33 EDM·캠페인 ── + public record CampaignDto(String id, String name, String status, int audience, + String meta, String openRate, String clickRate) { + } + + /** 캠페인 생성 요청 — 발송은 미구현(status 전이만). */ + public record CampaignCreateRequest(String name, Integer audience, String meta, + String scheduleType, String scheduledAt) { + } + + // ── SCR-34 스폰서십 ── + public record SponsorshipView(List kpis, List tiers, List sponsors) { + } + + public record MktKpi(String label, String value, String unit, String tone) { + } + + public record TierDto(String id, String name, String nameKo, String price, String accent, + List benefits, int remaining, String status, Integer dday) { + } + + public record SponsorDto(String id, String name, String initial, String tier, String status, + List fulfillment) { + } + + public record Fulfillment(String id, String label, boolean done) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/publicsite/PublicSiteController.java b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/PublicSiteController.java new file mode 100644 index 0000000..1346cb9 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/PublicSiteController.java @@ -0,0 +1,63 @@ +package com.zioinfo.kintex.publicsite; + +import com.zioinfo.kintex.common.ApiResponse; +import com.zioinfo.kintex.publicsite.dto.InquiryReceiptDto; +import com.zioinfo.kintex.publicsite.dto.InquiryRequest; +import com.zioinfo.kintex.publicsite.dto.PublicEventDto; +import com.zioinfo.kintex.publicsite.dto.PublicFloorplanDto; +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; + +/** + * 공개 홍보 사이트(M12) API — 전 경로 비인증 공개(SecurityConfig 에서 {@code /api/public/**} permitAll). + *

공개 카탈로그(행사·플로어플랜)는 read-only, 문의는 접수만. 내부 식별자·PII·원가 미노출. + *

관람객 사전등록(POST {@code /api/public/events/{id}/register})은 visitor 트랙 소관 — 여기서 정의하지 않는다. + */ +@RestController +@RequestMapping("/api/public") +public class PublicSiteController { + + private static final int MAX_LIMIT = 2000; + + private final PublicSiteService service; + + public PublicSiteController(PublicSiteService service) { + this.service = service; + } + + /** GET /api/public/events?year=&q= — 공개 카탈로그(시작일 내림차순). */ + @GetMapping("/events") + public ApiResponse> events(@RequestParam(required = false) String year, + @RequestParam(required = false) String q, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "2000") int size) { + int limit = Math.min(Math.max(size, 1), MAX_LIMIT); + int offset = Math.max(page, 0) * limit; + return ApiResponse.ok(service.listEvents(year, q, limit, offset)); + } + + /** GET /api/public/events/{eventId} — 공개 행사 상세(공개 필드만). */ + @GetMapping("/events/{eventId}") + public ApiResponse event(@PathVariable String eventId) { + return ApiResponse.ok(service.getEvent(eventId)); + } + + /** GET /api/public/events/{eventId}/floorplan — 공개 플로어플랜 요약(부스번호·업체명·상태·중심점). */ + @GetMapping("/events/{eventId}/floorplan") + public ApiResponse floorplan(@PathVariable String eventId) { + return ApiResponse.ok(service.getFloorplan(eventId)); + } + + /** POST /api/public/inquiries — 참가/부스 신청 문의 접수(접수번호 반환). */ + @PostMapping("/inquiries") + public ApiResponse inquiry(@RequestBody InquiryRequest req) { + return ApiResponse.ok(service.submitInquiry(req)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/publicsite/PublicSiteMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/PublicSiteMapper.java new file mode 100644 index 0000000..f2d3d92 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/PublicSiteMapper.java @@ -0,0 +1,150 @@ +package com.zioinfo.kintex.publicsite; + +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; + +/** + * 공개 홍보 사이트(M12) 매퍼 — 기존 event·hall·layout·booth 는 read-only 조회, 문의만 write. + *

★ Map 반환 @Select 는 camelCase 별칭에 반드시 쌍따옴표({@code AS "x"})를 사용한다(PG lower-fold 방지). + *

공개 응답 정책: 내부 booth id·폴리곤·비용·PII 컬럼은 select 하지 않는다. + */ +@Mapper +public interface PublicSiteMapper { + + // ── 공개 카탈로그(event read-only) ────────────────────────────────────── + /** 공개 행사 카탈로그 — year(시작연도)·q(이름 부분검색) 선택 필터. 시작일 내림차순. */ + @Select(""" + + """) + List> findEvents(@Param("year") String year, + @Param("q") String q, + @Param("limit") int limit, + @Param("offset") int offset); + + /** 공개 행사 상세(단건) — 공개 필드만. */ + @Select(""" + SELECT e.id, + e.name, + to_char(e.start_date, 'YYYY-MM-DD') AS "startDate", + to_char(e.end_date, 'YYYY-MM-DD') AS "endDate", + e.status, + h.label AS "hallLabel", + (SELECT count(*)::int + FROM booth b + JOIN layout l ON l.id = b.layout_id + WHERE l.event_id = e.id) AS "boothCount" + FROM event e + LEFT JOIN LATERAL ( + SELECT ha.hall_id FROM hall_assignment ha + WHERE ha.event_id = e.id ORDER BY ha.is_primary DESC LIMIT 1 + ) pa ON true + LEFT JOIN hall h ON h.id = pa.hall_id + WHERE e.id = #{eventId} + """) + Map findEvent(@Param("eventId") String eventId); + + // ── 공개 플로어플랜(hall·booth read-only) ─────────────────────────────── + /** 행사 배정 홀 목록 — 공개 라벨·전시장만. */ + @Select(""" + SELECT h.id AS "hallId", + h.label AS "label", + h.exhibition_center AS "center", + ha.is_primary AS "primary" + FROM hall_assignment ha + JOIN hall h ON h.id = ha.hall_id + WHERE ha.event_id = #{eventId} + ORDER BY ha.is_primary DESC, h.id + """) + List> findEventHalls(@Param("eventId") String eventId); + + /** + * 공개 부스 요약 — 홀별 최신 버전 배치안의 부스만. 폴리곤은 중심점(cx/cy)으로 단순화. + * status 는 업체 배정 여부로 파생(reserved/available). 내부 booth id 미노출. + */ + @Select(""" + SELECT b.booth_no AS "boothNo", + b.assigned_company_name AS "companyName", + b.booth_type AS "boothType", + CASE WHEN b.assigned_company_name IS NOT NULL + AND b.assigned_company_name <> '' + THEN 'reserved' ELSE 'available' END AS "status", + round(ST_X(ST_Centroid(b.geom))::numeric, 2) AS "cx", + round(ST_Y(ST_Centroid(b.geom))::numeric, 2) AS "cy", + h.label AS "hallLabel" + FROM booth b + JOIN layout l ON l.id = b.layout_id + JOIN hall h ON h.id = l.hall_id + WHERE l.event_id = #{eventId} + AND l.version = (SELECT max(l2.version) FROM layout l2 + WHERE l2.event_id = l.event_id AND l2.hall_id = l.hall_id) + ORDER BY b.booth_no NULLS LAST + LIMIT #{limit} + """) + List> findPublicBooths(@Param("eventId") String eventId, + @Param("limit") int limit); + + // ── 문의 접수(write) + rate ───────────────────────────────────────────── + /** 동일 이메일의 최근 window(초) 내 접수 건수 — 단순 rate 제어(1분 1건). */ + @Select(""" + SELECT count(*)::int FROM exhibit_inquiry + WHERE email = #{email} + AND created_at > now() - make_interval(secs => #{windowSeconds}) + """) + int countRecentByEmail(@Param("email") String email, @Param("windowSeconds") int windowSeconds); + + /** 문의 저장(공개 접수). */ + @org.apache.ibatis.annotations.Insert(""" + INSERT INTO exhibit_inquiry + (id, receipt_no, inquiry_type, company, contact_name, email, phone, + scale, hall_pref, message, agree_privacy, agree_marketing, status) + VALUES + (#{id}, #{receiptNo}, #{inquiryType}, #{company}, #{contactName}, #{email}, #{phone}, + #{scale}, #{hallPref}, #{message}, #{agreePrivacy}, #{agreeMarketing}, 'received') + """) + void insertInquiry(@Param("id") String id, + @Param("receiptNo") String receiptNo, + @Param("inquiryType") String inquiryType, + @Param("company") String company, + @Param("contactName") String contactName, + @Param("email") String email, + @Param("phone") String phone, + @Param("scale") String scale, + @Param("hallPref") String hallPref, + @Param("message") String message, + @Param("agreePrivacy") boolean agreePrivacy, + @Param("agreeMarketing") boolean agreeMarketing); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/publicsite/PublicSiteService.java b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/PublicSiteService.java new file mode 100644 index 0000000..e5b39a2 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/PublicSiteService.java @@ -0,0 +1,133 @@ +package com.zioinfo.kintex.publicsite; + +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.publicsite.dto.InquiryReceiptDto; +import com.zioinfo.kintex.publicsite.dto.InquiryRequest; +import com.zioinfo.kintex.publicsite.dto.PublicEventDto; +import com.zioinfo.kintex.publicsite.dto.PublicFloorplanDto; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.regex.Pattern; + +/** + * 공개 홍보 사이트(M12) 서비스 — 카탈로그 read-only 조회 + 문의 접수(rate 제어). + * 전 경로 비인증 공개. 공개 응답에는 내부 식별자·PII·원가를 담지 않는다. + */ +@Service +public class PublicSiteService { + + private static final int BOOTH_LIMIT = 3000; + private static final int RATE_WINDOW_SECONDS = 60; // 동일 이메일 1분 1건 + private static final Pattern EMAIL = Pattern.compile("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$"); + private static final DateTimeFormatter RECEIPT_DAY = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private final PublicSiteMapper mapper; + + public PublicSiteService(PublicSiteMapper mapper) { + this.mapper = mapper; + } + + // ── 카탈로그 ──────────────────────────────────────────────────────────── + public List listEvents(String year, String q, int limit, int offset) { + return mapper.findEvents(nullIfBlank(year), nullIfBlank(q), limit, offset) + .stream().map(this::toEvent).toList(); + } + + public PublicEventDto getEvent(String eventId) { + Map row = mapper.findEvent(eventId); + if (row == null) throw new ApiException(ErrorCode.NOT_FOUND); + return toEvent(row); + } + + public PublicFloorplanDto getFloorplan(String eventId) { + Map ev = mapper.findEvent(eventId); + if (ev == null) throw new ApiException(ErrorCode.NOT_FOUND); + List halls = mapper.findEventHalls(eventId).stream() + .map(m -> new PublicFloorplanDto.Hall( + str(m.get("hallId")), str(m.get("label")), + toInt(m.get("center")), Boolean.TRUE.equals(m.get("primary")))) + .toList(); + List booths = mapper.findPublicBooths(eventId, BOOTH_LIMIT).stream() + .map(m -> new PublicFloorplanDto.Booth( + str(m.get("boothNo")), str(m.get("companyName")), str(m.get("boothType")), + str(m.get("status")), toDouble(m.get("cx")), toDouble(m.get("cy")), + str(m.get("hallLabel")))) + .toList(); + return new PublicFloorplanDto(str(ev.get("id")), str(ev.get("name")), halls, booths); + } + + // ── 문의 접수 ──────────────────────────────────────────────────────────── + @Transactional + public InquiryReceiptDto submitInquiry(InquiryRequest req) { + String name = nullIfBlank(req.contactName()); + String email = nullIfBlank(req.email()); + String phone = nullIfBlank(req.phone()); + + if (!Boolean.TRUE.equals(req.agreePrivacy())) { + throw new ApiException(ErrorCode.VALIDATION, "개인정보 수집·이용 동의(필수)가 필요합니다."); + } + if (name == null) { + throw new ApiException(ErrorCode.VALIDATION, "담당자 이름을 입력해 주세요."); + } + if (email == null && phone == null) { + throw new ApiException(ErrorCode.VALIDATION, "연락 가능한 이메일 또는 전화번호를 입력해 주세요."); + } + if (email != null && !EMAIL.matcher(email).matches()) { + throw new ApiException(ErrorCode.VALIDATION, "이메일 형식이 올바르지 않습니다."); + } + if (nullIfBlank(req.message()) == null) { + throw new ApiException(ErrorCode.VALIDATION, "문의 내용을 입력해 주세요."); + } + + // 단순 rate: 동일 이메일 1분 1건(이메일 있을 때만 적용) + if (email != null && mapper.countRecentByEmail(email, RATE_WINDOW_SECONDS) > 0) { + throw new ApiException(ErrorCode.CONFLICT, + "같은 이메일로 잠시 전 문의가 접수되었습니다. 1분 후 다시 시도해 주세요."); + } + + String receiptNo = generateReceiptNo(); + mapper.insertInquiry( + UUID.randomUUID().toString(), receiptNo, + nullIfBlank(req.inquiryType()), nullIfBlank(req.company()), name, email, phone, + nullIfBlank(req.scale()), nullIfBlank(req.hallPref()), nullIfBlank(req.message()), + true, Boolean.TRUE.equals(req.agreeMarketing())); + return new InquiryReceiptDto(receiptNo, "received"); + } + + // ── helpers ────────────────────────────────────────────────────────────── + private PublicEventDto toEvent(Map m) { + return new PublicEventDto( + str(m.get("id")), str(m.get("name")), str(m.get("startDate")), str(m.get("endDate")), + str(m.get("status")), str(m.get("hallLabel")), toInt(m.get("boothCount"))); + } + + private String generateReceiptNo() { + String day = LocalDate.now().format(RECEIPT_DAY); + int rand = ThreadLocalRandom.current().nextInt(0, 10000); + return "KTX-INQ-" + day + "-" + String.format("%04d", rand); + } + + private static String nullIfBlank(String s) { + return (s == null || s.isBlank()) ? null : s.trim(); + } + + private static String str(Object o) { + return o == null ? null : o.toString(); + } + + private static Integer toInt(Object o) { + return (o instanceof Number n) ? n.intValue() : null; + } + + private static Double toDouble(Object o) { + return (o instanceof Number n) ? n.doubleValue() : null; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/publicsite/dto/InquiryReceiptDto.java b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/dto/InquiryReceiptDto.java new file mode 100644 index 0000000..a7fed26 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/dto/InquiryReceiptDto.java @@ -0,0 +1,10 @@ +package com.zioinfo.kintex.publicsite.dto; + +/** + * 문의 접수 결과 (M12 / SCR-P6). 공개 응답 — 접수번호·상태만 반환(PII 미노출). + */ +public record InquiryReceiptDto( + String receiptNo, + String status +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/publicsite/dto/InquiryRequest.java b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/dto/InquiryRequest.java new file mode 100644 index 0000000..3beb903 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/dto/InquiryRequest.java @@ -0,0 +1,19 @@ +package com.zioinfo.kintex.publicsite.dto; + +/** + * 참가/부스 신청 문의 접수 입력 (M12 / SCR-P6). 비인증 공개 POST 본문. + *

서버는 agreePrivacy(필수)·연락 수단(email 또는 phone)·contactName 을 검증한다. + */ +public record InquiryRequest( + String inquiryType, // shell | raw | premium | general (선택) + String company, + String contactName, + String email, + String phone, + String scale, + String hallPref, + String message, + Boolean agreePrivacy, + Boolean agreeMarketing +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/publicsite/dto/PublicEventDto.java b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/dto/PublicEventDto.java new file mode 100644 index 0000000..5953a2f --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/dto/PublicEventDto.java @@ -0,0 +1,17 @@ +package com.zioinfo.kintex.publicsite.dto; + +/** + * 공개 카탈로그 행사 항목 (M12 / SCR-P1·P2). 비인증 공개. + *

공개 필드만 노출한다 — 내부 식별자(멤버·비용·비공개 상태) 제외. + * status 는 {@code event.status} DB 원문(active/ended)을 그대로 반환하고 프론트가 날짜 기준 재계산한다. + */ +public record PublicEventDto( + String id, + String name, + String startDate, + String endDate, + String status, + String hallLabel, + Integer boothCount +) { +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/publicsite/dto/PublicFloorplanDto.java b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/dto/PublicFloorplanDto.java new file mode 100644 index 0000000..72c3438 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/publicsite/dto/PublicFloorplanDto.java @@ -0,0 +1,30 @@ +package com.zioinfo.kintex.publicsite.dto; + +import java.util.List; + +/** + * 공개 플로어플랜 요약 (M12·M2 / SCR-P3). 비인증 공개. + *

좌표 폴리곤은 단순화(부스 중심점 cx/cy)만 반환한다 — 원 폴리곤·트렌치·내부 식별자는 제외. + */ +public record PublicFloorplanDto( + String eventId, + String eventName, + List halls, + List booths +) { + /** 공개 홀 요약(전시장·라벨만). */ + public record Hall(String hallId, String label, Integer center, boolean primary) { + } + + /** 공개 부스 요약 — 부스번호·업체명(표시용)·유형·상태·중심점만. 내부 booth id·폴리곤 미노출. */ + public record Booth( + String boothNo, + String companyName, + String boothType, + String status, // available | reserved + Double cx, + Double cy, + String hallLabel + ) { + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorController.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorController.java new file mode 100644 index 0000000..16bc1f7 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorController.java @@ -0,0 +1,66 @@ +package com.zioinfo.kintex.visitor; + +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.visitor.dto.VisitorDtos.*; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** + * M10 관람객·리드 API (SCR-30·SCR-32) + 공개 사전등록 접수. + *

    + *
  • 공개(비인증): {@code POST /api/public/events/{eventId}/register} — /api/public/** permitAll.
  • + *
  • 인증: 요약·목록·리드 — {@link EventAccessGuard} 행사 접근 가드. 응답은 마스킹 필드만(PII 불변).
  • + *
+ */ +@RestController +public class VisitorController { + + private final VisitorService service; + private final EventAccessGuard guard; + + public VisitorController(VisitorService service, EventAccessGuard guard) { + this.service = service; + this.guard = guard; + } + + /** 공개 사전등록 접수(인증 불필요) — 중복 이메일은 기존 등록 재반환. */ + @Audited(action = "VISITOR_REGISTER", targetType = "visitor_registration") + @PostMapping("/api/public/events/{eventId}/register") + public ApiResponse register(@PathVariable String eventId, + @RequestBody RegisterRequest req) { + return ApiResponse.ok(service.register(eventId, req)); + } + + /** 등록 대시보드 요약(집계·추이·유형 분포·폼). */ + @GetMapping("/api/events/{eventId}/visitors/summary") + public ApiResponse summary(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.summary(eventId)); + } + + /** 등록자 목록(마스킹). */ + @GetMapping("/api/events/{eventId}/visitors") + public ApiResponse> visitors(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.visitors(eventId, page, size)); + } + + /** 리드 목록(마스킹 + AI 저장 스코어). booth 선택 필터. */ + @GetMapping("/api/events/{eventId}/leads") + public ApiResponse> leads(@AuthenticationPrincipal KintexPrincipal principal, + @PathVariable String eventId, + @RequestParam(required = false) String boothId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + guard.requireEventAccess(principal, eventId); + return ApiResponse.ok(service.leads(eventId, boothId, page, size)); + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorMapper.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorMapper.java new file mode 100644 index 0000000..f6ea172 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorMapper.java @@ -0,0 +1,164 @@ +package com.zioinfo.kintex.visitor; + +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; + +/** + * M10 관람객·리드 매퍼 — {@code visitor_registration}·{@code lead} read/insert. + *

★ PII 불변: 리스트 조회 SQL 은 원문 name/phone/email 을 SELECT 하지 않고 마스킹 표현식만 반환한다. + * Map 반환 @Select 는 별칭에 쌍따옴표(AS "x") 를 사용해 카멜케이스 키를 보존한다. + */ +@Mapper +public interface VisitorMapper { + + // ── 요약 집계 ── + + /** 등록/체크인/유형 원자 집계(단일 행). */ + @Select(""" + SELECT + count(*) AS "total", + count(*) FILTER (WHERE checkin_state = 'done') AS "checkedIn", + count(*) FILTER (WHERE checkin_state = 'waiting') AS "waiting", + count(*) FILTER (WHERE checkin_state = 'cancelled') AS "cancelled", + count(*) FILTER (WHERE visitor_type = 'buyer') AS "buyers", + count(*) FILTER (WHERE visitor_type = 'vip') AS "vips", + count(*) FILTER (WHERE badge_issued) AS "badgeIssued" + FROM visitor_registration + WHERE event_id = #{eventId} + """) + Map findSummaryCounts(@Param("eventId") String eventId); + + /** 리드 건수(스코어 80+ 핫리드 포함). */ + @Select(""" + SELECT count(*) AS "total", + count(*) FILTER (WHERE score >= 80) AS "hot" + FROM lead WHERE event_id = #{eventId} + """) + Map findLeadCounts(@Param("eventId") String eventId); + + /** 일자별 등록/체크인 건수(누적은 서비스에서 산출). */ + @Select(""" + SELECT to_char(registered_at, 'MM.DD') AS "day", + count(*) AS "regCount", + count(*) FILTER (WHERE checkin_state = 'done') AS "checkinCount" + FROM visitor_registration + WHERE event_id = #{eventId} + GROUP BY registered_at::date, to_char(registered_at, 'MM.DD') + ORDER BY registered_at::date + """) + List> findDailyTrend(@Param("eventId") String eventId); + + /** 유형별 분포 집계. */ + @Select(""" + SELECT visitor_type AS "type", count(*) AS "cnt" + FROM visitor_registration + WHERE event_id = #{eventId} + GROUP BY visitor_type + """) + List> findTypeCounts(@Param("eventId") String eventId); + + // ── 목록(마스킹) ── + + /** 등록자 목록 — 마스킹 필드만 SELECT(원문 미반환). */ + @Select(""" + SELECT id, + CASE WHEN char_length(name) <= 1 THEN name + WHEN char_length(name) = 2 THEN left(name,1) || '*' + ELSE left(name,1) || repeat('*', char_length(name)-2) || right(name,1) + END AS "nameMasked", + visitor_type AS "type", + company, + to_char(registered_at, 'YYYY.MM.DD') AS "registeredAt", + checkin_state AS "checkin", + badge_issued AS "badgeIssued" + FROM visitor_registration + WHERE event_id = #{eventId} + ORDER BY registered_at DESC, id DESC + LIMIT #{limit} OFFSET #{offset} + """) + List> findVisitorPage(@Param("eventId") String eventId, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select("SELECT count(*) FROM visitor_registration WHERE event_id = #{eventId}") + long countVisitors(@Param("eventId") String eventId); + + /** 리드 목록 — 마스킹 + AI 저장값. jsonb 는 ::text 로 반환해 서비스에서 파싱. booth 선택 필터. */ + @Select(""" + + """) + List> findLeadPage(@Param("eventId") String eventId, + @Param("boothId") String boothId, + @Param("limit") int limit, + @Param("offset") int offset); + + @Select(""" + + """) + long countLeads(@Param("eventId") String eventId, @Param("boothId") String boothId); + + // ── 공개 사전등록 ── + + /** 행사+이메일 기존 등록 조회(중복 시 재반환). 원문 미반환(id·badge_code 만). */ + @Select(""" + SELECT id AS "registrationId", badge_code AS "badgeCode" + FROM visitor_registration + WHERE event_id = #{eventId} AND lower(email) = lower(#{email}) + LIMIT 1 + """) + Map findByEventEmail(@Param("eventId") String eventId, @Param("email") String email); + + /** 행사 존재 확인(공개 등록 대상 유효성). */ + @Select("SELECT count(*) FROM event WHERE id = #{eventId}") + long eventExists(@Param("eventId") String eventId); + + /** 사전등록 삽입 — 원문 저장(응답에는 노출 금지). id·badgeCode 는 서비스가 생성. */ + @Insert(""" + INSERT INTO visitor_registration + (id, event_id, name, phone, email, company, visitor_type, agree_privacy, agree_marketing, badge_code, badge_issued, checkin_state) + VALUES + (#{id}, #{eventId}, #{name}, #{phone}, #{email}, NULL, #{visitorType}, #{agreePrivacy}, #{agreeMarketing}, #{badgeCode}, false, 'waiting') + """) + int insertRegistration(@Param("id") String id, + @Param("eventId") String eventId, + @Param("name") String name, + @Param("phone") String phone, + @Param("email") String email, + @Param("visitorType") String visitorType, + @Param("agreePrivacy") boolean agreePrivacy, + @Param("agreeMarketing") boolean agreeMarketing, + @Param("badgeCode") String badgeCode); +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorService.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorService.java new file mode 100644 index 0000000..eb03805 --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/VisitorService.java @@ -0,0 +1,260 @@ +package com.zioinfo.kintex.visitor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zioinfo.kintex.common.PageResponse; +import com.zioinfo.kintex.common.error.ApiException; +import com.zioinfo.kintex.common.error.ErrorCode; +import com.zioinfo.kintex.visitor.dto.VisitorDtos.*; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * M10 관람객·리드 서비스 — 집계·목록(마스킹)·공개 사전등록. + *

★ PII 불변: 매퍼는 이미 마스킹된 필드만 반환한다. 서비스는 원문 PII 를 응답 DTO 로 옮기지 않는다. + * 원문은 오직 등록 INSERT 파라미터로만 사용하고 어떤 반환/로그에도 담지 않는다. + */ +@Service +public class VisitorService { + + private static final ObjectMapper JSON = new ObjectMapper(); + + /** 유형 표시 라벨·색상(프론트 도넛 팔레트와 정렬). */ + private static final Map TYPE_META = Map.of( + "visitor", new String[]{"관람객", "#0066B3"}, + "buyer", new String[]{"바이어", "#6D4AFF"}, + "vip", new String[]{"VIP", "#0E8A5F"}); + + private final VisitorMapper mapper; + + public VisitorService(VisitorMapper mapper) { + this.mapper = mapper; + } + + // ── SCR-30 요약 ── + public VisitorSummary summary(String eventId) { + Map c = orEmpty(mapper.findSummaryCounts(eventId)); + Map lc = orEmpty(mapper.findLeadCounts(eventId)); + + long total = lng(c.get("total")); + long checkedIn = lng(c.get("checkedIn")); + long waiting = lng(c.get("waiting")); + long cancelled = lng(c.get("cancelled")); + long buyers = lng(c.get("buyers")); + long leads = lng(lc.get("total")); + + List kpis = new ArrayList<>(); + kpis.add(new Kpi("사전등록", fmt(total), null, null, null, null)); + kpis.add(new Kpi("체크인", fmt(checkedIn), null, null, null, + total > 0 ? "진행률 " + pct(checkedIn, total) : null)); + long noShowBase = checkedIn + waiting + cancelled; + kpis.add(new Kpi("노쇼/취소", fmt(waiting + cancelled), null, "down", null, + noShowBase > 0 ? pct(waiting + cancelled, noShowBase) : null)); + kpis.add(new Kpi("바이어 비중", total > 0 ? pct(buyers, total) : "0%", null, null, null, "목표 30%")); + kpis.add(new Kpi("리드 생성", fmt(leads), null, null, true, "AI 스코어링 대상")); + + return new VisitorSummary(kpis, buildTrend(eventId), buildTypes(eventId, total), buildForms(eventId)); + } + + private List buildTrend(String eventId) { + List> rows = mapper.findDailyTrend(eventId); + List out = new ArrayList<>(); + long cumReg = 0, cumChk = 0; + if (rows != null) { + for (Map r : rows) { + cumReg += lng(r.get("regCount")); + cumChk += lng(r.get("checkinCount")); + out.add(new TrendPoint(str(r.get("day")), cumReg, cumChk)); + } + } + return out; + } + + private List buildTypes(String eventId, long total) { + List> rows = mapper.findTypeCounts(eventId); + List out = new ArrayList<>(); + if (rows != null) { + for (Map r : rows) { + String type = str(r.get("type")); + long cnt = lng(r.get("cnt")); + String[] meta = TYPE_META.getOrDefault(type, new String[]{type, "#98A2B3"}); + int value = total > 0 ? (int) Math.round(cnt * 100.0 / total) : 0; + out.add(new TypeSlice(meta[0], value, meta[1])); + } + } + return out; + } + + /** 등록 폼 — 유형 카운트에서 파생(폼 마스터 부재 시). */ + private List buildForms(String eventId) { + List> rows = mapper.findTypeCounts(eventId); + Map byType = new java.util.HashMap<>(); + if (rows != null) { + for (Map r : rows) { + byType.put(str(r.get("type")), lng(r.get("cnt"))); + } + } + List out = new ArrayList<>(); + out.add(new FormItem("general", "일반 참관객", true, byType.getOrDefault("visitor", 0L))); + out.add(new FormItem("buyer", "비즈니스 바이어", true, byType.getOrDefault("buyer", 0L))); + out.add(new FormItem("vip", "VIP 초청", byType.getOrDefault("vip", 0L) > 0, byType.getOrDefault("vip", 0L))); + return out; + } + + // ── SCR-30 목록(마스킹) ── + public PageResponse visitors(String eventId, int page, int size) { + int limit = clamp(size, 1, 200); + int offset = Math.max(page, 0) * limit; + List> rows = mapper.findVisitorPage(eventId, limit, offset); + List items = new ArrayList<>(rows == null ? 0 : rows.size()); + if (rows != null) { + for (Map r : rows) { + items.add(new VisitorListItem( + str(r.get("id")), str(r.get("nameMasked")), str(r.get("type")), + str(r.get("company")), str(r.get("registeredAt")), str(r.get("checkin")), + bool(r.get("badgeIssued")))); + } + } + return PageResponse.of(items, Math.max(page, 0), limit, mapper.countVisitors(eventId)); + } + + // ── SCR-32 리드(마스킹 + AI 저장값) ── + public PageResponse leads(String eventId, String boothId, int page, int size) { + int limit = clamp(size, 1, 200); + int offset = Math.max(page, 0) * limit; + String bid = (boothId == null || boothId.isBlank()) ? null : boothId; + List> rows = mapper.findLeadPage(eventId, bid, limit, offset); + List items = new ArrayList<>(rows == null ? 0 : rows.size()); + if (rows != null) { + for (Map r : rows) { + items.add(new LeadItem( + str(r.get("id")), str(r.get("nameMasked")), str(r.get("role")), str(r.get("company")), + str(r.get("product")), intVal(r.get("interest")), intVal(r.get("score")), + str(r.get("collectedAt")), str(r.get("phoneMasked")), str(r.get("emailMasked")), + parseStrings(r.get("aiReasons")), parseActivity(r.get("activity")), + str(r.get("followupDraft")), bool(r.get("aiGenerated")))); + } + } + return PageResponse.of(items, Math.max(page, 0), limit, mapper.countLeads(eventId, bid)); + } + + // ── 공개 사전등록 ── + public RegisterResult register(String eventId, RegisterRequest req) { + String name = trimOrNull(req.name()); + String email = trimOrNull(req.email()); + String phone = trimOrNull(req.phone()); + if (name == null || email == null || !email.contains("@")) { + throw new ApiException(ErrorCode.VALIDATION, "이름과 유효한 이메일을 입력해 주세요."); + } + if (req.agreePrivacy() == null || !req.agreePrivacy()) { + throw new ApiException(ErrorCode.VALIDATION, "개인정보 수집·이용에 동의해야 등록할 수 있습니다."); + } + if (mapper.eventExists(eventId) == 0) { + throw new ApiException(ErrorCode.NOT_FOUND, "존재하지 않는 행사입니다."); + } + // 중복 이메일 → 기존 등록 재반환(멱등) + Map existing = mapper.findByEventEmail(eventId, email); + if (existing != null && existing.get("registrationId") != null) { + return new RegisterResult(str(existing.get("registrationId")), str(existing.get("badgeCode"))); + } + String type = normalizeType(req.visitorType()); + String id = "vr-" + UUID.randomUUID().toString().replace("-", "").substring(0, 20); + String badgeCode = "KTX-V-" + UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(); + try { + mapper.insertRegistration(id, eventId, name, phone, email, type, + Boolean.TRUE.equals(req.agreePrivacy()), Boolean.TRUE.equals(req.agreeMarketing()), badgeCode); + } catch (org.springframework.dao.DuplicateKeyException dup) { + // 동시 요청 경합 — 기존 등록 재조회 + Map again = mapper.findByEventEmail(eventId, email); + if (again != null && again.get("registrationId") != null) { + return new RegisterResult(str(again.get("registrationId")), str(again.get("badgeCode"))); + } + throw new ApiException(ErrorCode.CONFLICT, "이미 등록된 이메일입니다."); + } + return new RegisterResult(id, badgeCode); + } + + // ── helpers ── + private static String normalizeType(String t) { + if (t == null) return "visitor"; + String v = t.trim().toLowerCase(); + return switch (v) { + case "buyer", "vip", "visitor" -> v; + default -> "visitor"; + }; + } + + private List parseStrings(Object json) { + if (json == null) return List.of(); + try { + return JSON.readValue(String.valueOf(json), new com.fasterxml.jackson.core.type.TypeReference>() { + }); + } catch (Exception e) { + return List.of(); + } + } + + private List parseActivity(Object json) { + if (json == null) return List.of(); + try { + List> raw = JSON.readValue(String.valueOf(json), + new com.fasterxml.jackson.core.type.TypeReference>>() { + }); + List out = new ArrayList<>(raw.size()); + for (Map m : raw) { + out.add(new Activity(str(m.get("text")), str(m.get("at")))); + } + return out; + } catch (Exception e) { + return List.of(); + } + } + + private static Map orEmpty(Map m) { + return m == null ? Map.of() : m; + } + + private static String str(Object o) { + return o == null ? null : String.valueOf(o); + } + + private static String trimOrNull(String s) { + if (s == null) return null; + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private static long lng(Object o) { + if (o == null) return 0; + if (o instanceof Number n) return n.longValue(); + try { + return Long.parseLong(String.valueOf(o)); + } catch (NumberFormatException e) { + return 0; + } + } + + private static int intVal(Object o) { + return (int) lng(o); + } + + private static boolean bool(Object o) { + return o instanceof Boolean b ? b : Boolean.parseBoolean(String.valueOf(o)); + } + + private static int clamp(int v, int min, int max) { + return Math.max(min, Math.min(v, max)); + } + + private static String fmt(long v) { + return String.format("%,d", v); + } + + private static String pct(long part, long whole) { + if (whole <= 0) return "0%"; + return Math.round(part * 100.0 / whole) + "%"; + } +} diff --git a/src/backend/src/main/java/com/zioinfo/kintex/visitor/dto/VisitorDtos.java b/src/backend/src/main/java/com/zioinfo/kintex/visitor/dto/VisitorDtos.java new file mode 100644 index 0000000..e73b44d --- /dev/null +++ b/src/backend/src/main/java/com/zioinfo/kintex/visitor/dto/VisitorDtos.java @@ -0,0 +1,52 @@ +package com.zioinfo.kintex.visitor.dto; + +import java.util.List; + +/** + * M10 관람객·리드 응답 DTO 모음. + *

★ PII 불변(N2/R10): 리스트/상세 응답에는 마스킹 필드({@code nameMasked}·{@code phoneMasked}·{@code emailMasked})만 + * 존재하며 원문 name/phone/email 필드는 존재하지 않는다. 마스킹은 매퍼 SQL 표현식이 수행한다. + */ +public final class VisitorDtos { + + private VisitorDtos() { + } + + // ── SCR-30 등록 대시보드 요약 ── + public record VisitorSummary(List kpis, List trend, List types, List forms) { + } + + public record Kpi(String label, String value, String delta, String trend, Boolean ai, String sub) { + } + + public record TrendPoint(String label, long preReg, long checkIn) { + } + + public record TypeSlice(String name, int value, String color) { + } + + public record FormItem(String id, String name, boolean active, long count) { + } + + // ── SCR-30 등록자 목록(마스킹) ── + public record VisitorListItem(String id, String nameMasked, String type, String company, + String registeredAt, String checkin, boolean badgeIssued) { + } + + // ── SCR-32 리드(마스킹 + AI 저장값) ── + public record LeadItem(String id, String nameMasked, String role, String company, String product, + int interest, int score, String collectedAt, String phoneMasked, String emailMasked, + List aiReasons, List activity, String followupDraft, boolean aiGenerated) { + } + + public record Activity(String text, String at) { + } + + // ── 공개 사전등록 요청/결과 ── + public record RegisterRequest(String name, String phone, String email, String visitorType, + Boolean agreePrivacy, Boolean agreeMarketing) { + } + + public record RegisterResult(String registrationId, String badgeCode) { + } +} diff --git a/src/backend/src/main/resources/db/migration/V14__m6_docs_milestones.sql b/src/backend/src/main/resources/db/migration/V14__m6_docs_milestones.sql new file mode 100644 index 0000000..8f24292 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V14__m6_docs_milestones.sql @@ -0,0 +1,72 @@ +-- ===================================================================== +-- V14 M6 서류·마일스톤 (SCR-22/23) — event_milestone · required_document · document_review_issue +-- 멱등: CREATE TABLE IF NOT EXISTS + 시드 ON CONFLICT DO NOTHING. +-- 참조 컨벤션: event(id)·hall 등 snake_case, FK ON DELETE CASCADE. +-- 시드는 기존 실행사 e-2026-smf(2026 스마트팩토리 코리아, 개장 2026-08-11)를 참조. +-- V1~V13 불변 · 이 파일은 V14 단일 버전만 사용. +-- ===================================================================== + +-- 전시 마일스톤(행사별 표준 일정 노드) ----------------------------------- +CREATE TABLE IF NOT EXISTS event_milestone ( + id varchar(40) PRIMARY KEY, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + milestone_type varchar(30) NOT NULL, -- assignment|pre_review|utility|documents|opening + label varchar(80) NOT NULL, + due_date date, + state varchar(20) NOT NULL DEFAULT 'todo', -- done|active|todo + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (event_id, milestone_type) +); +CREATE INDEX IF NOT EXISTS idx_event_milestone_event ON event_milestone(event_id); + +-- 신고서류 체크리스트(행사별 필수 서류) ---------------------------------- +CREATE TABLE IF NOT EXISTS required_document ( + id varchar(40) PRIMARY KEY, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + doc_type varchar(60) NOT NULL, -- operation_plan|booth_layout|disaster_plan|... + name varchar(120) NOT NULL, + status varchar(20) NOT NULL DEFAULT 'pending', -- pending|draft|submitted|approved|rejected + due_date date, + sort_order integer NOT NULL DEFAULT 0, + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (event_id, doc_type) +); +CREATE INDEX IF NOT EXISTS idx_required_document_event ON required_document(event_id); + +-- AI 서류 검수 이슈(행사별 불일치/누락 경고) ----------------------------- +CREATE TABLE IF NOT EXISTS document_review_issue ( + id varchar(40) PRIMARY KEY, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + tone varchar(10) NOT NULL DEFAULT 'info', -- warn|info + title varchar(120) NOT NULL, + description varchar(400), + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_document_review_issue_event ON document_review_issue(event_id); + +-- ── 시드: e-2026-smf (Stitch SCR-22 정합) ────────────────────────────── +INSERT INTO event_milestone (id, event_id, milestone_type, label, due_date, state, sort_order) VALUES + ('ms-smf-assign', 'e-2026-smf', 'assignment', 'D-150 배정', DATE '2026-03-14', 'done', 0), + ('ms-smf-prereview','e-2026-smf', 'pre_review', 'D-30 사전협의', DATE '2026-07-12', 'done', 1), + ('ms-smf-utility', 'e-2026-smf', 'utility', 'D-25 유틸리티', DATE '2026-07-17', 'done', 2), + ('ms-smf-docs', 'e-2026-smf', 'documents', 'D-7 신고서류', DATE '2026-08-04', 'active', 3), + ('ms-smf-opening', 'e-2026-smf', 'opening', 'D-0 개장', DATE '2026-08-11', 'todo', 4) +ON CONFLICT (event_id, milestone_type) DO NOTHING; + +INSERT INTO required_document (id, event_id, doc_type, name, status, due_date, sort_order) VALUES + ('doc-smf-op', 'e-2026-smf', 'operation_plan', '행사운영계획서', 'approved', NULL, 0), + ('doc-smf-layout', 'e-2026-smf', 'booth_layout', '부스배치도', 'submitted', NULL, 1), + ('doc-smf-disaster','e-2026-smf', 'disaster_plan', '재해대처계획서', 'draft', DATE '2026-08-04', 2), + ('doc-smf-rigging', 'e-2026-smf', 'rigging_calc', '리깅 구조계산서', 'rejected', DATE '2026-08-04', 3), + ('doc-smf-fire', 'e-2026-smf', 'fire_safety', '방화관리 책임서약서', 'pending', DATE '2026-08-04', 4), + ('doc-smf-parking', 'e-2026-smf', 'parking', '주차관리 신청서', 'pending', DATE '2026-08-04', 5), + ('doc-smf-security','e-2026-smf', 'security', '보안요원 배치계획', 'pending', DATE '2026-08-04', 6), + ('doc-smf-hazmat', 'e-2026-smf', 'hazmat', '위험물 반입신고서', 'pending', DATE '2026-08-04', 7) +ON CONFLICT (event_id, doc_type) DO NOTHING; + +INSERT INTO document_review_issue (id, event_id, tone, title, description, sort_order) VALUES + ('dri-smf-1', 'e-2026-smf', 'warn', '데이터 불일치 감지', '부스배치도 부스 수 486 ≠ 운영계획서 510 (불일치)', 0), + ('dri-smf-2', 'e-2026-smf', 'info', '필수요소 누락', '재해대처계획서 필수요소 누락 2건', 1) +ON CONFLICT (id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V15__m8_dock_reservation.sql b/src/backend/src/main/resources/db/migration/V15__m8_dock_reservation.sql new file mode 100644 index 0000000..44ae8e2 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V15__m8_dock_reservation.sql @@ -0,0 +1,68 @@ +-- ===================================================================== +-- V15 M8 반입/반출 도크 슬롯 예약 (SCR-25) — dock · dock_reservation +-- 멱등: CREATE TABLE IF NOT EXISTS + 시드 ON CONFLICT DO NOTHING. +-- dock 은 하역장 물리 도크(홀 귀속), dock_reservation 은 행사·방향·일자·시간대 슬롯. +-- 슬롯 중복(같은 도크·일자·방향의 시간대 겹침)은 서비스에서 409 CONFLICT 판정. +-- 시드는 e-2026-smf(홀 H7, 반입일 2026-08-11 / 반출일 2026-08-15) 참조. +-- V1~V13 불변 · 이 파일은 V15 단일 버전만 사용. +-- ===================================================================== + +-- 하역장 도크 마스터(홀별) ---------------------------------------------- +CREATE TABLE IF NOT EXISTS dock ( + id varchar(40) PRIMARY KEY, + hall_id varchar(20) NOT NULL REFERENCES hall(id), + dock_no integer NOT NULL, + label varchar(40) NOT NULL, + heavy_priority boolean NOT NULL DEFAULT false, -- 중량물(5t↑) 우선 도크 + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (hall_id, dock_no) +); +CREATE INDEX IF NOT EXISTS idx_dock_hall ON dock(hall_id); + +-- 도크 예약(반입/반출 슬롯) --------------------------------------------- +CREATE TABLE IF NOT EXISTS dock_reservation ( + id varchar(40) PRIMARY KEY, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + dock_id varchar(40) NOT NULL REFERENCES dock(id), + direction varchar(4) NOT NULL, -- in|out + reserve_date date NOT NULL, + start_hour integer NOT NULL, -- 8..19 (그리드 08:00~19:00) + span integer NOT NULL DEFAULT 2, -- 점유 시간대 수 + company_name varchar(120), + vehicle_no varchar(30), + vehicle_weight varchar(20), + item varchar(200), + forklift boolean NOT NULL DEFAULT false, + tone varchar(10) NOT NULL DEFAULT 'booked', -- booked|priority + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_dock_reservation_lookup + ON dock_reservation(event_id, direction, reserve_date); + +-- ── 시드: 홀 H7 도크 6개 ─────────────────────────────────────────────── +INSERT INTO dock (id, hall_id, dock_no, label, heavy_priority) VALUES + ('dock-h7-1', 'H7', 1, '도크 1', true), + ('dock-h7-2', 'H7', 2, '도크 2', false), + ('dock-h7-3', 'H7', 3, '도크 3', false), + ('dock-h7-4', 'H7', 4, '도크 4', false), + ('dock-h7-5', 'H7', 5, '도크 5', false), + ('dock-h7-6', 'H7', 6, '도크 6', false) +ON CONFLICT (hall_id, dock_no) DO NOTHING; + +-- ── 시드: 반입(2026-08-11) 예약 (Stitch SCR-25 정합) ─────────────────── +INSERT INTO dock_reservation + (id, event_id, dock_id, direction, reserve_date, start_hour, span, company_name, vehicle_no, vehicle_weight, item, forklift, tone) VALUES + ('dr-smf-in-1', 'e-2026-smf', 'dock-h7-1', 'in', DATE '2026-08-11', 8, 2, '(주)공간디자인', '12가 3456', '5t', '전시 부스 자재', true, 'booked'), + ('dr-smf-in-2', 'e-2026-smf', 'dock-h7-1', 'in', DATE '2026-08-11', 11, 2, NULL, NULL, '5t', '중량물 우선(5t↑)', false, 'priority'), + ('dr-smf-in-3', 'e-2026-smf', 'dock-h7-2', 'in', DATE '2026-08-11', 12, 3, '글로벌부스테크', '34나 5678', '2.5t', '조립식 부스', false, 'booked'), + ('dr-smf-in-4', 'e-2026-smf', 'dock-h7-3', 'in', DATE '2026-08-11', 9, 2, '비욘드디자인', '56다 7890', '1t', '전시 소품', false, 'booked'), + ('dr-smf-in-5', 'e-2026-smf', 'dock-h7-5', 'in', DATE '2026-08-11', 16, 2, '네오로지스', '78라 1234', '5t', '전시 장비', true, 'booked') +ON CONFLICT (id) DO NOTHING; + +-- ── 시드: 반출(2026-08-15) 예약 ──────────────────────────────────────── +INSERT INTO dock_reservation + (id, event_id, dock_id, direction, reserve_date, start_hour, span, company_name, vehicle_no, vehicle_weight, item, forklift, tone) VALUES + ('dr-smf-out-1', 'e-2026-smf', 'dock-h7-1', 'out', DATE '2026-08-15', 14, 3, '(주)공간디자인', '12가 3456', '5t', '철거 자재', true, 'booked'), + ('dr-smf-out-2', 'e-2026-smf', 'dock-h7-3', 'out', DATE '2026-08-15', 15, 2, NULL, NULL, '5t', '중량물 우선(5t↑)', false, 'priority'), + ('dr-smf-out-3', 'e-2026-smf', 'dock-h7-4', 'out', DATE '2026-08-15', 17, 3, '네오로지스', '78라 1234', '11t 이상', '철거 장비', true, 'booked') +ON CONFLICT (id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V16__m15_auction.sql b/src/backend/src/main/resources/db/migration/V16__m15_auction.sql new file mode 100644 index 0000000..9da282d --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V16__m15_auction.sql @@ -0,0 +1,122 @@ +-- 킨텍스 — M15 공사/장치 옥션(역경매) 스키마 + 시드 (멱등) +-- PLANNING §5 M15: Auction 1─N Bid(=Quotation) ─ Award. 등록업체(company.registered)만 응찰(서버 게이트). +-- 봉인 입찰: 마감 전 경쟁 견적 비공개(서비스 레이어에서 강제 마스킹), 마감 후 발주자만 전체 공개. +-- 보안 불변: 본 마이그레이션은 V16 단독. V1~V13 및 타 팀 마이그레이션(V14/V15/V17~)을 변경하지 않는다. + +-- 옥션(공고) ------------------------------------------------------------------ +CREATE TABLE IF NOT EXISTS auction ( + id varchar(40) PRIMARY KEY, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + title varchar(200) NOT NULL, + category varchar(40) NOT NULL, -- 전시디자인설치|구조물 임대|전기/조명|영상/음향|네트워크 + auction_type varchar(20) NOT NULL DEFAULT 'reverse', -- reverse(역경매)|rfq + award_criteria varchar(20) NOT NULL DEFAULT 'comprehensive', -- lowest|comprehensive + weight_price integer NOT NULL DEFAULT 60, + weight_reputation integer NOT NULL DEFAULT 25, + weight_delivery integer NOT NULL DEFAULT 15, + round integer NOT NULL DEFAULT 1, + deadline timestamptz NOT NULL, + material_package_id varchar(40), -- M2~M5 AI 자료 패키지 소프트 참조 + materials varchar(120) NOT NULL DEFAULT '', -- 콤마구분: layout,design,boq,aiimage + accent varchar(20) NOT NULL DEFAULT 'primary', + created_by varchar(40), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_auction_event ON auction(event_id); + +-- 초대(등록업체만) — 미등록 업체는 초대·응찰 원천 차단 ------------------------- +CREATE TABLE IF NOT EXISTS auction_invite ( + id varchar(40) PRIMARY KEY, + auction_id varchar(40) NOT NULL REFERENCES auction(id) ON DELETE CASCADE, + company_id varchar(40) NOT NULL REFERENCES company(id), + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (auction_id, company_id) +); + +-- 응찰 = 견적서(Quotation). (auction,company) 당 1행 — 재응찰 시 버전 증가·갱신 -- +CREATE TABLE IF NOT EXISTS bid ( + id varchar(40) PRIMARY KEY, + auction_id varchar(40) NOT NULL REFERENCES auction(id) ON DELETE CASCADE, + company_id varchar(40) NOT NULL REFERENCES company(id), + bidder_user_id varchar(40), + round integer NOT NULL DEFAULT 1, + subtotal bigint NOT NULL DEFAULT 0, + vat bigint NOT NULL DEFAULT 0, + total bigint NOT NULL, + lead_days integer NOT NULL DEFAULT 0, + valid_until date, + terms text, + lines jsonb, -- 라인아이템(공종·자재·수량·단가) 스냅샷 + version integer NOT NULL DEFAULT 1, + status varchar(20) NOT NULL DEFAULT 'submitted', + submitted_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (auction_id, company_id) +); +CREATE INDEX IF NOT EXISTS idx_bid_auction ON bid(auction_id); + +-- 낙찰(Award) — 옥션당 1건. 감사 추적(사유·발주자·시각) -------------------------- +CREATE TABLE IF NOT EXISTS award ( + id varchar(40) PRIMARY KEY, + auction_id varchar(40) NOT NULL UNIQUE REFERENCES auction(id) ON DELETE CASCADE, + bid_id varchar(40) NOT NULL REFERENCES bid(id), + reason text NOT NULL, + awarded_by varchar(40), + awarded_at timestamptz NOT NULL DEFAULT now() +); + +-- 업체 평판(종합평가 스코어 입력) — 초기 규정준수 이력·자기신고, 축적 후 시공 평점 -- +CREATE TABLE IF NOT EXISTS company_reputation ( + company_id varchar(40) PRIMARY KEY REFERENCES company(id) ON DELETE CASCADE, + rating numeric(3,1) NOT NULL DEFAULT 4.0, -- 5점 만점 + jobs_done integer NOT NULL DEFAULT 0, + claim_rate numeric(5,2) NOT NULL DEFAULT 0 -- 클레임 발생률(%) +); + +-- ===== 시드(멱등) — 기존 event(e-2026-smf)·company(co-000x) 참조 ================= + +INSERT INTO company_reputation (company_id, rating, jobs_done, claim_rate) VALUES + ('co-0001', 4.8, 12, 0.0), + ('co-0002', 4.6, 8, 1.5), + ('co-0003', 4.2, 15, 3.0), + ('co-0004', 4.0, 5, 2.0) +ON CONFLICT (company_id) DO NOTHING; + +-- 옥션 3건: 진행중(종합)·진행중(최저가)·정산중(낙찰 완료) -------------------------- +INSERT INTO auction (id, event_id, title, category, auction_type, award_criteria, + weight_price, weight_reputation, weight_delivery, round, deadline, + material_package_id, materials, accent) VALUES + ('AUC-2026-0102', 'e-2026-smf', 'A-102 독립부스 시공', '전시디자인설치', 'reverse', 'comprehensive', + 60, 25, 15, 2, now() + interval '2 day', 'mp-a102', 'layout,design,boq,aiimage', 'primary'), + ('AUC-2026-0405', 'e-2026-smf', 'B-405 트러스 구조물 설치', '구조물 임대', 'reverse', 'lowest', + 100, 0, 0, 1, now() + interval '1 day', NULL, 'layout,boq', 'secondary'), + ('AUC-2026-0210', 'e-2026-smf', 'C-10 공용 통로 조명 공사', '전기/조명', 'reverse', 'comprehensive', + 50, 30, 20, 1, now() - interval '3 day', NULL, 'layout,boq', 'tertiary') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO auction_invite (id, auction_id, company_id) VALUES + ('ai-0102-1', 'AUC-2026-0102', 'co-0001'), + ('ai-0102-2', 'AUC-2026-0102', 'co-0002'), + ('ai-0102-3', 'AUC-2026-0102', 'co-0003'), + ('ai-0405-1', 'AUC-2026-0405', 'co-0002'), + ('ai-0405-2', 'AUC-2026-0405', 'co-0003'), + ('ai-0210-1', 'AUC-2026-0210', 'co-0003') +ON CONFLICT (auction_id, company_id) DO NOTHING; + +INSERT INTO bid (id, auction_id, company_id, round, subtotal, vat, total, lead_days) VALUES + -- AUC-0102 (진행중·종합) — 최저가 co-0001 8.4M + ('bid-0102-1', 'AUC-2026-0102', 'co-0001', 2, 8400000, 840000, 8400000, 12), + ('bid-0102-2', 'AUC-2026-0102', 'co-0002', 2, 8700000, 870000, 8700000, 10), + ('bid-0102-3', 'AUC-2026-0102', 'co-0003', 1, 8900000, 890000, 8900000, 14), + -- AUC-0405 (진행중·최저가) + ('bid-0405-1', 'AUC-2026-0405', 'co-0002', 1, 4250000, 425000, 4250000, 8), + ('bid-0405-2', 'AUC-2026-0405', 'co-0003', 1, 4600000, 460000, 4600000, 9), + -- AUC-0210 (정산중·낙찰) — 낙찰 co-0003 12M + ('bid-0210-1', 'AUC-2026-0210', 'co-0003', 1, 12000000, 1200000, 12000000, 20), + ('bid-0210-2', 'AUC-2026-0210', 'co-0001', 1, 12800000, 1280000, 12800000, 18) +ON CONFLICT (auction_id, company_id) DO NOTHING; + +INSERT INTO award (id, auction_id, bid_id, reason, awarded_by) VALUES + ('aw-0210', 'AUC-2026-0210', 'bid-0210-1', + '최저가 응찰 + 최근 3년 유사 규모 시공 실적 우수(종합평가 1위).', 'system-seed') +ON CONFLICT (auction_id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V17__m10_visitor_leads.sql b/src/backend/src/main/resources/db/migration/V17__m10_visitor_leads.sql new file mode 100644 index 0000000..dd63da6 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V17__m10_visitor_leads.sql @@ -0,0 +1,91 @@ +-- V17: M10 관람객 등록 · 리드 (사전등록·배지·리드 스코어링) +-- 멱등(IF NOT EXISTS / ON CONFLICT DO NOTHING). 기존 마이그레이션(V1~V13) 불변. +-- ★ 개인정보(name/phone/email)는 원문 저장하되 API 응답에서는 매퍼가 마스킹만 노출한다(N2/R10). +-- 원문 컬럼은 조회 SQL 의 SELECT 목록에 절대 포함하지 않는다(마스킹 표현식만 반환). + +-- ── 관람객 사전등록 (SCR-30) ──────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS visitor_registration ( + id varchar(40) PRIMARY KEY, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + name varchar(120) NOT NULL, -- 원문(응답 금지 · 마스킹만 노출) + phone varchar(40), -- 원문(응답 금지) + email varchar(200), -- 원문(응답 금지) + company varchar(200), + visitor_type varchar(20) NOT NULL DEFAULT 'visitor', -- visitor|buyer|vip + agree_privacy boolean NOT NULL DEFAULT false, + agree_marketing boolean NOT NULL DEFAULT false, + badge_code varchar(40) NOT NULL, -- 배지/QR 코드(민감정보 아님) + badge_issued boolean NOT NULL DEFAULT false, + checkin_state varchar(20) NOT NULL DEFAULT 'waiting', -- done|waiting|cancelled + registered_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now() +); +-- 이메일 중복 등록 방지(행사 단위) — register 재요청 시 기존 등록 재반환 근거. +CREATE UNIQUE INDEX IF NOT EXISTS uq_visitor_reg_event_email + ON visitor_registration (event_id, lower(email)) WHERE email IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_visitor_reg_event ON visitor_registration (event_id); +CREATE INDEX IF NOT EXISTS idx_visitor_reg_regdate ON visitor_registration (event_id, registered_at); + +-- ── 리드 (SCR-32, 참가업체·부스 방문 리드) ────────────────────────────────── +CREATE TABLE IF NOT EXISTS lead ( + id varchar(40) PRIMARY KEY, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + booth_id varchar(40), -- 부스 스코프(선택 · booth 시드 전엔 NULL 허용) + name varchar(120) NOT NULL, -- 원문(응답 금지) + phone varchar(40), -- 원문(응답 금지) + email varchar(200), -- 원문(응답 금지) + role varchar(120), + company varchar(200), + product varchar(200), + interest integer NOT NULL DEFAULT 3, -- 1..5 관심도 + score integer NOT NULL DEFAULT 0, -- 0..100 AI 저장 스코어 + ai_reasons jsonb NOT NULL DEFAULT '[]'::jsonb, + activity jsonb NOT NULL DEFAULT '[]'::jsonb, + followup_draft text, + ai_generated boolean NOT NULL DEFAULT true, + collected_at timestamptz NOT NULL DEFAULT now(), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_lead_event ON lead (event_id); +CREATE INDEX IF NOT EXISTS idx_lead_event_booth ON lead (event_id, booth_id); +CREATE INDEX IF NOT EXISTS idx_lead_score ON lead (event_id, score DESC); + +-- ── 시드(기존 event 참조 · 멱등) ──────────────────────────────────────────── +-- 관람객 사전등록 데모(원문은 데모용 가명 — 마스킹 표현식 검증 목적). +INSERT INTO visitor_registration + (id, event_id, name, phone, email, company, visitor_type, agree_privacy, agree_marketing, badge_code, badge_issued, checkin_state, registered_at) +VALUES + ('vr-smf-001','e-2026-smf','김서희','010-1234-9910','seohee.kim@lg.com','LG전자','visitor',true,true,'KTX-V-0001',true,'done', TIMESTAMPTZ '2026-08-01 09:10+09'), + ('vr-smf-002','e-2026-smf','이도준','010-2233-1188','dojun.lee@samsung.com','삼성전자','buyer',true,true,'KTX-V-0002',true,'waiting',TIMESTAMPTZ '2026-08-03 10:20+09'), + ('vr-smf-003','e-2026-smf','박민서','010-8890-4412','minseo.park@hyundai.com','현대자동차','vip',true,false,'KTX-V-0003',true,'done', TIMESTAMPTZ '2026-08-04 11:05+09'), + ('vr-smf-004','e-2026-smf','최상훈','010-5566-2030','sanghoon.choi@indiv.kr',NULL,'visitor',true,false,'KTX-V-0004',false,'cancelled',TIMESTAMPTZ '2026-08-04 14:40+09'), + ('vr-smf-005','e-2026-smf','정유아','010-7712-6654','yua.jung@future-tech.co.kr','(주)미래기술','buyer',true,true,'KTX-V-0005',true,'done',TIMESTAMPTZ '2026-08-05 09:55+09'), + ('vr-smf-006','e-2026-smf','한지수','010-3341-7788','jisu.han@doosan.com','두산로보틱스','visitor',true,true,'KTX-V-0006',true,'waiting',TIMESTAMPTZ '2026-08-05 15:30+09'), + ('vr-smf-007','e-2026-smf','오세연','010-9922-1043','seyeon.oh@nextinno.com','넥스트이노베이션','buyer',true,true,'KTX-V-0007',true,'done',TIMESTAMPTZ '2026-08-06 09:15+09'), + ('vr-smf-008','e-2026-smf','신재원','010-6604-3399','jaewon.shin@globaltech.io','글로벌테크','visitor',true,false,'KTX-V-0008',false,'waiting',TIMESTAMPTZ '2026-08-06 13:12+09') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO lead + (id, event_id, booth_id, name, phone, email, role, company, product, interest, score, ai_reasons, activity, followup_draft, ai_generated, collected_at) +VALUES + ('ld-smf-001','e-2026-smf',NULL,'김태훈','010-1120-3021','taehoon.kim@techsol.co.kr','구매 담당자','(주)테크솔루션','협동로봇 HR-A1',5,87, + '["과거 유사 전시회 로봇 구매 이력 있음","당사 부스 체류 시간 평균 대비 240% 높음","기술 사양서(PDF) 다운로드 2회 기록"]'::jsonb, + '[{"text":"제품 카탈로그 QR 스캔","at":"오늘 14:22"},{"text":"부스 내 HR-A1 구동 시연 참관","at":"오늘 14:15"}]'::jsonb, + '안녕하세요, (주)테크솔루션 김 팀장님. KINTEX 부스에 방문해주셔서 감사합니다. 관심을 보이셨던 협동로봇 HR-A1의 상세 기술 사양과 커스텀 제안서를 준비했습니다. 다음 주 중 짧은 미팅이 가능하실까요?',true, TIMESTAMPTZ '2026-08-11 14:22+09'), + ('ld-smf-002','e-2026-smf',NULL,'이하늘','010-3300-7742','haneul.lee@nextinno.com','기술 기획','넥스트이노베이션','스마트 물류 시스템',3,65, + '["물류 자동화 세션 참석 이력","부스 체류 시간 평균 수준"]'::jsonb, + '[{"text":"스마트 물류 데모 영상 시청","at":"오늘 14:15"},{"text":"브로슈어 다운로드","at":"오늘 14:03"}]'::jsonb, + '안녕하세요, 넥스트이노베이션 이 님. 스마트 물류 시스템에 관심 가져주셔서 감사합니다. 도입 사례집과 ROI 시뮬레이션 자료를 보내드립니다.',true, TIMESTAMPTZ '2026-08-11 14:15+09'), + ('ld-smf-003','e-2026-smf',NULL,'박연구','010-4400-1188','park@fmlab.re.kr','연구소장','미래제조연구소','AI 비전 검사 모듈',4,82, + '["AI 비전 검사 데모 3회 재방문","구매 결정권자(연구소장) 직급","견적 요청 폼 작성 완료"]'::jsonb, + '[{"text":"견적 요청 폼 제출","at":"오늘 14:02"},{"text":"AI 비전 모듈 상세 상담","at":"오늘 13:50"}]'::jsonb, + '안녕하세요, 미래제조연구소 박 소장님. 요청하신 AI 비전 검사 모듈 견적서를 첨부드립니다. 파일럿 도입 프로그램도 안내드리겠습니다.',true, TIMESTAMPTZ '2026-08-11 14:02+09'), + ('ld-smf-004','e-2026-smf',NULL,'정설비','010-5500-9003','jung@globaltech.io','설비 담당','글로벌테크','자율주행 AMR',2,44, + '["AMR 부스 단순 방문","자료 열람 이력 없음"]'::jsonb, + '[{"text":"부스 QR 스캔","at":"오늘 13:55"}]'::jsonb, + '안녕하세요, 글로벌테크 정 님. 자율주행 AMR 소개 자료를 보내드립니다. 궁금하신 점이 있으시면 언제든 문의해 주세요.',true, TIMESTAMPTZ '2026-08-11 13:55+09'), + ('ld-smf-005','e-2026-smf',NULL,'강엔지','010-6600-2250','kang@sctsys.kr','인프라 엔지니어','SCT시스템','클라우드 모니터링',1,28, + '["짧은 부스 체류","경쟁사 부스 위주 관람"]'::jsonb, + '[{"text":"부스 QR 스캔","at":"오늘 13:48"}]'::jsonb, + '안녕하세요, SCT시스템 강 님. 클라우드 모니터링 솔루션 소개서를 보내드립니다.',true, TIMESTAMPTZ '2026-08-11 13:48+09') +ON CONFLICT (id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V18__m12_campaign_sponsorship.sql b/src/backend/src/main/resources/db/migration/V18__m12_campaign_sponsorship.sql new file mode 100644 index 0000000..fd16299 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V18__m12_campaign_sponsorship.sql @@ -0,0 +1,76 @@ +-- V18: M12 EDM·캠페인 · 스폰서십 패키지/판매 +-- 멱등(IF NOT EXISTS / ON CONFLICT DO NOTHING). 기존 마이그레이션 불변. +-- 실 발송은 미구현 — 캠페인은 status 전이만(발송 게이트웨이 연동은 후속 갭). + +-- ── EDM·캠페인 (SCR-33) ───────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS edm_campaign ( + id varchar(40) PRIMARY KEY, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + name varchar(300) NOT NULL, + status varchar(20) NOT NULL DEFAULT 'draft', -- draft|scheduled|sending|done + audience integer NOT NULL DEFAULT 0, + meta varchar(300), -- 표시용 부가정보(최종수정·발송예정 등) + open_rate varchar(40) NOT NULL DEFAULT '-', + click_rate varchar(40) NOT NULL DEFAULT '-', + scheduled_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_edm_campaign_event ON edm_campaign (event_id, created_at DESC); + +-- ── 스폰서십 패키지 (SCR-34, F070) ────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS sponsorship_package ( + id varchar(40) PRIMARY KEY, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + code varchar(30) NOT NULL, -- diamond|gold|silver + name varchar(60) NOT NULL, -- DIAMOND/GOLD/SILVER + name_ko varchar(60) NOT NULL, + price bigint NOT NULL DEFAULT 0, -- KRW + accent varchar(20), + benefits jsonb NOT NULL DEFAULT '[]'::jsonb, + total_qty integer NOT NULL DEFAULT 0, + sold_qty integer NOT NULL DEFAULT 0, + dday integer, + sort_order integer NOT NULL DEFAULT 0, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_sponsorship_pkg_event ON sponsorship_package (event_id, sort_order); + +-- ── 스폰서 계약/판매 (SCR-34) ────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS sponsorship_sponsor ( + id varchar(40) PRIMARY KEY, + event_id varchar(40) NOT NULL REFERENCES event(id) ON DELETE CASCADE, + package_id varchar(40) REFERENCES sponsorship_package(id) ON DELETE SET NULL, + name varchar(200) NOT NULL, -- 기업명(민감정보 아님) + tier_label varchar(80), + contract_status varchar(20) NOT NULL DEFAULT 'pending', -- signed|pending + fulfillment jsonb NOT NULL DEFAULT '[]'::jsonb, + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_sponsorship_sponsor_event ON sponsorship_sponsor (event_id); + +-- ── 시드(기존 event 참조 · 멱등) ──────────────────────────────────────────── +INSERT INTO edm_campaign (id, event_id, name, status, audience, meta, open_rate, click_rate) VALUES + ('cp-smf-001','e-2026-smf','2026 스마트팩토리 사전등록 안내','draft', 5200, '최종 수정: 2시간 전', '-', '-'), + ('cp-smf-002','e-2026-smf','VIP 바이어 네트워킹 데이 초대권','scheduled', 850, '발송 예정: 2026.08.05 10:00', '24% (예상)','5% (예상)'), + ('cp-smf-003','e-2026-smf','글로벌 테크 트렌드 리포트 (Vol. 12)','sending', 12000,'진행률: 68% (8,160건 완료)', '42.5%', '11.2%'), + ('cp-smf-004','e-2026-smf','KINTEX 개막 D-7 프로그램 안내','done', 45000, '완료일: 2026.08.04', '31.8%', '2.4%') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO sponsorship_package (id, event_id, code, name, name_ko, price, accent, benefits, total_qty, sold_qty, dday, sort_order) VALUES + ('sp-smf-diamond','e-2026-smf','diamond','DIAMOND','다이아몬드',50000000,'#B8860B', + '["메인 로고 노출 (온·오프라인)","전시 부스 100㎡ (최우선 배정)","연사 세션 2회 부여"]'::jsonb, 2, 1, 12, 1), + ('sp-smf-gold','e-2026-smf','gold','GOLD','골드',30000000,'#667085', + '["서브 로고 노출","전시 부스 50㎡","연사 세션 1회"]'::jsonb, 3, 3, NULL, 2), + ('sp-smf-silver','e-2026-smf','silver','SILVER','실버',15000000,'#8B4513', + '["일반 로고 노출","전시 부스 20㎡"]'::jsonb, 5, 2, NULL, 3) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO sponsorship_sponsor (id, event_id, package_id, name, tier_label, contract_status, fulfillment) VALUES + ('ss-smf-001','e-2026-smf','sp-smf-diamond','(주)테크솔루션','다이아몬드 패키지','signed', + '[{"id":"f1","label":"공식 홈페이지 로고 노출","done":true},{"id":"f2","label":"전시 홀 부스 배정 (A-101)","done":true},{"id":"f3","label":"연사 세션 주제 선정","done":false},{"id":"f4","label":"브로슈어 광고 인쇄","done":false}]'::jsonb), + ('ss-smf-002','e-2026-smf','sp-smf-gold','글로벌바이오','골드 패키지','pending', + '[{"id":"f1","label":"공식 홈페이지 로고 노출","done":true},{"id":"f2","label":"전시 홀 부스 배정","done":false}]'::jsonb), + ('ss-smf-003','e-2026-smf','sp-smf-silver','넥스트인더스트리','실버 패키지','signed', + '[{"id":"f1","label":"공식 홈페이지 로고 노출","done":true},{"id":"f2","label":"전시 홀 부스 배정 (C-210)","done":true}]'::jsonb) +ON CONFLICT (id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V19__m17_cms.sql b/src/backend/src/main/resources/db/migration/V19__m17_cms.sql new file mode 100644 index 0000000..5e206cf --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V19__m17_cms.sql @@ -0,0 +1,90 @@ +-- V19: M17 CMS — 콘텐츠·게시 워크플로 · 다국어 번역 · 참가업체 마이크로사이트 (멱등 · V1~V13 불변) +-- 근거: _workspace/port_cms.md(화면 계약) + 할 일(엔드포인트 계약). +-- cms_content : 헤드리스 콘텐츠(페이지/포스트/블록) + 게시 상태(draft→review→approved→published) + 예약게시. +-- cms_translation : 콘텐츠×언어(ko/en/zh/ja) 번역본 + 번역 상태(none/ai/reviewed). +-- microsite : 참가업체별 공개 소개 페이지(섹션 JSONB·테마·SEO) — 관람객 공개 조회(/api/public/microsites). +-- 신규 테이블만 추가(기존 스키마 무영향). 모든 DDL/시드 멱등. + +-- ── 콘텐츠 ─────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS cms_content ( + id varchar(40) PRIMARY KEY, + event_id varchar(40), -- 행사 스코프(선택) + content_type varchar(20) NOT NULL DEFAULT 'PAGE', -- PAGE|POST|NOTICE|BLOCK + title varchar(300) NOT NULL, + body text, -- 블록/본문(JSON 또는 텍스트) + status varchar(20) NOT NULL DEFAULT 'draft', -- draft|review|approved|published + lang varchar(5) NOT NULL DEFAULT 'ko', -- 원천(소스) 언어 + scheduled_at timestamptz, -- 예약 게시 시각(NULL=즉시/미예약) + signage boolean NOT NULL DEFAULT false, -- 현장 사이니지 동시 배포 + mailing boolean NOT NULL DEFAULT false, -- 메일링 통보 + author_id varchar(40), + author_name varchar(120), + published_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_cms_content_event ON cms_content(event_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_cms_content_status ON cms_content(status); + +-- ── 번역(콘텐츠×언어) ──────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS cms_translation ( + id varchar(40) PRIMARY KEY, + content_id varchar(40) NOT NULL REFERENCES cms_content(id) ON DELETE CASCADE, + lang varchar(5) NOT NULL, -- ko|en|zh|ja + title varchar(300), + body text, + trans_status varchar(10) NOT NULL DEFAULT 'none', -- none(미번역)|ai(AI 초벌)|reviewed(검수완료) + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (content_id, lang) +); +CREATE INDEX IF NOT EXISTS idx_cms_translation_content ON cms_translation(content_id); + +-- ── 마이크로사이트(참가업체별) ─────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS microsite ( + exhibitor_id varchar(40) PRIMARY KEY, -- 참가업체 식별자(company/booth 스코프) + event_id varchar(40), + slug varchar(80) UNIQUE, -- 공개 URL 서브도메인 슬러그 + exhibitor_name varchar(200), + theme varchar(20) NOT NULL DEFAULT 'blue', + sections jsonb NOT NULL DEFAULT '[]'::jsonb, -- [{id,type,visible,order,payload}] + seo_title varchar(300), + seo_meta text, + langs varchar(40) NOT NULL DEFAULT 'ko', -- 지원 언어 CSV + status varchar(20) NOT NULL DEFAULT 'draft', -- draft|published + published_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_microsite_event ON microsite(event_id); + +-- ── 시드(멱등: ON CONFLICT DO NOTHING) ─────────────────────────────────────── +INSERT INTO cms_content (id, event_id, content_type, title, body, status, lang, scheduled_at, signage, mailing, author_name) +VALUES + ('cms-home', NULL, 'PAGE', '페이지', '전시 공식 홈 페이지 본문', 'published', 'ko', NULL, true, false, '관리자'), + ('cms-notice', NULL, 'NOTICE', '공지사항', '반입·반출 안내 공지', 'draft', 'ko', NULL, false, false, '관리자'), + ('cms-about', NULL, 'PAGE', '행사 소개', 'KINTEX 스마트 팩토리 엑스포 소개', 'review', 'ko', '2026-08-01 09:00+09', false, false, '관리자'), + ('cms-speaker',NULL, 'POST', '연사 정보', '주요 연사 및 세션 안내', 'published', 'ko', NULL, false, true, '시스템'), + ('cms-faq', NULL, 'PAGE', 'FAQ', '자주 묻는 질문', 'published', 'ko', NULL, false, false, '관리자') +ON CONFLICT (id) DO NOTHING; + +INSERT INTO cms_translation (id, content_id, lang, title, body, trans_status) +VALUES + ('tr-about-ko', 'cms-about', 'ko', '행사 소개', 'KINTEX 스마트 팩토리 엑스포 소개', 'reviewed'), + ('tr-about-en', 'cms-about', 'en', 'Exhibition Overview', 'Overview of KINTEX Smart Factory Expo.', 'ai'), + ('tr-about-zh', 'cms-about', 'zh', '展会介绍', '', 'none'), + ('tr-about-ja', 'cms-about', 'ja', 'イベント紹介', '', 'none'), + ('tr-home-ko', 'cms-home', 'ko', '페이지', '전시 공식 홈 페이지 본문', 'reviewed'), + ('tr-home-en', 'cms-home', 'en', 'KINTEX Global Smart Tech Expo 2026', 'Official home page of the exhibition.', 'reviewed'), + ('tr-notice-ko','cms-notice','ko', '공지사항', '반입·반출 안내 공지', 'reviewed'), + ('tr-notice-en','cms-notice','en', '', '', 'none') +ON CONFLICT (content_id, lang) DO NOTHING; + +INSERT INTO microsite (exhibitor_id, event_id, slug, exhibitor_name, theme, sections, seo_title, seo_meta, langs, status) +VALUES ( + 'hanbit', NULL, 'hanbit', '(주)한빛로보틱스', 'blue', + '[{"id":"intro","type":"intro","visible":true,"order":0,"payload":{"headline":"지능형 로보틱스의 새로운 지평을 열다","desc":"산업용 로봇부터 서비스 자동화 솔루션까지, 한빛로보틱스가 제시하는 미래 모빌리티 생태계를 경험하세요."}},{"id":"products","type":"products","visible":true,"order":1,"payload":{"items":[{"name":"HR-A1 협동로봇","tag":"NEW","desc":"고정밀 센서·AI 비전 시스템 탑재 다목적 협동로봇."},{"name":"ServiBot Elite","tag":"POPULAR","desc":"전시장·호텔용 지능형 서비스 로봇."}]}},{"id":"gallery","type":"gallery","visible":true,"order":2,"payload":{"images":[]}},{"id":"contact","type":"contact","visible":true,"order":3,"payload":{"email":"","phone":""}}]'::jsonb, + '한빛로보틱스 | KINTEX AI 전시', + 'KINTEX AI EXPO (주)한빛로보틱스 공식 마이크로사이트 — 최첨단 협동로봇·서비스 로봇 솔루션을 만나보세요.', + 'ko,en', 'published' +) +ON CONFLICT (exhibitor_id) DO NOTHING; diff --git a/src/backend/src/main/resources/db/migration/V20__public_site.sql b/src/backend/src/main/resources/db/migration/V20__public_site.sql new file mode 100644 index 0000000..0d94cf1 --- /dev/null +++ b/src/backend/src/main/resources/db/migration/V20__public_site.sql @@ -0,0 +1,37 @@ +-- V20: 공개 홍보 사이트(M12) — 참가/부스 신청 문의 접수 테이블. +-- 근거: _workspace/port_public.md G6(참가 문의 리드) + POST /api/public/inquiries. +-- 범위: exhibit_inquiry 신규 테이블만(멱등) + 최소 시드. 기존 테이블/컬럼 불변. +-- 공개 영역: 비인증 접수. PII(email/phone)는 저장하되 공개 응답에는 노출하지 않는다(접수번호만 반환). + +-- ── 참가/부스 신청 문의(리드) ────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS exhibit_inquiry ( + id varchar(40) PRIMARY KEY, + receipt_no varchar(30) NOT NULL UNIQUE, -- 공개 접수번호(KTX-INQ-YYYYMMDD-XXXX) + inquiry_type varchar(30), -- shell | raw | premium | general(부스 유형/문의 성격) + company varchar(200), + contact_name varchar(120), + email varchar(200), -- 접수 확인용(공개 응답 미노출) + phone varchar(40), -- (공개 응답 미노출) + scale varchar(60), -- 예상 규모 + hall_pref varchar(40), -- 희망 홀 + message text, + agree_privacy boolean NOT NULL DEFAULT false, + agree_marketing boolean NOT NULL DEFAULT false, + status varchar(20) NOT NULL DEFAULT 'received', -- received | in_review | closed + created_at timestamptz NOT NULL DEFAULT now() +); + +-- 스팸/rate 제어(동일 이메일 1분 1건) + 최근순 조회 가속 +CREATE INDEX IF NOT EXISTS idx_exhibit_inquiry_email_created ON exhibit_inquiry (email, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_exhibit_inquiry_created ON exhibit_inquiry (created_at DESC); + +-- ── 최소 시드(멱등) — 관리 백오피스 목록 초기 표시용 데모 1건 ────────────── +INSERT INTO exhibit_inquiry + (id, receipt_no, inquiry_type, company, contact_name, email, phone, scale, hall_pref, message, + agree_privacy, agree_marketing, status, created_at) +VALUES + ('inq-seed-0001', 'KTX-INQ-20260101-0001', 'shell', '(주)데모컴퍼니', '홍길동', + 'demo@example.com', '010-0000-0000', '2-4개 부스 (18-36sqm)', 'Hall 1', + '스마트팩토리 전시 참가를 검토 중입니다. 부스 배치와 요금 안내 부탁드립니다.', + true, false, 'received', now() - interval '30 days') +ON CONFLICT (id) DO NOTHING; diff --git a/src/frontend/src/screens/admin/AuditLogPage.tsx b/src/frontend/src/screens/admin/AuditLogPage.tsx index 6bf4131..5065226 100644 --- a/src/frontend/src/screens/admin/AuditLogPage.tsx +++ b/src/frontend/src/screens/admin/AuditLogPage.tsx @@ -81,10 +81,10 @@ export function AuditLogPage() { {/* 필터 바 */}

-
-
+
-
+
v.version === selected) ?? VERSIONS[0]; + const listQ = useQuery({ + queryKey: ['admin-rulesets'], + queryFn: () => rulesetApi.list(), + retry: false, + }); + + const versions = listQ.data ?? []; + const [selected, setSelected] = useState(null); + + // 목록 로드 시 최초 파일 자동 선택(사용자 선택이 우선). + useEffect(() => { + if (!selected && versions.length > 0) { + setSelected(versions[0].name); + } + }, [versions, selected]); + + const activeName = selected ?? versions[0]?.name ?? null; + const current = versions.find((v) => v.name === activeName) ?? null; + + const detailQ = useQuery({ + queryKey: ['admin-ruleset', activeName], + queryFn: () => rulesetApi.detail(activeName as string), + enabled: !!activeName, + retry: false, + }); + + const rules: RulesetRule[] = detailQ.data?.rules ?? []; return (
@@ -70,95 +48,175 @@ export function RulesetVersionsPage() {
- 샘플 · 읽기 전용 - 룰셋 조회/버전 관리 REST API가 아직 없어 화면은 현재 배포 룰셋(compliance-v1.json)의 정적 스냅샷을 - 표시합니다. 편집·버전 생성은 비활성입니다. + 읽기 전용 + 룰셋은 배포 리소스(rulesets/*.json)로 버전 관리됩니다. 편집·버전 생성은 규정 개정 시 리소스 교체로 + 수행되며 화면에서는 조회만 가능합니다.
-
- {/* 좌: 버전 히스토리 */} - + {listQ.isLoading && } + {listQ.isError && ( + listQ.refetch()} /> + )} - {/* 우: 규칙 목록 */} -
-
-
-

{current.version} 규칙 ({current.rules.length}개)

-

발효일 {current.effectiveDate}

+ {!listQ.isLoading && !listQ.isError && versions.length === 0 && ( + + )} + + {versions.length > 0 && ( +
+ {/* 좌: 버전 히스토리 */} +
-
- - - - - - - - - - - {current.rules.map((r) => ( - - - - - - - ))} - -
규칙명수치/설정값논리 / 요구사항위반 수준
- {r.label} - {r.code} - {r.value}{r.logic} - - {r.severity === 'block' ? '차단' : '경고'} - -
-
-

- 본 룰셋은 사전 필터이며 최종 승인은 킨텍스 및 구조기술사의 판단에 따릅니다. -

-
-
+
    + {versions.map((v) => ( +
  • + +
  • + ))} +
+ + + {/* 우: 규칙 목록 */} +
+
+
+

+ {current?.version ?? activeName} 규칙 ({rules.length}개) +

+

+ {detailQ.data?.effectiveDate + ? `발효일 ${detailQ.data.effectiveDate}` + : current?.name} +

+
+
+ + {detailQ.isLoading && } + {detailQ.isError && ( + detailQ.refetch()} /> + )} + + {!detailQ.isLoading && !detailQ.isError && rules.length === 0 && ( + + )} + + {rules.length > 0 && ( +
+ + + + + + + + + + + {rules.map((r) => ( + + + + + + + ))} + +
규칙명수치/설정값논리 / 요구사항위반 수준
+ {r.label ?? r.code} + {r.code} + {formatValue(r)}{formatLogic(r)} + + {r.severity === 'block' ? '차단' : '경고'} + +
+
+ )} + + {detailQ.data?.disclaimer && ( +

{detailQ.data.disclaimer}

+ )} +
+
+ )}
); } -function VersionBadge({ status }: { status: RulesetVersion['status'] }) { - const map = { - current: { cls: 'success', label: '현재 발효' }, - expired: { cls: 'neutral', label: '만료' }, - draft: { cls: 'warn', label: '초안' }, - } as const; - const m = map[status]; - return {m.label}; +function ModuleDistribution({ dist }: { dist: Record }) { + const entries = Object.entries(dist); + if (entries.length === 0) return null; + return ( + + {entries.map(([m, n]) => `${m} ${n}`).join(' · ')} + + ); +} + +/** 원문 규칙에서 표시용 수치/설정값을 유도한다(operator + threshold/min/max/unit). */ +function formatValue(r: RulesetRule): string { + const unit = r.unit ?? ''; + switch (r.operator) { + case 'lte': + return `≤ ${fmtNum(r.threshold)}${unit}`; + case 'gte': + return `≥ ${fmtNum(r.threshold)}${unit}`; + case 'eq': + return `= ${fmtNum(r.threshold)}${unit}`; + case 'between': + return `${fmtNum(r.min)}–${fmtNum(r.max)}${unit}`; + case 'isTrue': + return '필수'; + case 'excludesAll': + return (r.forbidden ?? []).join(' · '); + case 'lteHall': + return r.hallLimits + ? Object.entries(r.hallLimits) + .map(([h, v]) => `${h} ${v}`) + .join(' · ') + unit + : `홀별 ${unit}`; + default: + return r.threshold != null ? `${fmtNum(r.threshold)}${unit}` : '—'; + } +} + +/** 원문 규칙에서 논리/요구사항 설명을 유도한다(metric·requiresDocument·note). */ +function formatLogic(r: RulesetRule): string { + const parts: string[] = []; + if (r.metric) parts.push(r.metric); + if (r.requiresDocument) parts.push(`문서 필요: ${r.requiresDocument}`); + if (r.note) parts.push(r.note); + return parts.length > 0 ? parts.join(' · ') : (r.group ?? '—'); +} + +function fmtNum(n: number | null | undefined): string { + return n == null ? '—' : String(n); +} + +function RulesetSkeleton() { + return ( + + ); } diff --git a/src/frontend/src/screens/admin/SystemSettingsPage.tsx b/src/frontend/src/screens/admin/SystemSettingsPage.tsx index 84346dc..0f322b5 100644 --- a/src/frontend/src/screens/admin/SystemSettingsPage.tsx +++ b/src/frontend/src/screens/admin/SystemSettingsPage.tsx @@ -186,7 +186,7 @@ function SettingRowItem({ ) : ( <>

설정 추가 / 재정의

-
-
{/* 중: 캔버스 프리뷰 */} @@ -183,28 +256,28 @@ export function MicrositeBuilderPage() {
- {/* 마이크로사이트 nav */}
- H - (주)한빛로보틱스 + {brand.replace(/[^가-힣A-Za-z]/g, '').charAt(0) || 'H'} + {brand}
- {/* Hero */}
KINTEX AI EXPO -

지능형 로보틱스의 새로운 지평을 열다

+

+ {(sections.find((s) => s.type === 'intro')?.payload?.headline as string) ?? + '지능형 로보틱스의 새로운 지평을 열다'} +

- 산업용 로봇부터 서비스 자동화 솔루션까지, 한빛로보틱스가 제시하는 미래 모빌리티 - 생태계를 경험하세요. + {(sections.find((s) => s.type === 'intro')?.payload?.desc as string) ?? + '산업용 로봇부터 서비스 자동화 솔루션까지, 미래 모빌리티 생태계를 경험하세요.'}

미팅 예약하기 @@ -213,61 +286,59 @@ export function MicrositeBuilderPage() {
- {/* 제품 그리드 */} -
-
- -

주요 혁신 제품

-
-
-
-
- - NEW -
-
-
HR-A1 협동로봇
-

- 고정밀 센서·AI 비전 시스템 탑재 다목적 협동로봇으로 안전한 협업 환경 제공. -

-
-
-
-
- - POPULAR -
-
-
ServiBot Elite
-

- 전시장·호텔용 지능형 서비스 로봇 — 다국어 응대·자율 주행 안내. -

-
-
-
-
+ {sections.some((s) => s.visible && s.type === 'products') && ( +
+
+ +

주요 혁신 제품

+
+
+
+
+ + NEW +
+
+
HR-A1 협동로봇
+

고정밀 센서·AI 비전 시스템 탑재 다목적 협동로봇.

+
+
+
+
+ + POPULAR +
+
+
ServiBot Elite
+

전시장·호텔용 지능형 서비스 로봇.

+
+
+
+
+ )} - {/* AI 예상샷 갤러리 */} -
-
- -

AI 부스 예상 시뮬레이션

-
-
-
- - - AI 생성 예상 - + {sections.some((s) => s.visible && s.type === 'gallery') && ( +
+
+ +

AI 부스 예상 시뮬레이션

-
- - - AI 생성 예상 - +
+
+ + + AI 생성 예상 + +
+
+ + + AI 생성 예상 + +
-
-
+
+ )}
@@ -277,14 +348,15 @@ export function MicrositeBuilderPage() {
사이트 상태 - - 게시됨 + + {site?.status === 'published' ? '게시됨' : '초안'}
- 도메인 URL + 공개 URL
- hanbit.expo.kintex.kr -
@@ -294,19 +366,11 @@ export function MicrositeBuilderPage() { SEO · 검색 엔진