commit b519efe62933d1735ade985e69fb2c2c75298394 Author: GUARDiA Date: Sat Jun 13 20:23:46 2026 +0900 feat: GUARDiA Mall v1.0 — 옴니채널 꽃집 e-커머스 (백엔드 8011 + 프론트 + 모바일 고객/관리자앱) + CI/CD diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..3ec3675 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,59 @@ +// GUARDiA Mall — CI/CD Pipeline (Jenkins, 보조) +// 주 배포는 Gitea webhook → deploy_server.py(9999). Jenkins는 검증/롤백 백업 경로. +// 저장소 구조: backend/, frontend/ 가 루트에 위치 (단일 jar — frontend가 backend static으로 번들됨) +pipeline { + agent any + environment { + MALL_HOME = '/opt/guardia-mall' + JAVA_HOME = '/usr/lib/jvm/java-21-openjdk-amd64' // 서버 JDK21 (Java17 타겟 호환 빌드) + PATH = "${JAVA_HOME}/bin:${env.PATH}" + } + stages { + stage('Checkout') { steps { checkout scm } } + + // frontend 먼저 빌드 → vite outDir(../backend/src/main/resources/static)에 산출 → jar에 번들 + stage('Frontend Build') { + steps { + dir('frontend') { + sh 'npm ci --silent 2>/dev/null || npm install --silent' + sh 'npm run build' + } + } + } + + stage('Backend Build & Test') { + steps { + dir('backend') { + sh 'mvn clean package -q' // 테스트 포함(CryptoUtil 게이트웨이 어댑터) + } + } + } + + stage('Deploy') { + steps { + sh ''' + sudo systemctl stop guardia-mall 2>/dev/null || true + sudo mkdir -p ${MALL_HOME} + sudo cp backend/target/guardia-mall-*.jar ${MALL_HOME}/app.jar + sudo systemctl start guardia-mall + ''' + } + } + + stage('Health Check') { + steps { + retry(5) { + sleep 8 + sh 'curl -sf http://localhost:8011/actuator/health | grep -q "UP"' + } + } + } + } + post { + success { echo 'GUARDiA Mall 배포 성공 (포트 8011)' } + failure { + sh 'sudo systemctl stop guardia-mall 2>/dev/null || true' + echo 'GUARDiA Mall 배포 실패 — 서비스 중지(롤백)' + } + } +} diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..e7503c6 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,44 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.5.11 + + com.zioinfo + guardia-mall + 1.0.0 + GUARDiA Mall + AI 기반 쇼핑몰 커머스 플랫폼 — Ollama 온프레미스 + GUARDiA ITSM/ERP/CRM/OCR/BI 연동 + + 17 + 0.12.6 + 2.6.0 + 3.0.3 + 42.7.7 + + + org.springframework.bootspring-boot-starter-web + org.springframework.bootspring-boot-starter-security + org.springframework.bootspring-boot-starter-validation + org.springframework.bootspring-boot-starter-actuator + org.springframework.bootspring-boot-starter-aop + org.mybatis.spring.bootmybatis-spring-boot-starter${mybatis.version} + org.postgresqlpostgresql${postgresql.version} + io.jsonwebtokenjjwt-api${jjwt.version} + io.jsonwebtokenjjwt-impl${jjwt.version}runtime + io.jsonwebtokenjjwt-jackson${jjwt.version}runtime + org.springdocspringdoc-openapi-starter-webmvc-ui${springdoc.version} + org.projectlomboklomboktrue + org.springframework.bootspring-boot-starter-webflux + org.springframework.bootspring-boot-starter-websocket + org.springframework.bootspring-boot-starter-testtest + org.springframework.securityspring-security-testtest + + + org.springframework.bootspring-boot-maven-plugin + org.projectlomboklombok + + diff --git a/backend/src/main/java/com/zioinfo/mall/MallApplication.java b/backend/src/main/java/com/zioinfo/mall/MallApplication.java new file mode 100644 index 0000000..f7d19fa --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/MallApplication.java @@ -0,0 +1,22 @@ +package com.zioinfo.mall; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** + * GUARDiA Mall — AI 기반 쇼핑몰 커머스 플랫폼. + * + *

쇼핑몰 코어(상품·카탈로그·장바구니·주문·결제·배송·재고·프로모션·리뷰·정산·찜·CS)와 + * GUARDiA 기능 연계(CRM 회원·ERP 재고/정산·OCR 상품등록·BI 매출분석·ITSM CS연계), + * Ollama 온프레미스 AI(상품추천·리뷰요약·자연어검색·CS자동응답)를 제공한다. + */ +@SpringBootApplication +@EnableScheduling +@EnableAsync +public class MallApplication { + public static void main(String[] args) { + SpringApplication.run(MallApplication.class, args); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/admin/AdminController.java b/backend/src/main/java/com/zioinfo/mall/admin/AdminController.java new file mode 100644 index 0000000..c9c3710 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/admin/AdminController.java @@ -0,0 +1,96 @@ +package com.zioinfo.mall.admin; + +import com.zioinfo.mall.admin.dto.AuditLog; +import com.zioinfo.mall.admin.dto.MallSetting; +import com.zioinfo.mall.admin.dto.UserDto; +import com.zioinfo.mall.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * GUARDiA Mall 관리자 API — /api/admin. + * + *

RBAC(SecurityConfig requestMatchers 로 통제): + *

+ */ +@RestController +@RequestMapping("/api/admin") +@RequiredArgsConstructor +public class AdminController { + + private final AdminUserService userService; + private final AuditService auditService; + private final SettingService settingService; + + // ===================== 1. 사용자 관리 (ADMIN) ===================== + + @GetMapping("/users") + public ApiResponse> listUsers() { + return ApiResponse.ok(userService.list()); + } + + @PostMapping("/users") + public ApiResponse createUser(@RequestBody CreateUserRequest req) { + return ApiResponse.ok(userService.create(req.username(), req.password(), req.role(), req.displayName())); + } + + @PutMapping("/users/{id}/role") + public ApiResponse updateRole(@PathVariable Long id, @RequestBody RoleRequest req) { + return ApiResponse.ok(userService.updateRole(id, req.role())); + } + + @PutMapping("/users/{id}/active") + public ApiResponse updateActive(@PathVariable Long id, @RequestBody ActiveRequest req) { + return ApiResponse.ok(userService.updateActive(id, req.active())); + } + + @PutMapping("/users/{id}/password") + public ApiResponse resetPassword(@PathVariable Long id, @RequestBody PasswordRequest req) { + return ApiResponse.ok(userService.resetPassword(id, req.password())); + } + + @DeleteMapping("/users/{id}") + public ApiResponse deleteUser(@PathVariable Long id, Authentication auth) { + String currentUsername = auth != null ? auth.getName() : null; + userService.delete(id, currentUsername); + return ApiResponse.ok(null); + } + + // ===================== 2. 감사 로그 (ADMIN/MANAGER) ===================== + + @GetMapping("/audit") + public ApiResponse> audit( + @RequestParam(value = "action", required = false) String action, + @RequestParam(value = "actor", required = false) String actor, + @RequestParam(value = "limit", defaultValue = "100") int limit) { + return ApiResponse.ok(auditService.find(action, actor, limit)); + } + + // ===================== 3. 시스템 설정 ===================== + + @GetMapping("/settings") + public ApiResponse> settings() { + return ApiResponse.ok(settingService.list()); + } + + @PutMapping("/settings/{key}") + public ApiResponse updateSetting(@PathVariable String key, + @RequestBody SettingRequest req) { + return ApiResponse.ok(settingService.update(key, req.value())); + } + + // ===================== 요청 DTO ===================== + + record CreateUserRequest(String username, String password, String role, String displayName) {} + record RoleRequest(String role) {} + record ActiveRequest(boolean active) {} + record PasswordRequest(String password) {} + record SettingRequest(String value) {} +} diff --git a/backend/src/main/java/com/zioinfo/mall/admin/AdminUserService.java b/backend/src/main/java/com/zioinfo/mall/admin/AdminUserService.java new file mode 100644 index 0000000..32d508c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/admin/AdminUserService.java @@ -0,0 +1,116 @@ +package com.zioinfo.mall.admin; + +import com.zioinfo.mall.admin.dto.UserDto; +import com.zioinfo.mall.admin.mapper.AdminUserMapper; +import com.zioinfo.mall.auth.MallUser; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Set; + +/** + * 관리자 사용자 관리 서비스 (ADMIN 전용). + * + *

보안: 응답은 항상 {@link UserDto} 로 변환하여 password_hash 노출을 차단한다. + */ +@Service +@RequiredArgsConstructor +public class AdminUserService { + + private static final Set VALID_ROLES = Set.of("ADMIN", "MANAGER", "USER"); + + private final AdminUserMapper mapper; + private final PasswordEncoder passwordEncoder; + private final AuditService auditService; + + public List list() { + return mapper.findAll().stream().map(UserDto::from).toList(); + } + + public UserDto create(String username, String password, String role, String displayName) { + if (username == null || username.isBlank()) { + throw new IllegalArgumentException("ERR-USR-400: username 필수"); + } + if (password == null || password.isBlank()) { + throw new IllegalArgumentException("ERR-USR-400: password 필수"); + } + String resolvedRole = normalizeRole(role); + if (mapper.countByUsername(username) > 0) { + throw new RuntimeException("ERR-USR-409: 이미 존재하는 username"); + } + MallUser user = new MallUser(); + user.setUsername(username); + user.setPasswordHash(passwordEncoder.encode(password)); + user.setRole(resolvedRole); + user.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName); + user.setActive(true); + mapper.insert(user); + auditService.log("USER_CREATE", username, "role=" + resolvedRole); + return UserDto.from(user); + } + + public UserDto updateRole(Long id, String role) { + MallUser user = require(id); + String newRole = normalizeRole(role); + if ("ADMIN".equals(user.getRole()) && !"ADMIN".equals(newRole) + && user.isActive() && mapper.countAdmins() <= 1) { + throw new RuntimeException("ERR-USR-423: 마지막 ADMIN 계정의 역할은 변경할 수 없습니다"); + } + mapper.updateRole(id, newRole); + auditService.log("USER_ROLE_CHANGE", user.getUsername(), user.getRole() + " -> " + newRole); + return UserDto.from(require(id)); + } + + public UserDto updateActive(Long id, boolean active) { + MallUser user = require(id); + if (!active && "ADMIN".equals(user.getRole()) && user.isActive() && mapper.countAdmins() <= 1) { + throw new RuntimeException("ERR-USR-423: 마지막 ADMIN 계정은 비활성화할 수 없습니다"); + } + mapper.updateActive(id, active); + auditService.log("USER_ACTIVE_TOGGLE", user.getUsername(), "active=" + active); + return UserDto.from(require(id)); + } + + public UserDto resetPassword(Long id, String newPassword) { + if (newPassword == null || newPassword.isBlank()) { + throw new IllegalArgumentException("ERR-USR-400: password 필수"); + } + MallUser user = require(id); + mapper.updatePassword(id, passwordEncoder.encode(newPassword)); + auditService.log("USER_PASSWORD_RESET", user.getUsername(), "password reset"); + return UserDto.from(user); + } + + public void delete(Long id, String currentUsername) { + MallUser user = require(id); + if (user.getUsername().equals(currentUsername)) { + throw new RuntimeException("ERR-USR-423: 자기 자신은 삭제할 수 없습니다"); + } + if ("ADMIN".equals(user.getRole()) && user.isActive() && mapper.countAdmins() <= 1) { + throw new RuntimeException("ERR-USR-423: 마지막 ADMIN 계정은 삭제할 수 없습니다"); + } + mapper.deleteById(id); + auditService.log("USER_DELETE", user.getUsername(), "role=" + user.getRole()); + } + + private MallUser require(Long id) { + MallUser user = mapper.findById(id); + if (user == null) { + throw new RuntimeException("ERR-USR-404: 사용자를 찾을 수 없습니다"); + } + return user; + } + + private String normalizeRole(String role) { + if (role == null || role.isBlank()) { + return "USER"; + } + String upper = role.trim().toUpperCase(); + if (!VALID_ROLES.contains(upper)) { + throw new IllegalArgumentException("ERR-USR-400: 유효하지 않은 role (ADMIN/MANAGER/USER)"); + } + return upper; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/admin/AuditService.java b/backend/src/main/java/com/zioinfo/mall/admin/AuditService.java new file mode 100644 index 0000000..3a5d10f --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/admin/AuditService.java @@ -0,0 +1,54 @@ +package com.zioinfo.mall.admin; + +import com.zioinfo.mall.admin.dto.AuditLog; +import com.zioinfo.mall.admin.mapper.AuditLogMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 감사 로그 서비스. + * + *

관리자/주문/결제 등 주요 작업 시 호출하여 mall_audit_log 에 기록한다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class AuditService { + + private final AuditLogMapper mapper; + + public void log(String action, String target, String detail) { + log(currentActor(), action, target, detail); + } + + public void log(String actor, String action, String target, String detail) { + AuditLog entry = new AuditLog(); + entry.setActor(actor); + entry.setAction(action); + entry.setTarget(target); + entry.setDetail(detail); + try { + mapper.insert(entry); + } catch (Exception e) { + log.warn("감사 로그 기록 실패(무시): {}", e.getMessage()); + } + } + + public List find(String action, String actor, int limit) { + int safeLimit = (limit <= 0 || limit > 1000) ? 100 : limit; + return mapper.find(action, actor, safeLimit); + } + + public static String currentActor() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getName() != null && !auth.getName().isBlank()) { + return auth.getName(); + } + return "system"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/admin/SettingService.java b/backend/src/main/java/com/zioinfo/mall/admin/SettingService.java new file mode 100644 index 0000000..8f2dfc9 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/admin/SettingService.java @@ -0,0 +1,39 @@ +package com.zioinfo.mall.admin; + +import com.zioinfo.mall.admin.dto.MallSetting; +import com.zioinfo.mall.admin.mapper.SettingMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 시스템 설정 서비스. + * + *

조회는 ADMIN/MANAGER, 변경은 ADMIN (SecurityConfig requestMatchers 로 통제). + */ +@Service +@RequiredArgsConstructor +public class SettingService { + + private final SettingMapper mapper; + private final AuditService auditService; + + public List list() { + return mapper.findAll(); + } + + public MallSetting update(String key, String value) { + if (key == null || key.isBlank()) { + throw new IllegalArgumentException("ERR-SET-400: key 필수"); + } + if (value == null) { + throw new IllegalArgumentException("ERR-SET-400: value 필수"); + } + MallSetting before = mapper.findByKey(key); + mapper.upsert(key, value); + String prev = before == null ? "(none)" : before.getValue(); + auditService.log("SETTING_CHANGE", key, prev + " -> " + value); + return mapper.findByKey(key); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/admin/dto/AuditLog.java b/backend/src/main/java/com/zioinfo/mall/admin/dto/AuditLog.java new file mode 100644 index 0000000..c44c85c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/admin/dto/AuditLog.java @@ -0,0 +1,16 @@ +package com.zioinfo.mall.admin.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 감사 로그 (mall_audit_log 테이블 매핑). */ +@Data +public class AuditLog { + private Long id; + private String actor; + private String action; + private String target; + private String detail; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/admin/dto/MallSetting.java b/backend/src/main/java/com/zioinfo/mall/admin/dto/MallSetting.java new file mode 100644 index 0000000..93c41ae --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/admin/dto/MallSetting.java @@ -0,0 +1,13 @@ +package com.zioinfo.mall.admin.dto; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 시스템 설정 (mall_setting 테이블 매핑). */ +@Data +public class MallSetting { + private String key; + private String value; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/admin/dto/UserDto.java b/backend/src/main/java/com/zioinfo/mall/admin/dto/UserDto.java new file mode 100644 index 0000000..ffeefac --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/admin/dto/UserDto.java @@ -0,0 +1,23 @@ +package com.zioinfo.mall.admin.dto; + +import com.zioinfo.mall.auth.MallUser; + +import java.time.LocalDateTime; + +/** + * 사용자 응답 DTO. + * + *

보안 불변 규칙: password_hash 는 어떤 응답에도 포함하지 않는다. + */ +public record UserDto( + Long id, + String username, + String role, + String displayName, + boolean active, + LocalDateTime createdAt +) { + public static UserDto from(MallUser u) { + return new UserDto(u.getId(), u.getUsername(), u.getRole(), u.getDisplayName(), u.isActive(), u.getCreatedAt()); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/admin/mapper/AdminUserMapper.java b/backend/src/main/java/com/zioinfo/mall/admin/mapper/AdminUserMapper.java new file mode 100644 index 0000000..e937b7c --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/admin/mapper/AdminUserMapper.java @@ -0,0 +1,30 @@ +package com.zioinfo.mall.admin.mapper; + +import com.zioinfo.mall.auth.MallUser; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** 관리자 사용자 관리 매퍼. mall_account 테이블 사용. */ +@Mapper +public interface AdminUserMapper { + + List findAll(); + + MallUser findById(@Param("id") Long id); + + int insert(MallUser user); + + int updateRole(@Param("id") Long id, @Param("role") String role); + + int updateActive(@Param("id") Long id, @Param("active") boolean active); + + int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash); + + int deleteById(@Param("id") Long id); + + int countAdmins(); + + int countByUsername(@Param("username") String username); +} diff --git a/backend/src/main/java/com/zioinfo/mall/admin/mapper/AuditLogMapper.java b/backend/src/main/java/com/zioinfo/mall/admin/mapper/AuditLogMapper.java new file mode 100644 index 0000000..becd421 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/admin/mapper/AuditLogMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mall.admin.mapper; + +import com.zioinfo.mall.admin.dto.AuditLog; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface AuditLogMapper { + + int insert(AuditLog log); + + List find(@Param("action") String action, + @Param("actor") String actor, + @Param("limit") int limit); +} diff --git a/backend/src/main/java/com/zioinfo/mall/admin/mapper/SettingMapper.java b/backend/src/main/java/com/zioinfo/mall/admin/mapper/SettingMapper.java new file mode 100644 index 0000000..c3bc209 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/admin/mapper/SettingMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mall.admin.mapper; + +import com.zioinfo.mall.admin.dto.MallSetting; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface SettingMapper { + + List findAll(); + + MallSetting findByKey(@Param("key") String key); + + int upsert(@Param("key") String key, @Param("value") String value); +} diff --git a/backend/src/main/java/com/zioinfo/mall/ai/MallAiController.java b/backend/src/main/java/com/zioinfo/mall/ai/MallAiController.java new file mode 100644 index 0000000..40031ad --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/ai/MallAiController.java @@ -0,0 +1,83 @@ +package com.zioinfo.mall.ai; + +import com.zioinfo.mall.analytics.mapper.AnalyticsMapper; +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.product.MallProduct; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * GUARDiA Mall AI API — /api/mall/ai. Ollama 온프레미스 + Java 폴백. + * 고객용(추천·리뷰요약·자연어검색·카드메시지)과 운영용(당일소진·수요예측·이양추천) 분리. + */ +@RestController +@RequestMapping("/api/mall/ai") +@RequiredArgsConstructor +public class MallAiController { + + private final MallAiService aiService; + private final AnalyticsMapper analyticsMapper; + + /** 1. 상품 추천 — 공개(스토어프론트). */ + @GetMapping("/recommend") + public ApiResponse> recommend(@RequestParam(required = false) String occasion, + @RequestParam(required = false) String keyword, + @RequestParam(defaultValue = "6") int limit) { + return ApiResponse.ok(aiService.recommend(occasion, keyword, limit)); + } + + /** 2. 리뷰 AI 요약 — 공개. */ + @GetMapping("/review-summary/{productId}") + public ApiResponse> reviewSummary(@PathVariable Long productId) { + return ApiResponse.ok(aiService.summarizeReviews(productId)); + } + + /** 3. 자연어 상품 검색 — 공개. */ + @PostMapping("/nl-search") + public ApiResponse> nlSearch(@RequestBody Map req) { + return ApiResponse.ok(aiService.nlSearch(req.getOrDefault("query", ""))); + } + + /** 4. CS 자동응답 초안 — 인증 고객. */ + @PostMapping("/cs-reply") + public ApiResponse> csReply(@RequestBody Map req) { + String reply = aiService.csAutoReply(req.getOrDefault("subject", ""), req.getOrDefault("content", "")); + return ApiResponse.ok(Map.of("reply", reply)); + } + + /** 7. AI 카드 메시지 — 인증 고객. */ + @PostMapping("/card-message") + public ApiResponse> cardMessage(@RequestBody Map req) { + List msgs = aiService.cardMessages(req.get("occasion"), req.get("tone"), req.get("recipient")); + return ApiResponse.ok(Map.of("messages", msgs)); + } + + /** 5. 당일 재고 소진 추천 — MANAGER+. */ + @GetMapping("/daily-clearance/{storeId}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> dailyClearance(@PathVariable Long storeId) { + return ApiResponse.ok(aiService.dailyClearanceBouquet(storeId)); + } + + /** 6. 피크시즌 수요 예측 — MANAGER+. */ + @GetMapping("/demand-forecast") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> demandForecast(@RequestParam(defaultValue = "valentine") String season, + @RequestParam(defaultValue = "14") int days) { + return ApiResponse.ok(aiService.demandForecast(season, analyticsMapper.salesByStore(days))); + } + + /** 8. 매장간 재고 이양 추천 — MANAGER+. */ + @PostMapping("/transfer-recommend") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + @SuppressWarnings("unchecked") + public ApiResponse>> transferRecommend(@RequestBody Map req) { + List raw = (List) req.getOrDefault("storeIds", List.of()); + List storeIds = raw.stream().map(o -> Long.valueOf(String.valueOf(o))).toList(); + return ApiResponse.ok(aiService.transferRecommendations(storeIds)); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/ai/MallAiService.java b/backend/src/main/java/com/zioinfo/mall/ai/MallAiService.java new file mode 100644 index 0000000..57d5d05 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/ai/MallAiService.java @@ -0,0 +1,302 @@ +package com.zioinfo.mall.ai; + +import com.zioinfo.mall.inventory.mapper.StoreInventoryMapper; +import com.zioinfo.mall.product.MallProduct; +import com.zioinfo.mall.product.mapper.ProductMapper; +import com.zioinfo.mall.review.mapper.ReviewMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.*; + +/** + * GUARDiA Mall AI 서비스 — Ollama(localhost) 적극 활용 + 전부 Java 폴백. + * + *

보안 불변: 외부 AI API 금지. OllamaClient는 localhost:11434만 호출하고 + * 빈 응답 시 결정론적 Java 폴백으로 동작한다(서비스 중단 없음). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MallAiService { + + private final OllamaClient ollama; + private final ProductMapper productMapper; + private final ReviewMapper reviewMapper; + private final StoreInventoryMapper storeInventoryMapper; + + /** 1. 상품 추천 — 행사/키워드 기반. AI 실패 시 인기/평점 폴백. */ + public List recommend(String occasion, String keyword, int limit) { + List pool = productMapper.search(null, keyword, "ON_SALE", occasion, null, null, "sales", 30, 0); + if (pool.isEmpty()) { + pool = productMapper.search(null, null, "ON_SALE", null, null, null, "rating", 30, 0); + } + String names = joinNames(pool); + String prompt = "You are a florist recommender. From this catalog: [" + names + "]. " + + "Recommend up to " + limit + " bouquets for occasion='" + (occasion == null ? "any" : occasion) + + "' keyword='" + (keyword == null ? "" : keyword) + "'. Reply ONLY product names comma-separated."; + String ai = ollama.generate(prompt); + if (ai != null && !ai.isBlank()) { + List ordered = reorderByAi(pool, ai); + if (!ordered.isEmpty()) return ordered.subList(0, Math.min(limit, ordered.size())); + } + return pool.subList(0, Math.min(limit, pool.size())); // Java 폴백: 판매순 + } + + /** 2. 리뷰 AI 요약. 폴백: 평균 평점 + 최근 키워드 요약. */ + public Map summarizeReviews(Long productId) { + List contents = reviewMapper.recentContents(productId, 20); + Map stats = reviewMapper.stats(productId); + Map out = new LinkedHashMap<>(); + out.put("stats", stats); + if (contents.isEmpty()) { + out.put("summary", "No reviews yet."); + out.put("source", "fallback"); + return out; + } + String prompt = "Summarize these flower bouquet reviews in 2 concise sentences (pros/cons):\n" + + String.join("\n", contents); + String ai = ollama.generate(prompt); + if (ai != null && !ai.isBlank()) { + out.put("summary", ai); + out.put("source", "ollama"); + } else { + out.put("summary", "Customers mention: " + topKeywords(contents) + ". Avg rating " + + stats.getOrDefault("avg", "N/A") + "/5."); + out.put("source", "fallback"); + } + return out; + } + + /** 3. 자연어 상품 검색 — "$50 이하 기념일 꽃" → 필터 추출. 폴백: 정규식 파싱. */ + public Map nlSearch(String query) { + BigDecimal maxPrice = parsePrice(query); + String occasion = parseOccasion(query); + String flowerType = parseFlower(query); + List items = productMapper.search(null, null, "ON_SALE", occasion, flowerType, maxPrice, "sales", 20, 0); + Map out = new LinkedHashMap<>(); + out.put("query", query); + out.put("parsed", Map.of("maxPrice", maxPrice == null ? "" : maxPrice, + "occasion", occasion == null ? "" : occasion, "flowerType", flowerType == null ? "" : flowerType)); + out.put("items", items); + out.put("source", "fallback-nlp"); + return out; + } + + /** 4. CS 자동응답 초안. 폴백: 카테고리 템플릿. */ + public String csAutoReply(String subject, String content) { + String prompt = "You are a polite flower-shop customer support agent. Write a short helpful reply (<=4 sentences) to:\n" + + "Subject: " + subject + "\nMessage: " + content; + String ai = ollama.generate(prompt); + if (ai != null && !ai.isBlank()) return ai; + return "Thank you for reaching out about \"" + subject + "\". We're sorry for any inconvenience. " + + "Our team is reviewing your request and will follow up shortly. " + + "For urgent delivery issues, please reply with your order number."; + } + + /** + * 5. 당일 재고 소진 추천 — 매장 잔여 재고로 AI 부케 구성(Farmgirl 'Daily Standard'). + * 폴백: 잔여 수량 많은 상위 상품을 묶음 제안. + */ + public Map dailyClearanceBouquet(Long storeId) { + List inv = storeInventoryMapper.findByStore(storeId); + inv.sort((a, b) -> Integer.compare(b.getOnHand() == null ? 0 : b.getOnHand(), + a.getOnHand() == null ? 0 : a.getOnHand())); + List surplus = new ArrayList<>(); + for (com.zioinfo.mall.inventory.MallStoreInventory si : inv) { + if (si.getOnHand() != null && si.getOnHand() > 0 && Boolean.TRUE.equals(si.getAvailable())) { + surplus.add(si.getProductName() + " (x" + si.getOnHand() + ")"); + } + if (surplus.size() >= 6) break; + } + Map out = new LinkedHashMap<>(); + out.put("storeId", storeId); + out.put("surplus", surplus); + if (surplus.isEmpty()) { + out.put("bouquet", "No surplus stock to compose today."); + out.put("source", "fallback"); + return out; + } + String prompt = "Compose a creative 'Daily Standard' bouquet name and 1-line description using surplus flowers: [" + + String.join(", ", surplus) + "]. Reply as: NAME | DESCRIPTION."; + String ai = ollama.generate(prompt); + if (ai != null && !ai.isBlank()) { + out.put("bouquet", ai); + out.put("source", "ollama"); + } else { + out.put("bouquet", "Today's Designer's Choice | A fresh seasonal mix featuring " + + surplus.get(0).replaceAll("\\s*\\(.*\\)", "") + " and more."); + out.put("source", "fallback"); + } + return out; + } + + /** + * 6. 피크시즌 수요 예측 — 매장별 용량 계획(밸런타인/어머니날). + * 폴백: 최근 일평균 주문 × 시즌 배수 vs 용량 비교. + */ + public Map demandForecast(String season, List> storeSales) { + double multiplier = "valentine".equalsIgnoreCase(season) ? 3.5 + : "mother".equalsIgnoreCase(season) ? 3.0 : 1.5; + List> forecast = new ArrayList<>(); + for (Map s : storeSales) { + Object oc = s.get("orderCount"); + int recent = oc == null ? 0 : ((Number) oc).intValue(); + int predicted = (int) Math.round(recent * multiplier); + Map row = new LinkedHashMap<>(); + row.put("storeId", s.get("storeId")); + row.put("storeName", s.get("storeName")); + row.put("recentOrders", recent); + row.put("predictedOrders", predicted); + row.put("multiplier", multiplier); + forecast.add(row); + } + Map out = new LinkedHashMap<>(); + out.put("season", season); + out.put("forecast", forecast); + out.put("source", "fallback-heuristic"); + return out; + } + + /** 7. AI 카드 메시지 작성 도우미. 폴백: 행사별 템플릿. */ + public List cardMessages(String occasion, String tone, String recipient) { + String prompt = "Write 3 short flower-card messages for occasion='" + occasion + + "' tone='" + (tone == null ? "warm" : tone) + "' recipient='" + (recipient == null ? "" : recipient) + + "'. One per line, no numbering."; + String ai = ollama.generate(prompt); + if (ai != null && !ai.isBlank()) { + List lines = new ArrayList<>(); + for (String l : ai.split("\n")) { + String t = l.replaceAll("^[0-9.\\-)\\s]+", "").trim(); + if (!t.isEmpty()) lines.add(t); + } + if (!lines.isEmpty()) return lines; + } + return fallbackCards(occasion); + } + + /** + * 8. 매장간 재고 이양 추천 — 잉여/부족 매칭. + * 폴백: 상품별로 재고 많은 매장(공급) → 적은/품절 매장(수요) 매칭. + */ + public List> transferRecommendations(List storeIds) { + // 상품별 매장 재고 집계 + Map> byProduct = new HashMap<>(); + for (Long sid : storeIds) { + for (com.zioinfo.mall.inventory.MallStoreInventory si : storeInventoryMapper.findByStore(sid)) { + byProduct.computeIfAbsent(si.getProductId(), k -> new ArrayList<>()).add(si); + } + } + List> recs = new ArrayList<>(); + for (Map.Entry> e : byProduct.entrySet()) { + List list = e.getValue(); + com.zioinfo.mall.inventory.MallStoreInventory max = null, min = null; + for (com.zioinfo.mall.inventory.MallStoreInventory si : list) { + int oh = si.getOnHand() == null ? 0 : si.getOnHand(); + if (max == null || oh > (max.getOnHand() == null ? 0 : max.getOnHand())) max = si; + if (min == null || oh < (min.getOnHand() == null ? 0 : min.getOnHand())) min = si; + } + if (max != null && min != null && !Objects.equals(max.getStoreId(), min.getStoreId())) { + int surplus = (max.getOnHand() == null ? 0 : max.getOnHand()); + int shortage = (min.getOnHand() == null ? 0 : min.getOnHand()); + if (surplus - shortage >= 10) { + int qty = (surplus - shortage) / 2; + Map r = new LinkedHashMap<>(); + r.put("productId", e.getKey()); + r.put("productName", max.getProductName()); + r.put("fromStoreId", max.getStoreId()); + r.put("toStoreId", min.getStoreId()); + r.put("suggestedQty", qty); + r.put("reason", "Surplus " + surplus + " vs shortage " + shortage); + recs.add(r); + } + } + } + return recs; + } + + // ---------------------------------------------------------------- helpers + private String joinNames(List ps) { + StringBuilder sb = new StringBuilder(); + for (MallProduct p : ps) { + if (sb.length() > 0) sb.append(", "); + sb.append(p.getName()); + } + return sb.toString(); + } + + private List reorderByAi(List pool, String ai) { + String lower = ai.toLowerCase(); + List ordered = new ArrayList<>(); + for (MallProduct p : pool) { + if (p.getName() != null && lower.contains(p.getName().toLowerCase())) ordered.add(p); + } + return ordered; + } + + private BigDecimal parsePrice(String q) { + if (q == null) return null; + java.util.regex.Matcher m = java.util.regex.Pattern.compile("\\$?\\s*(\\d+(?:\\.\\d+)?)").matcher(q); + BigDecimal found = null; + if ((q.contains("under") || q.contains("이하") || q.contains("below") || q.contains("$")) && m.find()) { + found = new BigDecimal(m.group(1)); + } + return found; + } + + private String parseOccasion(String q) { + if (q == null) return null; + String l = q.toLowerCase(); + if (l.contains("anniversary") || l.contains("기념일")) return "ANNIVERSARY"; + if (l.contains("birthday") || l.contains("생일")) return "BIRTHDAY"; + if (l.contains("sympathy") || l.contains("조의")) return "SYMPATHY"; + if (l.contains("romance") || l.contains("love") || l.contains("사랑")) return "ROMANCE"; + if (l.contains("get well") || l.contains("쾌유")) return "GETWELL"; + if (l.contains("congrat") || l.contains("축하")) return "CONGRATS"; + return null; + } + + private String parseFlower(String q) { + if (q == null) return null; + String l = q.toLowerCase(); + if (l.contains("rose") || l.contains("장미")) return "ROSES"; + if (l.contains("tulip") || l.contains("튤립")) return "TULIPS"; + if (l.contains("lily") || l.contains("백합")) return "LILIES"; + if (l.contains("sunflower") || l.contains("해바라기")) return "SUNFLOWERS"; + if (l.contains("hydrangea") || l.contains("수국")) return "HYDRANGEAS"; + return null; + } + + private String topKeywords(List contents) { + Map freq = new HashMap<>(); + for (String c : contents) { + for (String w : c.toLowerCase().split("\\W+")) { + if (w.length() >= 5) freq.merge(w, 1, Integer::sum); + } + } + return freq.entrySet().stream() + .sorted((a, b) -> b.getValue() - a.getValue()) + .limit(3).map(Map.Entry::getKey) + .reduce((a, b) -> a + ", " + b).orElse("quality, freshness"); + } + + private List fallbackCards(String occasion) { + String o = occasion == null ? "" : occasion.toUpperCase(); + return switch (o) { + case "BIRTHDAY" -> List.of("Happy Birthday! Wishing you a day as bright as these blooms.", + "Another year more wonderful — enjoy every petal!", + "Hope your special day blossoms with joy."); + case "ANNIVERSARY" -> List.of("Happy Anniversary — here's to many more years in bloom.", + "Celebrating your love today and always.", + "Forever and always, with these flowers."); + case "SYMPATHY" -> List.of("With heartfelt sympathy in your time of loss.", + "Thinking of you with deepest condolences.", + "May these flowers bring a moment of peace."); + default -> List.of("Just because you deserve something beautiful.", + "Sending smiles your way with these blooms.", + "A little flower to brighten your day."); + }; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/ai/OllamaClient.java b/backend/src/main/java/com/zioinfo/mall/ai/OllamaClient.java new file mode 100644 index 0000000..aa7a671 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/ai/OllamaClient.java @@ -0,0 +1,50 @@ +package com.zioinfo.mall.ai; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.util.Map; + +/** + * Ollama 온프레미스 LLM 클라이언트 (Mall AI 전용). + * + *

보안 불변 규칙: localhost Ollama만 호출. 외부 AI API 절대 금지. + * 장애/오프라인 시 예외 없이 빈 문자열 반환(서비스가 Java 폴백 수행). + */ +@Slf4j +@Component +public class OllamaClient { + + private final WebClient.Builder builder; + private final String ollamaUrl; + private final String model; + + public OllamaClient(WebClient.Builder builder, + @Value("${guardia.ollama-url:http://localhost:11434}") String ollamaUrl, + @Value("${guardia.ollama-text-model:llama3}") String model) { + this.builder = builder; + this.ollamaUrl = ollamaUrl; + this.model = model; + } + + @SuppressWarnings("unchecked") + public String generate(String prompt) { + try { + Map body = Map.of("model", model, "prompt", prompt, "stream", false); + Map res = builder.baseUrl(ollamaUrl).build() + .post().uri("/api/generate").bodyValue(body) + .retrieve().bodyToMono(Map.class) + .timeout(Duration.ofSeconds(30)) + .map(m -> (Map) m).block(); + if (res == null) return ""; + Object r = res.get("response"); + return r == null ? "" : String.valueOf(r).trim(); + } catch (Exception e) { + log.warn("Ollama 일시 불가 — Java 폴백 사용: {}", e.getMessage()); + return ""; + } + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/analytics/AnalyticsController.java b/backend/src/main/java/com/zioinfo/mall/analytics/AnalyticsController.java new file mode 100644 index 0000000..cede073 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/analytics/AnalyticsController.java @@ -0,0 +1,53 @@ +package com.zioinfo.mall.analytics; + +import com.zioinfo.mall.analytics.mapper.AnalyticsMapper; +import com.zioinfo.mall.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 분석 API — /api/mall/analytics. MANAGER+. + * Super Admin 대시보드(매장별 매출·순위·대량주문) + BI 매출 피드. + */ +@RestController +@RequestMapping("/api/mall/analytics") +@RequiredArgsConstructor +@PreAuthorize("hasAnyRole('ADMIN','MANAGER')") +public class AnalyticsController { + + private final AnalyticsMapper mapper; + + /** Super Admin 실시간 현황판 — 오늘 총매출 + 매장별 순위 + 대량주문. */ + @GetMapping("/dashboard") + public ApiResponse> dashboard( + @RequestParam(defaultValue = "1") int days, + @RequestParam(defaultValue = "500") BigDecimal bigOrderThreshold) { + Map out = new LinkedHashMap<>(); + out.put("today", mapper.todayTotals()); + out.put("storeRanking", mapper.salesByStore(days)); + out.put("topProducts", mapper.topProducts(10)); + out.put("bigOrders", mapper.bigOrders(bigOrderThreshold, 20)); + return ApiResponse.ok(out); + } + + @GetMapping("/store-sales") + public ApiResponse>> storeSales(@RequestParam(defaultValue = "7") int days) { + return ApiResponse.ok(mapper.salesByStore(days)); + } + + @GetMapping("/trend") + public ApiResponse>> trend(@RequestParam(defaultValue = "14") int days) { + return ApiResponse.ok(mapper.dailyTrend(days)); + } + + @GetMapping("/top-products") + public ApiResponse>> topProducts(@RequestParam(defaultValue = "10") int limit) { + return ApiResponse.ok(mapper.topProducts(limit)); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/analytics/mapper/AnalyticsMapper.java b/backend/src/main/java/com/zioinfo/mall/analytics/mapper/AnalyticsMapper.java new file mode 100644 index 0000000..588f30b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/analytics/mapper/AnalyticsMapper.java @@ -0,0 +1,21 @@ +package com.zioinfo.mall.analytics.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface AnalyticsMapper { + /** 오늘 전체 매출 합계. */ + Map todayTotals(); + /** 매장별 매출 순위(오늘). */ + List> salesByStore(@Param("days") int days); + /** 인기 상품 TOP. */ + List> topProducts(@Param("limit") int limit); + /** 대량 주문(웨딩/이벤트, 임계 금액 이상) 목록. */ + List> bigOrders(@Param("minAmount") java.math.BigDecimal minAmount, @Param("limit") int limit); + /** 일자별 매출 추이. */ + List> dailyTrend(@Param("days") int days); +} diff --git a/backend/src/main/java/com/zioinfo/mall/auth/AuthController.java b/backend/src/main/java/com/zioinfo/mall/auth/AuthController.java new file mode 100644 index 0000000..7f9b501 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/auth/AuthController.java @@ -0,0 +1,36 @@ +package com.zioinfo.mall.auth; + +import com.zioinfo.mall.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/mall/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + @PostMapping("/login") + public ApiResponse> login(@RequestBody LoginRequest req) { + String token = authService.login(req.username(), req.password()); + return ApiResponse.ok(Map.of("token", token, "type", "Bearer")); + } + + @PostMapping("/register") + public ApiResponse> register(@RequestBody RegisterRequest req) { + String token = authService.register(req.username(), req.password(), req.displayName()); + return ApiResponse.ok(Map.of("token", token, "type", "Bearer")); + } + + @GetMapping("/me") + public ApiResponse> me(@RequestHeader("Authorization") String header) { + String token = header.replace("Bearer ", ""); + return ApiResponse.ok(authService.me(token)); + } + + record LoginRequest(String username, String password) {} + record RegisterRequest(String username, String password, String displayName) {} +} diff --git a/backend/src/main/java/com/zioinfo/mall/auth/AuthService.java b/backend/src/main/java/com/zioinfo/mall/auth/AuthService.java new file mode 100644 index 0000000..6ff2d28 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/auth/AuthService.java @@ -0,0 +1,52 @@ +package com.zioinfo.mall.auth; + +import com.zioinfo.mall.auth.mapper.UserMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AuthService { + + private final UserMapper userMapper; + private final PasswordEncoder passwordEncoder; + private final JwtUtil jwtUtil; + + public String login(String username, String password) { + MallUser user = userMapper.findByUsername(username); + if (user == null || !user.isActive()) { + throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정"); + } + if (!passwordEncoder.matches(password, user.getPasswordHash())) { + throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치"); + } + return jwtUtil.generate(username, user.getRole()); + } + + /** 고객 셀프 회원가입 — 항상 USER 역할로 생성. */ + public String register(String username, String password, String displayName) { + if (username == null || username.isBlank() || password == null || password.isBlank()) { + throw new IllegalArgumentException("ERR-AUTH-400: username/password 필수"); + } + if (userMapper.countByUsername(username) > 0) { + throw new RuntimeException("ERR-AUTH-409: 이미 존재하는 아이디"); + } + MallUser user = new MallUser(); + user.setUsername(username); + user.setPasswordHash(passwordEncoder.encode(password)); + user.setRole("USER"); + user.setDisplayName(displayName == null || displayName.isBlank() ? username : displayName); + user.setActive(true); + userMapper.insert(user); + return jwtUtil.generate(username, "USER"); + } + + public Map me(String token) { + String username = jwtUtil.getUsername(token); + String role = jwtUtil.getRole(token); + return Map.of("username", username, "role", role); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/auth/JwtFilter.java b/backend/src/main/java/com/zioinfo/mall/auth/JwtFilter.java new file mode 100644 index 0000000..5e80909 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/auth/JwtFilter.java @@ -0,0 +1,40 @@ +package com.zioinfo.mall.auth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, + FilterChain chain) throws ServletException, IOException { + String header = req.getHeader("Authorization"); + if (header != null && header.startsWith("Bearer ")) { + String token = header.substring(7); + if (jwtUtil.isValid(token)) { + String username = jwtUtil.getUsername(token); + String role = jwtUtil.getRole(token); + var auth = new UsernamePasswordAuthenticationToken( + username, null, List.of(new SimpleGrantedAuthority("ROLE_" + role)) + ); + SecurityContextHolder.getContext().setAuthentication(auth); + } + } + chain.doFilter(req, res); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/auth/JwtUtil.java b/backend/src/main/java/com/zioinfo/mall/auth/JwtUtil.java new file mode 100644 index 0000000..24e5d94 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/auth/JwtUtil.java @@ -0,0 +1,59 @@ +package com.zioinfo.mall.auth; + +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; + +@Slf4j +@Component +public class JwtUtil { + + @Value("${guardia.jwt.secret:guardia-mall-jwt-secret-2026-minimum-256bit-key-zioinfo}") + private String secret; + + @Value("${guardia.jwt.expiration:86400000}") + private long expirationMs; + + private SecretKey key() { + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + } + + public String generate(String username, String role) { + return Jwts.builder() + .subject(username) + .claim("role", role) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + expirationMs)) + .signWith(key()) + .compact(); + } + + public Claims parse(String token) { + return Jwts.parser().verifyWith(key()).build() + .parseSignedClaims(token).getPayload(); + } + + public boolean isValid(String token) { + try { + parse(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + log.debug("JWT 검증 실패: {}", e.getMessage()); + return false; + } + } + + public String getUsername(String token) { + return parse(token).getSubject(); + } + + public String getRole(String token) { + return parse(token).get("role", String.class); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/auth/MallUser.java b/backend/src/main/java/com/zioinfo/mall/auth/MallUser.java new file mode 100644 index 0000000..1ee29f5 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/auth/MallUser.java @@ -0,0 +1,16 @@ +package com.zioinfo.mall.auth; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 계정 (mall_account). 역할: ADMIN/MANAGER/USER(고객). */ +@Data +public class MallUser { + private Long id; + private String username; + private String passwordHash; + private String role; + private String displayName; + private boolean active; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/auth/mapper/UserMapper.java b/backend/src/main/java/com/zioinfo/mall/auth/mapper/UserMapper.java new file mode 100644 index 0000000..c21ae8a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/auth/mapper/UserMapper.java @@ -0,0 +1,15 @@ +package com.zioinfo.mall.auth.mapper; + +import com.zioinfo.mall.auth.MallUser; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface UserMapper { + + MallUser findByUsername(@Param("username") String username); + + int insert(MallUser user); + + int countByUsername(@Param("username") String username); +} diff --git a/backend/src/main/java/com/zioinfo/mall/cart/CartController.java b/backend/src/main/java/com/zioinfo/mall/cart/CartController.java new file mode 100644 index 0000000..589ba6e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/cart/CartController.java @@ -0,0 +1,69 @@ +package com.zioinfo.mall.cart; + +import com.zioinfo.mall.cart.mapper.CartMapper; +import com.zioinfo.mall.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** 장바구니 API — /api/mall/cart. owner=인증 사용자. */ +@RestController +@RequestMapping("/api/mall/cart") +@RequiredArgsConstructor +public class CartController { + + private final CartMapper mapper; + + @GetMapping + public ApiResponse> list(Authentication auth) { + List items = mapper.findByOwner(auth.getName()); + BigDecimal total = items.stream() + .map(i -> (i.getUnitPrice() == null ? BigDecimal.ZERO : i.getUnitPrice()) + .multiply(BigDecimal.valueOf(i.getQuantity() == null ? 0 : i.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + Map out = new LinkedHashMap<>(); + out.put("items", items); + out.put("itemCount", items.size()); + out.put("totalAmount", total); + return ApiResponse.ok(out); + } + + @PostMapping + public ApiResponse add(@RequestBody MallCartItem item, Authentication auth) { + item.setOwner(auth.getName()); + if (item.getQuantity() == null || item.getQuantity() < 1) item.setQuantity(1); + MallCartItem existing = mapper.findExisting(auth.getName(), item.getProductId(), item.getOptionId()); + if (existing != null) { + int newQty = existing.getQuantity() + item.getQuantity(); + mapper.updateQty(existing.getId(), auth.getName(), newQty); + return ApiResponse.ok(mapper.findById(existing.getId())); + } + mapper.insert(item); + return ApiResponse.ok(item); + } + + @PutMapping("/{id}") + public ApiResponse updateQty(@PathVariable Long id, @RequestBody Map req, Authentication auth) { + int qty = req.getOrDefault("quantity", 1); + if (qty < 1) throw new IllegalArgumentException("ERR-CART-400: 수량은 1 이상"); + mapper.updateQty(id, auth.getName(), qty); + return ApiResponse.ok(mapper.findById(id)); + } + + @DeleteMapping("/{id}") + public ApiResponse remove(@PathVariable Long id, Authentication auth) { + mapper.delete(id, auth.getName()); + return ApiResponse.ok(null); + } + + @DeleteMapping + public ApiResponse clear(Authentication auth) { + mapper.clear(auth.getName()); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/cart/MallCartItem.java b/backend/src/main/java/com/zioinfo/mall/cart/MallCartItem.java new file mode 100644 index 0000000..ff8209d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/cart/MallCartItem.java @@ -0,0 +1,24 @@ +package com.zioinfo.mall.cart; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** 장바구니 항목 (mall_cart_item). owner=username. */ +@Data +public class MallCartItem { + private Long id; + private String owner; + private Long productId; + private Long optionId; + private String sizeCode; + private String cardMessage; + private Integer quantity; + private LocalDateTime createdAt; + // 조인 표시용(읽기 전용) + private String productName; + private BigDecimal unitPrice; + private String thumbnail; + private String optionLabel; +} diff --git a/backend/src/main/java/com/zioinfo/mall/cart/mapper/CartMapper.java b/backend/src/main/java/com/zioinfo/mall/cart/mapper/CartMapper.java new file mode 100644 index 0000000..16ae230 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/cart/mapper/CartMapper.java @@ -0,0 +1,18 @@ +package com.zioinfo.mall.cart.mapper; + +import com.zioinfo.mall.cart.MallCartItem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface CartMapper { + List findByOwner(@Param("owner") String owner); + MallCartItem findExisting(@Param("owner") String owner, @Param("productId") Long productId, @Param("optionId") Long optionId); + MallCartItem findById(@Param("id") Long id); + int insert(MallCartItem item); + int updateQty(@Param("id") Long id, @Param("owner") String owner, @Param("quantity") int quantity); + int delete(@Param("id") Long id, @Param("owner") String owner); + int clear(@Param("owner") String owner); +} diff --git a/backend/src/main/java/com/zioinfo/mall/category/CategoryController.java b/backend/src/main/java/com/zioinfo/mall/category/CategoryController.java new file mode 100644 index 0000000..b985336 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/category/CategoryController.java @@ -0,0 +1,55 @@ +package com.zioinfo.mall.category; + +import com.zioinfo.mall.category.mapper.CategoryMapper; +import com.zioinfo.mall.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** 카탈로그 카테고리 API — /api/mall/category. GET 공개, 변경 MANAGER+. */ +@RestController +@RequestMapping("/api/mall/category") +@RequiredArgsConstructor +public class CategoryController { + + private final CategoryMapper mapper; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) Long parentId, + @RequestParam(required = false) Boolean children) { + if (Boolean.TRUE.equals(children) || parentId != null) { + return ApiResponse.ok(mapper.findByParent(parentId)); + } + return ApiResponse.ok(mapper.findAll()); + } + + @GetMapping("/tree") + public ApiResponse> tree() { + return ApiResponse.ok(mapper.findAll()); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(mapper.findById(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MallCategory c) { + mapper.insert(c); + return ApiResponse.ok(c); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MallCategory c) { + c.setId(id); + mapper.update(c); + return ApiResponse.ok(mapper.findById(id)); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + mapper.delete(id); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/category/MallCategory.java b/backend/src/main/java/com/zioinfo/mall/category/MallCategory.java new file mode 100644 index 0000000..db8aebd --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/category/MallCategory.java @@ -0,0 +1,14 @@ +package com.zioinfo.mall.category; + +import lombok.Data; + +/** 카탈로그 카테고리 트리 (mall_category). parentId=null 이면 최상위. */ +@Data +public class MallCategory { + private Long id; + private Long parentId; + private String name; + private String code; + private Integer sortOrder; + private Boolean active; +} diff --git a/backend/src/main/java/com/zioinfo/mall/category/mapper/CategoryMapper.java b/backend/src/main/java/com/zioinfo/mall/category/mapper/CategoryMapper.java new file mode 100644 index 0000000..231a860 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/category/mapper/CategoryMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mall.category.mapper; + +import com.zioinfo.mall.category.MallCategory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface CategoryMapper { + List findAll(); + List findByParent(@Param("parentId") Long parentId); + MallCategory findById(@Param("id") Long id); + int insert(MallCategory c); + int update(MallCategory c); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mall/common/ApiResponse.java b/backend/src/main/java/com/zioinfo/mall/common/ApiResponse.java new file mode 100644 index 0000000..756badc --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/common/ApiResponse.java @@ -0,0 +1,20 @@ +package com.zioinfo.mall.common; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class ApiResponse { + private boolean success; + private String message; + private T data; + + public static ApiResponse ok(T data) { + return new ApiResponse<>(true, "OK", data); + } + + public static ApiResponse fail(String message) { + return new ApiResponse<>(false, message, null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/common/CryptoUtil.java b/backend/src/main/java/com/zioinfo/mall/common/CryptoUtil.java new file mode 100644 index 0000000..e5eba93 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/common/CryptoUtil.java @@ -0,0 +1,85 @@ +package com.zioinfo.mall.common; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +/** + * 결제·회원 민감정보 암호화 유틸 — AES-256-GCM. + * + *

GUARDiA 보안 불변 규칙: 카드번호·계좌번호 등 결제 민감정보는 평문 저장 금지. + * {@code *_enc} 컬럼에 본 유틸로 암호화하여 저장하고, API 응답에는 절대 노출하지 않는다(마스킹만 노출). + * + *

저장 포맷: Base64( IV(12B) || ciphertext || GCM tag(16B) ). + */ +@Component +public class CryptoUtil { + + private static final int IV_LEN = 12; + private static final int TAG_BITS = 128; + private final SecretKeySpec key; + private final SecureRandom random = new SecureRandom(); + + public CryptoUtil(@Value("${guardia.crypto.secret:guardia-mall-aes-256-gcm-master-key-2026-zioinfo}") String secret) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(secret.getBytes(StandardCharsets.UTF_8)); + this.key = new SecretKeySpec(digest, "AES"); + } catch (Exception e) { + throw new IllegalStateException("암호화 키 초기화 실패", e); + } + } + + /** 평문을 AES-256-GCM으로 암호화하여 Base64 문자열로 반환. null/빈 입력은 그대로 반환. */ + public String encrypt(String plain) { + if (plain == null || plain.isEmpty()) { + return plain; + } + try { + byte[] iv = new byte[IV_LEN]; + random.nextBytes(iv); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] ct = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); + byte[] out = new byte[iv.length + ct.length]; + System.arraycopy(iv, 0, out, 0, iv.length); + System.arraycopy(ct, 0, out, iv.length, ct.length); + return Base64.getEncoder().encodeToString(out); + } catch (Exception e) { + throw new RuntimeException("ERR-MALL-CRYPTO-01: 암호화 실패"); + } + } + + /** Base64 암호문을 복호화. 복호화 실패 시 빈 문자열. */ + public String decrypt(String enc) { + if (enc == null || enc.isEmpty()) { + return enc; + } + try { + byte[] all = Base64.getDecoder().decode(enc); + byte[] iv = new byte[IV_LEN]; + System.arraycopy(all, 0, iv, 0, IV_LEN); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); + byte[] pt = cipher.doFinal(all, IV_LEN, all.length - IV_LEN); + return new String(pt, StandardCharsets.UTF_8); + } catch (Exception e) { + return ""; + } + } + + /** 카드/계좌번호 마스킹 — 마지막 4자리만 노출. */ + public static String maskTail(String value) { + if (value == null || value.length() <= 4) { + return "****"; + } + String tail = value.substring(value.length() - 4); + return "****-****-" + tail; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/common/GlobalExceptionHandler.java b/backend/src/main/java/com/zioinfo/mall/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..cac60a9 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/common/GlobalExceptionHandler.java @@ -0,0 +1,48 @@ +package com.zioinfo.mall.common; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +/** + * 전역 예외 처리. + * + *

보안 불변 규칙: 스택트레이스를 응답에 절대 노출하지 않는다. + * 에러 코드 + 요약 메시지만 반환한다. + */ +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(MaxUploadSizeExceededException.class) + @ResponseStatus(HttpStatus.PAYLOAD_TOO_LARGE) + public ApiResponse handleMaxSize(MaxUploadSizeExceededException e) { + log.warn("업로드 크기 초과: {}", e.getMessage()); + return ApiResponse.fail("ERR-MALL-413: 파일 크기 초과 (최대 20MB)"); + } + + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleIllegalArg(IllegalArgumentException e) { + log.warn("잘못된 요청: {}", e.getMessage()); + return ApiResponse.fail(e.getMessage()); + } + + @ExceptionHandler(RuntimeException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ApiResponse handleRuntime(RuntimeException e) { + // 스택트레이스 미노출 — 에러 코드/요약만 반환 + log.warn("업무 오류: {}", e.getMessage()); + return ApiResponse.fail(e.getMessage()); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public ApiResponse handleGeneral(Exception e) { + log.error("시스템 오류", e); + return ApiResponse.fail("ERR-SYS-001"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/config/CorsConfig.java b/backend/src/main/java/com/zioinfo/mall/config/CorsConfig.java new file mode 100644 index 0000000..7e0bc11 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/config/CorsConfig.java @@ -0,0 +1,25 @@ +package com.zioinfo.mall.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +public class CorsConfig { + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOriginPatterns(List.of("*")); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/config/MyBatisConfig.java b/backend/src/main/java/com/zioinfo/mall/config/MyBatisConfig.java new file mode 100644 index 0000000..485d289 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/config/MyBatisConfig.java @@ -0,0 +1,30 @@ +package com.zioinfo.mall.config; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.session.SqlSessionFactory; +import org.mybatis.spring.SqlSessionFactoryBean; +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +import javax.sql.DataSource; + +@Configuration +// 전체 베이스 패키지에서 @Mapper 인터페이스만 등록 — *.mapper 외 위치도 포함 +@MapperScan(basePackages = "com.zioinfo.mall", annotationClass = Mapper.class) +public class MyBatisConfig { + + @Bean + public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { + SqlSessionFactoryBean factory = new SqlSessionFactoryBean(); + factory.setDataSource(dataSource); + factory.setMapperLocations( + new PathMatchingResourcePatternResolver().getResources("classpath:mapper/**/*.xml") + ); + org.apache.ibatis.session.Configuration config = new org.apache.ibatis.session.Configuration(); + config.setMapUnderscoreToCamelCase(true); + factory.setConfiguration(config); + return factory.getObject(); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/config/SecurityConfig.java b/backend/src/main/java/com/zioinfo/mall/config/SecurityConfig.java new file mode 100644 index 0000000..a3ec687 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/config/SecurityConfig.java @@ -0,0 +1,87 @@ +package com.zioinfo.mall.config; + +import com.zioinfo.mall.auth.JwtFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +/** + * GUARDiA Mall 보안 — JWT 무상태 + RBAC. + * + *

    + *
  • 스토어프론트 조회(상품/카탈로그/리뷰 GET) — 공개
  • + *
  • 장바구니/주문/결제/배송조회/찜/CS — 인증 고객
  • + *
  • 상품/카탈로그/프로모션/정산 정의 변경 — MANAGER 이상
  • + *
  • 관리자 API(/api/admin) — ADMIN (감사/설정 조회는 MANAGER 허용)
  • + *
+ */ +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtFilter jwtFilter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .cors(cors -> {}) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/mall/auth/**").permitAll() + .requestMatchers("/actuator/health").permitAll() + .requestMatchers("/ws/mall/**").permitAll() + .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/api/mall/docs/**", "/api/mall/swagger/**").permitAll() + // 스토어프론트 공개 조회 (상품·카탈로그·리뷰·기획전·매장·권역·스케줄 브라우징) + .requestMatchers(HttpMethod.GET, "/api/mall/product/**", "/api/mall/category/**", + "/api/mall/review/**", "/api/mall/promotion/**", + "/api/mall/store/**", "/api/mall/schedule/availability", + "/api/mall/schedule/holidays").permitAll() + // ZIP 진입 조회(공개) — 배송 가능 매장 판정 + .requestMatchers(HttpMethod.GET, "/api/mall/zone/lookup").permitAll() + // 등급 혜택 안내(공개) + 진행중 이벤트/배너(공개 스토어프론트) + .requestMatchers(HttpMethod.GET, "/api/mall/loyalty/tiers", "/api/mall/loyalty/tiers/**", + "/api/mall/event/ongoing", "/api/mall/event/banners", + "/api/mall/event/{id}").permitAll() + // AI 스토어프론트(추천·리뷰요약·자연어검색·카드메시지) 공개 + .requestMatchers(HttpMethod.GET, "/api/mall/ai/recommend", "/api/mall/ai/review-summary/**").permitAll() + .requestMatchers(HttpMethod.POST, "/api/mall/ai/nl-search", "/api/mall/ai/card-message").permitAll() + // 관리자 API + .requestMatchers("/api/admin/users/**").hasRole("ADMIN") + .requestMatchers("/api/admin/audit").hasAnyRole("ADMIN", "MANAGER") + .requestMatchers(HttpMethod.GET, "/api/admin/settings").hasAnyRole("ADMIN", "MANAGER") + .requestMatchers("/api/admin/settings/**").hasRole("ADMIN") + // 운영 분석 — MANAGER 이상 + .requestMatchers("/api/mall/analytics/**").hasAnyRole("ADMIN", "MANAGER") + .requestMatchers("/api/mall/settlement/**").hasAnyRole("ADMIN", "MANAGER") + // 상품·카탈로그·프로모션·재고 정의 변경 — MANAGER 이상 + .requestMatchers(HttpMethod.POST, "/api/mall/product/**", "/api/mall/category/**", + "/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER") + .requestMatchers(HttpMethod.PUT, "/api/mall/product/**", "/api/mall/category/**", + "/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER") + .requestMatchers(HttpMethod.DELETE, "/api/mall/product/**", "/api/mall/category/**", + "/api/mall/promotion/**", "/api/mall/inventory/**").hasAnyRole("ADMIN", "MANAGER") + // 그 외 mall API (장바구니·주문·결제·배송·찜·CS·회원·AI) — 인증 사용자 + .requestMatchers("/api/mall/**").authenticated() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration cfg) throws Exception { return cfg.getAuthenticationManager(); } +} diff --git a/backend/src/main/java/com/zioinfo/mall/config/WebSocketConfig.java b/backend/src/main/java/com/zioinfo/mall/config/WebSocketConfig.java new file mode 100644 index 0000000..efa93da --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/config/WebSocketConfig.java @@ -0,0 +1,22 @@ +package com.zioinfo.mall.config; + +import com.zioinfo.mall.realtime.OrderWebSocketHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +/** 실시간 주문 알림 WebSocket — /ws/mall/orders. */ +@Configuration +@EnableWebSocket +@RequiredArgsConstructor +public class WebSocketConfig implements WebSocketConfigurer { + + private final OrderWebSocketHandler orderWebSocketHandler; + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(orderWebSocketHandler, "/ws/mall/orders").setAllowedOrigins("*"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/cs/CsController.java b/backend/src/main/java/com/zioinfo/mall/cs/CsController.java new file mode 100644 index 0000000..a7b83af --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/cs/CsController.java @@ -0,0 +1,70 @@ +package com.zioinfo.mall.cs; + +import com.zioinfo.mall.ai.MallAiService; +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.cs.mapper.CsMapper; +import com.zioinfo.mall.integration.ItsmClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 고객문의 API — /api/mall/cs. + * 문의 등록 시 ITSM SR 자동 생성 + Ollama AI 자동응답 초안 작성(Java 폴백). + */ +@Slf4j +@RestController +@RequestMapping("/api/mall/cs") +@RequiredArgsConstructor +public class CsController { + + private final CsMapper mapper; + private final ItsmClient itsmClient; + private final MallAiService aiService; + + @GetMapping + public ApiResponse> mine(Authentication auth) { + return ApiResponse.ok(mapper.findByOwner(auth.getName())); + } + + @GetMapping("/admin") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> all(@RequestParam(required = false) String status) { + return ApiResponse.ok(mapper.findAll(status)); + } + + /** 문의 등록 → ITSM SR 생성 + AI 자동응답. */ + @PostMapping + public ApiResponse create(@RequestBody MallCsTicket t, Authentication auth) { + t.setOwner(auth.getName()); + mapper.insert(t); + // ITSM SR 자동 생성 (실패 무시) + try { + String srId = itsmClient.createSr("[Mall CS] " + t.getSubject(), + t.getContent(), "MEDIUM"); + if (srId != null) mapper.setSr(t.getId(), srId); + } catch (Exception e) { + log.warn("CS→ITSM SR 생성 실패(무시): {}", e.getMessage()); + } + // AI 자동응답 초안 + try { + String reply = aiService.csAutoReply(t.getSubject(), t.getContent()); + if (reply != null && !reply.isBlank()) mapper.setAiReply(t.getId(), reply); + } catch (Exception e) { + log.warn("CS AI 자동응답 실패(무시): {}", e.getMessage()); + } + return ApiResponse.ok(mapper.findById(t.getId())); + } + + @PutMapping("/{id}/status") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse status(@PathVariable Long id, @RequestBody Map req) { + mapper.updateStatus(id, req.getOrDefault("status", "CLOSED")); + return ApiResponse.ok(mapper.findById(id)); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/cs/MallCsTicket.java b/backend/src/main/java/com/zioinfo/mall/cs/MallCsTicket.java new file mode 100644 index 0000000..e9a42ca --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/cs/MallCsTicket.java @@ -0,0 +1,20 @@ +package com.zioinfo.mall.cs; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 고객 문의 (mall_cs_ticket). ITSM SR 연계 + AI 자동응답. */ +@Data +public class MallCsTicket { + private Long id; + private String owner; + private String orderNo; + private String category; + private String subject; + private String content; + private String status; // OPEN / ANSWERED / CLOSED + private String itsmSrId; + private String aiReply; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/cs/mapper/CsMapper.java b/backend/src/main/java/com/zioinfo/mall/cs/mapper/CsMapper.java new file mode 100644 index 0000000..2acfb5b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/cs/mapper/CsMapper.java @@ -0,0 +1,18 @@ +package com.zioinfo.mall.cs.mapper; + +import com.zioinfo.mall.cs.MallCsTicket; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface CsMapper { + List findByOwner(@Param("owner") String owner); + List findAll(@Param("status") String status); + MallCsTicket findById(@Param("id") Long id); + int insert(MallCsTicket t); + int setSr(@Param("id") Long id, @Param("srId") String srId); + int setAiReply(@Param("id") Long id, @Param("aiReply") String aiReply); + int updateStatus(@Param("id") Long id, @Param("status") String status); +} diff --git a/backend/src/main/java/com/zioinfo/mall/delivery/DeliveryController.java b/backend/src/main/java/com/zioinfo/mall/delivery/DeliveryController.java new file mode 100644 index 0000000..1ee31f4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/delivery/DeliveryController.java @@ -0,0 +1,67 @@ +package com.zioinfo.mall.delivery; + +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.delivery.mapper.DeliveryMapper; +import com.zioinfo.mall.order.OrderService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 배송 API — /api/mall/delivery. + * + *

송장 등록·상태 변경은 운영(MANAGER+). 주문별 배송 조회는 주문 소유 고객. + * 배송 상태가 DELIVERED 되면 주문 상태도 SHIPPED→DELIVERED 로 연동. + */ +@RestController +@RequestMapping("/api/mall/delivery") +@RequiredArgsConstructor +public class DeliveryController { + + private final DeliveryMapper mapper; + private final OrderService orderService; + + @GetMapping("/order/{orderId}") + public ApiResponse byOrder(@PathVariable Long orderId, Authentication auth) { + orderService.detail(orderId, auth.getName(), isAdmin(auth)); + return ApiResponse.ok(mapper.findByOrderId(orderId)); + } + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> list(@RequestParam(required = false) String status, + @RequestParam(defaultValue = "200") int limit) { + return ApiResponse.ok(mapper.findAll(status, limit)); + } + + /** 송장 등록(출고) — 주문 PREPARING→SHIPPED 연동. */ + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse create(@RequestBody MallDelivery d, Authentication auth) { + d.setStatus("IN_TRANSIT"); + mapper.insert(d); + try { orderService.transition(d.getOrderId(), auth.getName(), true, "SHIPPED"); } catch (Exception ignored) {} + return ApiResponse.ok(mapper.findById(d.getId())); + } + + @PutMapping("/{id}/status") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse status(@PathVariable Long id, @RequestBody Map req, Authentication auth) { + String to = req.get("status"); + mapper.updateStatus(id, to); + if ("DELIVERED".equals(to)) { + MallDelivery d = mapper.findById(id); + try { orderService.transition(d.getOrderId(), auth.getName(), true, "DELIVERED"); } catch (Exception ignored) {} + } + return ApiResponse.ok(mapper.findById(id)); + } + + private boolean isAdmin(Authentication auth) { + return auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_MANAGER")); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/delivery/MallDelivery.java b/backend/src/main/java/com/zioinfo/mall/delivery/MallDelivery.java new file mode 100644 index 0000000..74a19e0 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/delivery/MallDelivery.java @@ -0,0 +1,20 @@ +package com.zioinfo.mall.delivery; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 배송 (mall_delivery). 송장 번호·택배사·상태. */ +@Data +public class MallDelivery { + private Long id; + private Long orderId; + private String carrier; // 택배사 + private String trackingNo; // 송장번호 + private String status; // READY / IN_TRANSIT / DELIVERED + private String receiverName; + private String address; + private LocalDateTime shippedAt; + private LocalDateTime deliveredAt; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/delivery/mapper/DeliveryMapper.java b/backend/src/main/java/com/zioinfo/mall/delivery/mapper/DeliveryMapper.java new file mode 100644 index 0000000..e804cec --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/delivery/mapper/DeliveryMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mall.delivery.mapper; + +import com.zioinfo.mall.delivery.MallDelivery; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface DeliveryMapper { + MallDelivery findById(@Param("id") Long id); + MallDelivery findByOrderId(@Param("orderId") Long orderId); + List findAll(@Param("status") String status, @Param("limit") int limit); + int insert(MallDelivery d); + int update(MallDelivery d); + int updateStatus(@Param("id") Long id, @Param("status") String status); +} diff --git a/backend/src/main/java/com/zioinfo/mall/event/EventController.java b/backend/src/main/java/com/zioinfo/mall/event/EventController.java new file mode 100644 index 0000000..4b25dcd --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/event/EventController.java @@ -0,0 +1,112 @@ +package com.zioinfo.mall.event; + +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.loyalty.LoyaltyService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 이벤트/캠페인 API — /api/mall/event. + * + *

    + *
  • 진행중 이벤트/배너 조회 — 공개(스토어프론트)
  • + *
  • 참여(쿠폰 발급) — 인증 고객
  • + *
  • 이벤트 정의 CRUD·발행·성과·AI 카피 — MANAGER+ (정의/발행) · ADMIN(삭제)
  • + *
+ * 공개 GET 화이트리스트는 SecurityConfig 에 등록. + */ +@RestController +@RequestMapping("/api/mall/event") +@RequiredArgsConstructor +public class EventController { + + private final EventService service; + private final LoyaltyService loyaltyService; + + // ---------- 공개 스토어프론트 ---------- + /** 진행중 이벤트(등급 필터 옵션). */ + @GetMapping("/ongoing") + public ApiResponse> ongoing(@RequestParam(required = false) String tier) { + return ApiResponse.ok(service.ongoing(tier)); + } + + /** 진행중 이벤트 배너(홈 캐러셀). */ + @GetMapping("/banners") + public ApiResponse> banners() { + return ApiResponse.ok(service.activeBanners()); + } + + @GetMapping("/{id}") + public ApiResponse detail(@PathVariable Long id) { + return ApiResponse.ok(service.detail(id)); + } + + // ---------- 참여 (인증) ---------- + /** 이벤트 참여 — 현재 등급 기준 자격 검증 후 연계 쿠폰 발급. */ + @PostMapping("/{id}/join") + public ApiResponse> join(@PathVariable Long id, Authentication auth) { + String tier = loyaltyService.currentTierCode(auth.getName()); + return ApiResponse.ok(service.join(id, auth.getName(), tier)); + } + + // ---------- 관리자 (MANAGER+) ---------- + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> list(@RequestParam(required = false) String status, + @RequestParam(required = false) String type, + @RequestParam(defaultValue = "false") boolean activeOnly) { + return ApiResponse.ok(service.list(status, type, activeOnly)); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse create(@RequestBody MallEvent e, Authentication auth) { + return ApiResponse.ok(service.create(e, auth.getName())); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse update(@PathVariable Long id, @RequestBody MallEvent e, Authentication auth) { + return ApiResponse.ok(service.update(id, e, auth.getName())); + } + + @PostMapping("/{id}/publish") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse publish(@PathVariable Long id, Authentication auth) { + return ApiResponse.ok(service.publish(id, auth.getName())); + } + + @PostMapping("/{id}/end") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse end(@PathVariable Long id, Authentication auth) { + return ApiResponse.ok(service.end(id, auth.getName())); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + service.delete(id, auth.getName()); + return ApiResponse.ok(null); + } + + /** 이벤트 성과(참여/매출). */ + @GetMapping("/{id}/performance") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> performance(@PathVariable Long id) { + return ApiResponse.ok(service.performance(id)); + } + + /** AI 이벤트 카피 보조(헤드라인/서브). */ + @PostMapping("/ai/copy") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> copy(@RequestBody CopyRequest req) { + return ApiResponse.ok(service.copyAssist(req.eventType(), req.theme(), req.tone())); + } + + public record CopyRequest(String eventType, String theme, String tone) {} +} diff --git a/backend/src/main/java/com/zioinfo/mall/event/EventService.java b/backend/src/main/java/com/zioinfo/mall/event/EventService.java new file mode 100644 index 0000000..b4b65bf --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/event/EventService.java @@ -0,0 +1,204 @@ +package com.zioinfo.mall.event; + +import com.zioinfo.mall.admin.AuditService; +import com.zioinfo.mall.ai.OllamaClient; +import com.zioinfo.mall.event.mapper.EventMapper; +import com.zioinfo.mall.promotion.MallCoupon; +import com.zioinfo.mall.promotion.mapper.PromotionMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.*; + +/** + * 이벤트/캠페인 서비스 — 발행·참여(연계 쿠폰 자동 발급)·AI 카피 보조·성과 집계. + * + *

연계 쿠폰은 promotion 모듈(mall_coupon)을 재사용한다. AI 카피는 Ollama(localhost) + * + Java 폴백. 외부 호출 없음. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class EventService { + + private final EventMapper mapper; + private final PromotionMapper promotionMapper; + private final OllamaClient ollama; + private final AuditService auditService; + + // ---------- 조회 ---------- + public List list(String status, String type, boolean activeOnly) { + return mapper.findEvents(status, type, activeOnly); + } + + /** 진행중 이벤트(스토어프론트). 등급 지정 시 전체대상 + 해당 등급 전용만. */ + public List ongoing(String tier) { + List events = mapper.findOngoing(tier); + for (MallEvent e : events) e.setBanners(mapper.findBanners(e.getId(), true)); + return events; + } + + public MallEvent detail(Long id) { + MallEvent e = mapper.findById(id); + if (e == null) throw new RuntimeException("ERR-EVT-404: 이벤트를 찾을 수 없습니다"); + e.setBanners(mapper.findBanners(id, false)); + return e; + } + + /** 진행중 이벤트의 활성 배너(홈 캐러셀). */ + public List activeBanners() { + return mapper.findActiveBanners(); + } + + // ---------- CRUD ---------- + @Transactional + public MallEvent create(MallEvent e, String actor) { + e.setCreatedBy(actor); + mapper.insert(e); + if (e.getBanners() != null) { + for (MallEventBanner b : e.getBanners()) { + b.setEventId(e.getId()); + mapper.insertBanner(b); + } + } + auditService.log(actor, "EVENT_CREATE", e.getCode() == null ? String.valueOf(e.getId()) : e.getCode(), e.getTitle()); + return detail(e.getId()); + } + + @Transactional + public MallEvent update(Long id, MallEvent e, String actor) { + e.setId(id); + mapper.update(e); + if (e.getBanners() != null) { + mapper.deleteBannersByEvent(id); + for (MallEventBanner b : e.getBanners()) { + b.setEventId(id); + mapper.insertBanner(b); + } + } + auditService.log(actor, "EVENT_UPDATE", String.valueOf(id), e.getTitle()); + return detail(id); + } + + @Transactional + public MallEvent publish(Long id, String actor) { + MallEvent e = mapper.findById(id); + if (e == null) throw new RuntimeException("ERR-EVT-404: 이벤트를 찾을 수 없습니다"); + mapper.updateStatus(id, "PUBLISHED"); + auditService.log(actor, "EVENT_PUBLISH", String.valueOf(id), e.getTitle()); + return detail(id); + } + + @Transactional + public MallEvent end(Long id, String actor) { + mapper.updateStatus(id, "ENDED"); + auditService.log(actor, "EVENT_END", String.valueOf(id), null); + return detail(id); + } + + @Transactional + public void delete(Long id, String actor) { + mapper.deleteBannersByEvent(id); + mapper.delete(id); + auditService.log(actor, "EVENT_DELETE", String.valueOf(id), null); + } + + // ---------- 참여 (연계 쿠폰 자동 발급) ---------- + /** + * 이벤트 참여 — 등급 자격 검증 후 연계 쿠폰을 발급(발급수 증가)하고 참여 이력 기록. + * 쿠폰 미연계 이벤트는 참여 이력만 기록. + */ + @Transactional + public Map join(Long eventId, String owner, String memberTier) { + MallEvent e = mapper.findById(eventId); + if (e == null) throw new RuntimeException("ERR-EVT-404: 이벤트를 찾을 수 없습니다"); + if (!"PUBLISHED".equals(e.getStatus()) || !Boolean.TRUE.equals(e.getActive())) { + throw new RuntimeException("ERR-EVT-409: 진행중 이벤트가 아닙니다"); + } + if (!isEligibleTier(e.getTargetTiers(), memberTier)) { + throw new RuntimeException("ERR-EVT-403: 이벤트 대상 등급이 아닙니다"); + } + Map out = new LinkedHashMap<>(); + out.put("eventId", eventId); + out.put("eventCode", e.getCode()); + String reward = null; + Long couponId = e.getCouponId(); + if (couponId != null) { + MallCoupon c = promotionMapper.findCouponById(couponId); + if (c != null && Boolean.TRUE.equals(c.getActive())) { + promotionMapper.incCouponIssued(couponId); + out.put("coupon", Map.of("id", c.getId(), "code", c.getCode(), "name", c.getName())); + reward = "COUPON:" + c.getCode(); + } + } + if (e.getBonusPointRate() != null) { + out.put("bonusPointRate", e.getBonusPointRate()); + if (reward == null) reward = "BONUS_POINT:" + e.getBonusPointRate(); + } + if (e.getGiftDescription() != null && !e.getGiftDescription().isBlank()) { + out.put("gift", e.getGiftDescription()); + if (reward == null) reward = "GIFT"; + } + mapper.insertParticipation(eventId, owner, couponId, null, reward); + out.put("joined", true); + return out; + } + + // ---------- 성과 ---------- + public Map performance(Long eventId) { + MallEvent e = mapper.findById(eventId); + if (e == null) throw new RuntimeException("ERR-EVT-404: 이벤트를 찾을 수 없습니다"); + Map perf = mapper.performance(eventId); + Map out = new LinkedHashMap<>(perf == null ? Map.of() : perf); + out.put("eventId", eventId); + out.put("title", e.getTitle()); + out.put("status", e.getStatus()); + return out; + } + + // ---------- AI 카피 보조 ---------- + /** 이벤트 카피(헤드라인/서브) 생성 보조. Ollama 실패 시 템플릿 폴백. */ + public Map copyAssist(String eventType, String theme, String tone) { + String prompt = "You are a flower-shop marketing copywriter. Write a punchy event banner for " + + "type='" + eventType + "' theme='" + (theme == null ? "" : theme) + "' tone='" + + (tone == null ? "warm" : tone) + "'. Reply as: HEADLINE | SUBTEXT."; + String ai = ollama.generate(prompt); + Map out = new LinkedHashMap<>(); + if (ai != null && !ai.isBlank() && ai.contains("|")) { + String[] parts = ai.split("\\|", 2); + out.put("headline", parts[0].trim()); + out.put("subtext", parts[1].trim()); + out.put("source", "ollama"); + } else { + String[] fb = fallbackCopy(eventType, theme); + out.put("headline", fb[0]); + out.put("subtext", fb[1]); + out.put("source", "fallback"); + } + return out; + } + + // ---------- helpers ---------- + private boolean isEligibleTier(String targetTiers, String memberTier) { + if (targetTiers == null || targetTiers.isBlank()) return true; // 전체 대상 + if (memberTier == null) return false; + for (String t : targetTiers.split(",")) { + if (t.trim().equalsIgnoreCase(memberTier.trim())) return true; + } + return false; + } + + private String[] fallbackCopy(String eventType, String theme) { + String t = theme == null || theme.isBlank() ? "Fresh Blooms" : theme; + return switch (eventType == null ? "" : eventType.toUpperCase()) { + case "DISCOUNT" -> new String[]{t + " Sale", "Save on hand-tied bouquets — limited time only."}; + case "POINT_BONUS" -> new String[]{"Double Points Event", "Earn bonus loyalty points on every " + t + " order."}; + case "GIFT" -> new String[]{"Free Gift With Purchase", "A little something extra with your " + t + "."}; + case "TIER_ONLY" -> new String[]{"Members-Only Preview", "Exclusive early access to " + t + " for our top members."}; + case "SEASON" -> new String[]{t + " Seasonal Collection", "Celebrate the season with our florist's picks."}; + default -> new String[]{t, "Discover our latest floral arrangements."}; + }; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/event/MallEvent.java b/backend/src/main/java/com/zioinfo/mall/event/MallEvent.java new file mode 100644 index 0000000..c19ad14 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/event/MallEvent.java @@ -0,0 +1,37 @@ +package com.zioinfo.mall.event; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 이벤트 / 캠페인 (mall_event). + * + *

eventType: DISCOUNT / POINT_BONUS / GIFT / TIER_ONLY / SEASON. + * status: DRAFT / PUBLISHED / ENDED. targetTiers·storeIds 는 콤마구분 문자열(빈값=전체). + */ +@Data +public class MallEvent { + private Long id; + private String code; + private String title; + private String eventType; // DISCOUNT/POINT_BONUS/GIFT/TIER_ONLY/SEASON + private String description; + private LocalDate startDate; + private LocalDate endDate; + private String targetTiers; // 'GOLD,VIP' (빈값=전체 등급) + private String storeIds; // '1,2' (빈값=전 매장/권역) + private Long couponId; // 연계 쿠폰(참여 시 발급) + private BigDecimal bonusPointRate; // POINT_BONUS 추가 적립률(%) + private String giftDescription; // GIFT 사은품 설명 + private String status; // DRAFT/PUBLISHED/ENDED + private Boolean active; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private List banners; +} diff --git a/backend/src/main/java/com/zioinfo/mall/event/MallEventBanner.java b/backend/src/main/java/com/zioinfo/mall/event/MallEventBanner.java new file mode 100644 index 0000000..a98b037 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/event/MallEventBanner.java @@ -0,0 +1,16 @@ +package com.zioinfo.mall.event; + +import lombok.Data; + +/** 이벤트 스토어프론트 배너 (mall_event_banner). */ +@Data +public class MallEventBanner { + private Long id; + private Long eventId; + private String imageUrl; + private String headline; + private String subtext; + private String linkUrl; + private Integer sortOrder; + private Boolean active; +} diff --git a/backend/src/main/java/com/zioinfo/mall/event/mapper/EventMapper.java b/backend/src/main/java/com/zioinfo/mall/event/mapper/EventMapper.java new file mode 100644 index 0000000..d4b7e4e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/event/mapper/EventMapper.java @@ -0,0 +1,36 @@ +package com.zioinfo.mall.event.mapper; + +import com.zioinfo.mall.event.MallEvent; +import com.zioinfo.mall.event.MallEventBanner; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface EventMapper { + // 이벤트 + List findEvents(@Param("status") String status, @Param("type") String type, + @Param("activeOnly") Boolean activeOnly); + List findOngoing(@Param("tier") String tier); + MallEvent findById(@Param("id") Long id); + MallEvent findByCode(@Param("code") String code); + int insert(MallEvent e); + int update(MallEvent e); + int updateStatus(@Param("id") Long id, @Param("status") String status); + int delete(@Param("id") Long id); + + // 배너 + List findBanners(@Param("eventId") Long eventId, @Param("activeOnly") Boolean activeOnly); + List findActiveBanners(); + int insertBanner(MallEventBanner b); + int deleteBanner(@Param("id") Long id); + int deleteBannersByEvent(@Param("eventId") Long eventId); + + // 참여/성과 + int insertParticipation(@Param("eventId") Long eventId, @Param("owner") String owner, + @Param("couponId") Long couponId, @Param("orderNo") String orderNo, + @Param("reward") String reward); + Map performance(@Param("eventId") Long eventId); +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/AddressVerifier.java b/backend/src/main/java/com/zioinfo/mall/gateway/AddressVerifier.java new file mode 100644 index 0000000..b0d6b2e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/AddressVerifier.java @@ -0,0 +1,20 @@ +package com.zioinfo.mall.gateway; + +import java.util.Map; + +/** 주소 검증/지오코딩 추상화. 기본 mock, 운영 시 GoogleMaps/Smarty. */ +public interface AddressVerifier { + /** + * 주소 검증 + 지오코딩. + * @return { provider, valid, normalized, lat, lng } + */ + Map verify(String address, String zip); + + /** + * 기사 라우팅 — 다중 주소 최적 동선 정렬. + * @return { provider, ordered:[{seq,address,lat,lng,eta}] } + */ + Map route(java.util.List addresses, String origin); + + String provider(); +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/AvalaraCalculator.java b/backend/src/main/java/com/zioinfo/mall/gateway/AvalaraCalculator.java new file mode 100644 index 0000000..82e5cfb --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/AvalaraCalculator.java @@ -0,0 +1,33 @@ +package com.zioinfo.mall.gateway; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * Avalara AvaTax 어댑터 (mall.tax.provider=avalara). 키는 환경변수 AVALARA_API_KEY만. + * 키 없으면 비활성. 실연동은 운영 배포 시 구현. + */ +@Component +@ConditionalOnProperty(name = "mall.tax.provider", havingValue = "avalara") +public class AvalaraCalculator implements TaxCalculator { + + @Value("${AVALARA_API_KEY:}") + private String apiKey; + + @Override + public Map calculate(BigDecimal amount, String zip, String state) { + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalStateException("ERR-TAX-CONFIG: AVALARA_API_KEY 미설정"); + } + throw new UnsupportedOperationException("ERR-TAX-IMPL: Avalara 실연동은 운영 배포 시 구현"); + } + + @Override + public String provider() { + return "avalara"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/EmailSender.java b/backend/src/main/java/com/zioinfo/mall/gateway/EmailSender.java new file mode 100644 index 0000000..75dbe6b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/EmailSender.java @@ -0,0 +1,11 @@ +package com.zioinfo.mall.gateway; + +import java.util.Map; + +/** 이메일 발송 추상화. 기본 mock, 운영 시 SendGrid. 영수증/주문확인. */ +public interface EmailSender { + /** @return { provider, messageId, status } */ + Map send(String toEmail, String subject, String htmlBody); + + String provider(); +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/GatewayController.java b/backend/src/main/java/com/zioinfo/mall/gateway/GatewayController.java new file mode 100644 index 0000000..e233ac3 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/GatewayController.java @@ -0,0 +1,65 @@ +package com.zioinfo.mall.gateway; + +import com.zioinfo.mall.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 게이트웨이 어댑터 API — /api/mall/gateway. + * 세금 견적·주소 검증은 체크아웃에 필요(인증 고객). 라우팅은 MANAGER+. provider 상태는 MANAGER+. + */ +@RestController +@RequestMapping("/api/mall/gateway") +@RequiredArgsConstructor +public class GatewayController { + + private final TaxCalculator taxCalculator; + private final AddressVerifier addressVerifier; + private final PaymentGateway paymentGateway; + private final SmsSender smsSender; + private final EmailSender emailSender; + + /** 판매세 견적(체크아웃). */ + @PostMapping("/tax/quote") + public ApiResponse> taxQuote(@RequestBody Map req) { + BigDecimal amount = new BigDecimal(String.valueOf(req.getOrDefault("amount", "0"))); + String zip = (String) req.get("zip"); + String state = (String) req.get("state"); + return ApiResponse.ok(taxCalculator.calculate(amount, zip, state)); + } + + /** 배송 주소 검증(체크아웃). */ + @PostMapping("/address/verify") + public ApiResponse> verify(@RequestBody Map req) { + return ApiResponse.ok(addressVerifier.verify(req.get("address"), req.get("zip"))); + } + + /** 기사 라우팅 — 다중 주소 최적 동선 (관리자앱 ④). */ + @PostMapping("/address/route") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + @SuppressWarnings("unchecked") + public ApiResponse> route(@RequestBody Map req) { + List addresses = (List) req.getOrDefault("addresses", List.of()); + String origin = (String) req.get("origin"); + return ApiResponse.ok(addressVerifier.route(addresses, origin)); + } + + /** 활성 provider 상태(운영 점검). 키 자체는 노출하지 않음. */ + @GetMapping("/providers") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> providers() { + Map out = new LinkedHashMap<>(); + out.put("payment", paymentGateway.provider()); + out.put("tax", taxCalculator.provider()); + out.put("address", addressVerifier.provider()); + out.put("sms", smsSender.provider()); + out.put("email", emailSender.provider()); + return ApiResponse.ok(out); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/GoogleMapsVerifier.java b/backend/src/main/java/com/zioinfo/mall/gateway/GoogleMapsVerifier.java new file mode 100644 index 0000000..12dc2d4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/GoogleMapsVerifier.java @@ -0,0 +1,43 @@ +package com.zioinfo.mall.gateway; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * Google Maps Places/Directions 어댑터 (mall.address.provider=google). + * 키는 환경변수 GOOGLE_MAPS_API_KEY만. 키 없으면 비활성. 실연동은 운영 배포 시 구현. + */ +@Component +@ConditionalOnProperty(name = "mall.address.provider", havingValue = "google") +public class GoogleMapsVerifier implements AddressVerifier { + + @Value("${GOOGLE_MAPS_API_KEY:}") + private String apiKey; + + private void requireKey() { + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalStateException("ERR-ADDR-CONFIG: GOOGLE_MAPS_API_KEY 미설정"); + } + } + + @Override + public Map verify(String address, String zip) { + requireKey(); + throw new UnsupportedOperationException("ERR-ADDR-IMPL: Google Maps 실연동은 운영 배포 시 구현"); + } + + @Override + public Map route(List addresses, String origin) { + requireKey(); + throw new UnsupportedOperationException("ERR-ADDR-IMPL: Google Maps 라우팅은 운영 배포 시 구현"); + } + + @Override + public String provider() { + return "google"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/MockAddressVerifier.java b/backend/src/main/java/com/zioinfo/mall/gateway/MockAddressVerifier.java new file mode 100644 index 0000000..4b00d07 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/MockAddressVerifier.java @@ -0,0 +1,56 @@ +package com.zioinfo.mall.gateway; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Mock 주소 검증기 (기본). 외부 호출 0. + * ZIP 5자리 형식이면 valid 처리, 라우팅은 입력 순서를 유지하며 가짜 ETA 부여. + */ +@Component +@ConditionalOnProperty(name = "mall.address.provider", havingValue = "mock", matchIfMissing = true) +public class MockAddressVerifier implements AddressVerifier { + + @Override + public Map verify(String address, String zip) { + boolean valid = zip != null && zip.matches("\\d{5}") && address != null && !address.isBlank(); + Map out = new LinkedHashMap<>(); + out.put("provider", "mock"); + out.put("valid", valid); + out.put("normalized", address == null ? "" : address.trim()); + out.put("lat", 40.0 + (zip == null ? 0 : (zip.hashCode() % 1000) / 10000.0)); + out.put("lng", -74.0 - (zip == null ? 0 : (Math.abs(zip.hashCode()) % 1000) / 10000.0)); + return out; + } + + @Override + public Map route(List addresses, String origin) { + List> ordered = new ArrayList<>(); + int seq = 1; + for (String a : addresses) { + Map stop = new LinkedHashMap<>(); + stop.put("seq", seq); + stop.put("address", a); + stop.put("lat", 40.0 + seq * 0.01); + stop.put("lng", -74.0 - seq * 0.01); + stop.put("eta", (seq * 15) + " min"); + ordered.add(stop); + seq++; + } + Map out = new LinkedHashMap<>(); + out.put("provider", "mock"); + out.put("origin", origin); + out.put("ordered", ordered); + return out; + } + + @Override + public String provider() { + return "mock"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/MockEmailSender.java b/backend/src/main/java/com/zioinfo/mall/gateway/MockEmailSender.java new file mode 100644 index 0000000..eda09a7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/MockEmailSender.java @@ -0,0 +1,31 @@ +package com.zioinfo.mall.gateway; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** Mock 이메일 발송기 (기본). 외부 호출 0 — 로그만 남기고 성공 반환. */ +@Slf4j +@Component +@ConditionalOnProperty(name = "mall.email.provider", havingValue = "mock", matchIfMissing = true) +public class MockEmailSender implements EmailSender { + + @Override + public Map send(String toEmail, String subject, String htmlBody) { + log.info("[MOCK-EMAIL] to={} subject={}", toEmail, subject); + Map out = new LinkedHashMap<>(); + out.put("provider", "mock"); + out.put("messageId", "MOCK-EMAIL-" + UUID.randomUUID().toString().substring(0, 10)); + out.put("status", "SENT"); + return out; + } + + @Override + public String provider() { + return "mock"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/MockPaymentGateway.java b/backend/src/main/java/com/zioinfo/mall/gateway/MockPaymentGateway.java new file mode 100644 index 0000000..28b6e7e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/MockPaymentGateway.java @@ -0,0 +1,46 @@ +package com.zioinfo.mall.gateway; + +import com.zioinfo.mall.common.CryptoUtil; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * Mock 결제 게이트웨이 (기본). 외부 호출 0 — 개발/데모용. + * 항상 승인 처리하고 가짜 txnId를 발급한다. + */ +@Component +@ConditionalOnProperty(name = "mall.payment.provider", havingValue = "mock", matchIfMissing = true) +public class MockPaymentGateway implements PaymentGateway { + + @Override + public Map charge(String orderNo, BigDecimal amount, String currency, String cardToken) { + Map out = new LinkedHashMap<>(); + out.put("provider", "mock"); + out.put("txnId", "MOCK-" + UUID.randomUUID().toString().substring(0, 12)); + out.put("status", "APPROVED"); + out.put("amount", amount); + out.put("currency", currency == null ? "USD" : currency); + out.put("cardMask", CryptoUtil.maskTail(cardToken)); + return out; + } + + @Override + public Map refund(String txnId, BigDecimal amount) { + Map out = new LinkedHashMap<>(); + out.put("provider", "mock"); + out.put("txnId", txnId); + out.put("status", "REFUNDED"); + out.put("amount", amount); + return out; + } + + @Override + public String provider() { + return "mock"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/MockSmsSender.java b/backend/src/main/java/com/zioinfo/mall/gateway/MockSmsSender.java new file mode 100644 index 0000000..68bea38 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/MockSmsSender.java @@ -0,0 +1,46 @@ +package com.zioinfo.mall.gateway; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** Mock SMS/MMS 발송기 (기본). 외부 호출 0 — 로그만 남기고 성공 반환. */ +@Slf4j +@Component +@ConditionalOnProperty(name = "mall.sms.provider", havingValue = "mock", matchIfMissing = true) +public class MockSmsSender implements SmsSender { + + @Override + public Map sendSms(String toPhone, String body) { + log.info("[MOCK-SMS] to={} body={}", mask(toPhone), body); + return result("SMS"); + } + + @Override + public Map sendMms(String toPhone, String body, String mediaUrl) { + log.info("[MOCK-MMS] to={} media={} body={}", mask(toPhone), mediaUrl, body); + return result("MMS"); + } + + private Map result(String type) { + Map out = new LinkedHashMap<>(); + out.put("provider", "mock"); + out.put("messageId", "MOCK-" + type + "-" + UUID.randomUUID().toString().substring(0, 10)); + out.put("status", "SENT"); + return out; + } + + private String mask(String phone) { + if (phone == null || phone.length() < 4) return "****"; + return "***" + phone.substring(phone.length() - 4); + } + + @Override + public String provider() { + return "mock"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/MockTaxCalculator.java b/backend/src/main/java/com/zioinfo/mall/gateway/MockTaxCalculator.java new file mode 100644 index 0000000..1ab4332 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/MockTaxCalculator.java @@ -0,0 +1,43 @@ +package com.zioinfo.mall.gateway; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Mock 판매세 계산기 (기본). 외부 호출 0. + * 주(state)별 대표 세율 테이블로 근사 계산한다(데모용). + */ +@Component +@ConditionalOnProperty(name = "mall.tax.provider", havingValue = "mock", matchIfMissing = true) +public class MockTaxCalculator implements TaxCalculator { + + private static final Map STATE_RATE = Map.ofEntries( + Map.entry("NY", new BigDecimal("0.08875")), Map.entry("MA", new BigDecimal("0.0625")), + Map.entry("IL", new BigDecimal("0.1025")), Map.entry("CA", new BigDecimal("0.0725")), + Map.entry("WA", new BigDecimal("0.1025")), Map.entry("FL", new BigDecimal("0.07")), + Map.entry("GA", new BigDecimal("0.089")), Map.entry("TX", new BigDecimal("0.0825")) + ); + + @Override + public Map calculate(BigDecimal amount, String zip, String state) { + BigDecimal rate = STATE_RATE.getOrDefault(state == null ? "" : state.toUpperCase(), new BigDecimal("0.07")); + BigDecimal tax = amount == null ? BigDecimal.ZERO + : amount.multiply(rate).setScale(2, RoundingMode.HALF_UP); + Map out = new LinkedHashMap<>(); + out.put("provider", "mock"); + out.put("rate", rate); + out.put("taxAmount", tax); + out.put("jurisdiction", (state == null ? "US" : state) + (zip == null ? "" : " " + zip)); + return out; + } + + @Override + public String provider() { + return "mock"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/PaymentGateway.java b/backend/src/main/java/com/zioinfo/mall/gateway/PaymentGateway.java new file mode 100644 index 0000000..afbff79 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/PaymentGateway.java @@ -0,0 +1,18 @@ +package com.zioinfo.mall.gateway; + +import java.math.BigDecimal; +import java.util.Map; + +/** 결제 게이트웨이 추상화. 기본 mock, 운영 시 Stripe. */ +public interface PaymentGateway { + /** + * 결제 승인. + * @return { provider, txnId, status(APPROVED/DECLINED), cardMask } + */ + Map charge(String orderNo, BigDecimal amount, String currency, String cardToken); + + /** 환불. @return { txnId, status(REFUNDED/FAILED) } */ + Map refund(String txnId, BigDecimal amount); + + String provider(); +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/SendGridEmailSender.java b/backend/src/main/java/com/zioinfo/mall/gateway/SendGridEmailSender.java new file mode 100644 index 0000000..cc8e1fb --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/SendGridEmailSender.java @@ -0,0 +1,32 @@ +package com.zioinfo.mall.gateway; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * SendGrid 이메일 어댑터 (mall.email.provider=sendgrid). + * 키는 환경변수 SENDGRID_API_KEY만. 키 없으면 비활성. + */ +@Component +@ConditionalOnProperty(name = "mall.email.provider", havingValue = "sendgrid") +public class SendGridEmailSender implements EmailSender { + + @Value("${SENDGRID_API_KEY:}") + private String apiKey; + + @Override + public Map send(String toEmail, String subject, String htmlBody) { + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalStateException("ERR-EMAIL-CONFIG: SENDGRID_API_KEY 미설정"); + } + throw new UnsupportedOperationException("ERR-EMAIL-IMPL: SendGrid 실연동은 운영 배포 시 구현"); + } + + @Override + public String provider() { + return "sendgrid"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/SmartyVerifier.java b/backend/src/main/java/com/zioinfo/mall/gateway/SmartyVerifier.java new file mode 100644 index 0000000..bed2b40 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/SmartyVerifier.java @@ -0,0 +1,45 @@ +package com.zioinfo.mall.gateway; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; + +/** + * Smarty(SmartyStreets) 주소 검증 어댑터 (mall.address.provider=smarty). + * 키는 환경변수 SMARTY_AUTH_ID/SMARTY_AUTH_TOKEN만. 키 없으면 비활성. + */ +@Component +@ConditionalOnProperty(name = "mall.address.provider", havingValue = "smarty") +public class SmartyVerifier implements AddressVerifier { + + @Value("${SMARTY_AUTH_ID:}") + private String authId; + @Value("${SMARTY_AUTH_TOKEN:}") + private String authToken; + + private void requireKey() { + if (authId == null || authId.isBlank() || authToken == null || authToken.isBlank()) { + throw new IllegalStateException("ERR-ADDR-CONFIG: SMARTY 인증정보 미설정"); + } + } + + @Override + public Map verify(String address, String zip) { + requireKey(); + throw new UnsupportedOperationException("ERR-ADDR-IMPL: Smarty 실연동은 운영 배포 시 구현"); + } + + @Override + public Map route(List addresses, String origin) { + requireKey(); + throw new UnsupportedOperationException("ERR-ADDR-IMPL: Smarty는 라우팅 미지원 — Google 사용"); + } + + @Override + public String provider() { + return "smarty"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/SmsSender.java b/backend/src/main/java/com/zioinfo/mall/gateway/SmsSender.java new file mode 100644 index 0000000..cc5eafa --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/SmsSender.java @@ -0,0 +1,14 @@ +package com.zioinfo.mall.gateway; + +import java.util.Map; + +/** SMS/MMS 발송 추상화. 기본 mock, 운영 시 Twilio. 배송 SMS·꽃사진 MMS. */ +public interface SmsSender { + /** 텍스트 SMS. @return { provider, messageId, status } */ + Map sendSms(String toPhone, String body); + + /** 꽃사진 MMS(Proof of Quality). @return { provider, messageId, status } */ + Map sendMms(String toPhone, String body, String mediaUrl); + + String provider(); +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/StripeGateway.java b/backend/src/main/java/com/zioinfo/mall/gateway/StripeGateway.java new file mode 100644 index 0000000..3e5eb83 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/StripeGateway.java @@ -0,0 +1,49 @@ +package com.zioinfo.mall.gateway; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * Stripe 결제 어댑터 (운영 시 활성, mall.payment.provider=stripe). + * + *

API 키는 환경변수(STRIPE_SECRET_KEY)에서만 주입. 평문 코드 금지. + * 키가 없으면 비활성(예외)으로 동작하여 실수로 외부 호출되지 않게 한다. + * 실제 Stripe SDK 호출은 운영 배포 시 구현 — 스텁은 키 검증만 수행한다. + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "mall.payment.provider", havingValue = "stripe") +public class StripeGateway implements PaymentGateway { + + @Value("${STRIPE_SECRET_KEY:}") + private String secretKey; + + private void requireKey() { + if (secretKey == null || secretKey.isBlank()) { + throw new IllegalStateException("ERR-PAY-CONFIG: STRIPE_SECRET_KEY 미설정 — Stripe 어댑터 비활성"); + } + } + + @Override + public Map charge(String orderNo, BigDecimal amount, String currency, String cardToken) { + requireKey(); + // 운영 배포 시 Stripe PaymentIntents API 연동. + throw new UnsupportedOperationException("ERR-PAY-IMPL: Stripe 실연동은 운영 배포 시 구현"); + } + + @Override + public Map refund(String txnId, BigDecimal amount) { + requireKey(); + throw new UnsupportedOperationException("ERR-PAY-IMPL: Stripe 환불은 운영 배포 시 구현"); + } + + @Override + public String provider() { + return "stripe"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/TaxCalculator.java b/backend/src/main/java/com/zioinfo/mall/gateway/TaxCalculator.java new file mode 100644 index 0000000..75fe44f --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/TaxCalculator.java @@ -0,0 +1,15 @@ +package com.zioinfo.mall.gateway; + +import java.math.BigDecimal; +import java.util.Map; + +/** 판매세 계산 추상화. 기본 mock, 운영 시 TaxJar/Avalara (목적지 기반). */ +public interface TaxCalculator { + /** + * 목적지 기반 판매세 계산. + * @return { provider, rate, taxAmount, jurisdiction } + */ + Map calculate(BigDecimal amount, String zip, String state); + + String provider(); +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/TaxJarCalculator.java b/backend/src/main/java/com/zioinfo/mall/gateway/TaxJarCalculator.java new file mode 100644 index 0000000..f60d3ee --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/TaxJarCalculator.java @@ -0,0 +1,33 @@ +package com.zioinfo.mall.gateway; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.util.Map; + +/** + * TaxJar 어댑터 (mall.tax.provider=taxjar). 키는 환경변수 TAXJAR_API_KEY만. + * 키 없으면 비활성. 실연동은 운영 배포 시 구현. + */ +@Component +@ConditionalOnProperty(name = "mall.tax.provider", havingValue = "taxjar") +public class TaxJarCalculator implements TaxCalculator { + + @Value("${TAXJAR_API_KEY:}") + private String apiKey; + + @Override + public Map calculate(BigDecimal amount, String zip, String state) { + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalStateException("ERR-TAX-CONFIG: TAXJAR_API_KEY 미설정"); + } + throw new UnsupportedOperationException("ERR-TAX-IMPL: TaxJar 실연동은 운영 배포 시 구현"); + } + + @Override + public String provider() { + return "taxjar"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/gateway/TwilioSmsSender.java b/backend/src/main/java/com/zioinfo/mall/gateway/TwilioSmsSender.java new file mode 100644 index 0000000..2f49352 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/gateway/TwilioSmsSender.java @@ -0,0 +1,46 @@ +package com.zioinfo.mall.gateway; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * Twilio SMS/MMS 어댑터 (mall.sms.provider=twilio). + * 키는 환경변수 TWILIO_ACCOUNT_SID/TWILIO_AUTH_TOKEN/TWILIO_FROM만. 키 없으면 비활성. + */ +@Component +@ConditionalOnProperty(name = "mall.sms.provider", havingValue = "twilio") +public class TwilioSmsSender implements SmsSender { + + @Value("${TWILIO_ACCOUNT_SID:}") + private String accountSid; + @Value("${TWILIO_AUTH_TOKEN:}") + private String authToken; + @Value("${TWILIO_FROM:}") + private String fromNumber; + + private void requireKey() { + if (accountSid == null || accountSid.isBlank() || authToken == null || authToken.isBlank()) { + throw new IllegalStateException("ERR-SMS-CONFIG: Twilio 인증정보 미설정"); + } + } + + @Override + public Map sendSms(String toPhone, String body) { + requireKey(); + throw new UnsupportedOperationException("ERR-SMS-IMPL: Twilio SMS 실연동은 운영 배포 시 구현"); + } + + @Override + public Map sendMms(String toPhone, String body, String mediaUrl) { + requireKey(); + throw new UnsupportedOperationException("ERR-SMS-IMPL: Twilio MMS 실연동은 운영 배포 시 구현"); + } + + @Override + public String provider() { + return "twilio"; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/integration/CrmClient.java b/backend/src/main/java/com/zioinfo/mall/integration/CrmClient.java new file mode 100644 index 0000000..1c63f09 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/integration/CrmClient.java @@ -0,0 +1,54 @@ +package com.zioinfo.mall.integration; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Collections; +import java.util.Map; + +/** + * GUARDiA CRM API 클라이언트. + * + *

회원=고객 연계. 주문 이력을 CRM 고객 인사이트로 전달한다. + * 응답은 새니타이저로 정제 후 반환. 실패 시 빈 Map, 스택트레이스 미노출. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CrmClient { + + @Value("${guardia.crm-url:http://localhost:8004}") + private String crmUrl; + + private final WebClient.Builder webClientBuilder; + + @SuppressWarnings("unchecked") + public Map call(String path, HttpMethod method, Object body) { + try { + WebClient.RequestBodySpec spec = webClientBuilder.baseUrl(crmUrl).build().method(method).uri(path); + WebClient.RequestHeadersSpec headersSpec = + (body != null && method != HttpMethod.GET) ? spec.bodyValue(body) : spec; + Map result = headersSpec.retrieve() + .bodyToMono(Map.class).map(m -> (Map) m).block(); + if (result == null) return Collections.emptyMap(); + return (Map) ItsmSecuritySanitizer.clean(result); + } catch (Exception e) { + log.warn("CRM 연동 일시 실패 [{} {}]: {}", method, path, e.getMessage()); + return Collections.emptyMap(); + } + } + + /** 회원 가입/주문 시 CRM 고객 upsert. */ + public Map upsertCustomer(Map customer) { + return call("/api/crm/customers/upsert", HttpMethod.POST, customer); + } + + /** CRM 고객 인사이트 조회. */ + public Map getCustomerInsight(String customerKey) { + return call("/api/crm/customers/" + customerKey + "/insight", HttpMethod.GET, null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/integration/ErpClient.java b/backend/src/main/java/com/zioinfo/mall/integration/ErpClient.java new file mode 100644 index 0000000..b00ed97 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/integration/ErpClient.java @@ -0,0 +1,54 @@ +package com.zioinfo.mall.integration; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Collections; +import java.util.Map; + +/** + * GUARDiA ERP API 클라이언트. + * + *

재고 동기화·정산(매입/매출 전표) 연계. 응답은 새니타이저로 정제 후 반환. + * 실패 시 빈 Map 반환, 스택트레이스 미노출. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ErpClient { + + @Value("${guardia.erp-url:http://localhost:8003}") + private String erpUrl; + + private final WebClient.Builder webClientBuilder; + + @SuppressWarnings("unchecked") + public Map call(String path, HttpMethod method, Object body) { + try { + WebClient.RequestBodySpec spec = webClientBuilder.baseUrl(erpUrl).build().method(method).uri(path); + WebClient.RequestHeadersSpec headersSpec = + (body != null && method != HttpMethod.GET) ? spec.bodyValue(body) : spec; + Map result = headersSpec.retrieve() + .bodyToMono(Map.class).map(m -> (Map) m).block(); + if (result == null) return Collections.emptyMap(); + return (Map) ItsmSecuritySanitizer.clean(result); + } catch (Exception e) { + log.warn("ERP 연동 일시 실패 [{} {}]: {}", method, path, e.getMessage()); + return Collections.emptyMap(); + } + } + + /** ERP 재고 조회 (SKU 기준). */ + public Map getStock(String sku) { + return call("/api/erp/inventory/" + sku, HttpMethod.GET, null); + } + + /** ERP 매출 전표 생성 (정산 연계). */ + public Map postSalesVoucher(Map voucher) { + return call("/api/erp/finance/sales-voucher", HttpMethod.POST, voucher); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/integration/IntegrationController.java b/backend/src/main/java/com/zioinfo/mall/integration/IntegrationController.java new file mode 100644 index 0000000..7dc2d50 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/integration/IntegrationController.java @@ -0,0 +1,40 @@ +package com.zioinfo.mall.integration; + +import com.zioinfo.mall.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** + * GUARDiA 연계 조회 API — /api/mall/integration. + * + *

모든 응답은 ItsmSecuritySanitizer 로 정제된 상태로 반환된다(자격증명 제거). + */ +@RestController +@RequestMapping("/api/mall/integration") +@RequiredArgsConstructor +public class IntegrationController { + + private final ItsmClient itsm; + private final ErpClient erp; + private final CrmClient crm; + + /** ITSM SR 상태 조회 (CS 연계). */ + @GetMapping("/itsm/sr/{srId}") + public ApiResponse> sr(@PathVariable String srId) { + return ApiResponse.ok(itsm.getSr(srId)); + } + + /** ERP 재고 조회 (SKU). */ + @GetMapping("/erp/stock/{sku}") + public ApiResponse> erpStock(@PathVariable String sku) { + return ApiResponse.ok(erp.getStock(sku)); + } + + /** CRM 고객 인사이트 조회. */ + @GetMapping("/crm/customer/{key}/insight") + public ApiResponse> crmInsight(@PathVariable("key") String key) { + return ApiResponse.ok(crm.getCustomerInsight(key)); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/integration/ItsmClient.java b/backend/src/main/java/com/zioinfo/mall/integration/ItsmClient.java new file mode 100644 index 0000000..2828f42 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/integration/ItsmClient.java @@ -0,0 +1,76 @@ +package com.zioinfo.mall.integration; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * GUARDiA ITSM API 클라이언트. + * + *

고객 문의(CS)를 ITSM SR로 자동 생성한다. 모든 응답은 + * {@link ItsmSecuritySanitizer#clean(Object)}로 자격증명을 제거한 뒤 반환한다. + * 실패 시 빈 Map/List 반환, 스택트레이스 미노출 (요약 로그만). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class ItsmClient { + + @Value("${guardia.itsm-url:http://localhost:9001}") + private String itsmUrl; + + private final WebClient.Builder webClientBuilder; + + private WebClient client() { + return webClientBuilder.baseUrl(itsmUrl).build(); + } + + @SuppressWarnings("unchecked") + public Map call(String path, HttpMethod method, Object body) { + try { + WebClient.RequestBodySpec spec = client().method(method).uri(path); + WebClient.RequestHeadersSpec headersSpec = + (body != null && method != HttpMethod.GET) ? spec.bodyValue(body) : spec; + Map result = headersSpec.retrieve() + .bodyToMono(Map.class) + .map(m -> (Map) m) + .block(); + if (result == null) { + return Collections.emptyMap(); + } + return (Map) ItsmSecuritySanitizer.clean(result); + } catch (Exception e) { + log.warn("ITSM 연동 일시 실패 [{} {}]: {}", method, path, e.getMessage()); + return Collections.emptyMap(); + } + } + + /** + * CS 문의 → ITSM SR 생성. + * + * @return 생성된 SR ID, 실패 시 null + */ + public String createSr(String title, String content, String priority) { + Map payload = Map.of( + "title", title, + "content", content, + "priority", priority == null ? "MEDIUM" : priority, + "source", "MALL" + ); + Map result = call("/api/tasks", HttpMethod.POST, payload); + Object id = result.getOrDefault("sr_id", result.get("id")); + return id != null ? String.valueOf(id) : null; + } + + /** SR 상태 조회. */ + public Map getSr(String srId) { + return call("/api/tasks/" + srId, HttpMethod.GET, null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/integration/ItsmSecuritySanitizer.java b/backend/src/main/java/com/zioinfo/mall/integration/ItsmSecuritySanitizer.java new file mode 100644 index 0000000..6889aa6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/integration/ItsmSecuritySanitizer.java @@ -0,0 +1,64 @@ +package com.zioinfo.mall.integration; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 외부 GUARDiA 연동 응답 보안 새니타이저. + * + *

GUARDiA 보안 불변 규칙: 서버 자격증명(IP/SSH 계정/비밀번호 등)을 + * API 응답에 절대 노출하지 않는다. ITSM/ERP/CRM 응답을 Mall로 반환하기 전 + * 반드시 {@link #clean(Object)}를 호출한다. + * + *

Map/List 구조를 재귀적으로 순회하며 민감 필드를 제거한다. + */ +public final class ItsmSecuritySanitizer { + + /** 응답에서 완전 제거할 민감 필드 키 (대소문자 무시). */ + private static final Set SENSITIVE_KEYS = Set.of( + "ip_addr", + "ssh_user", + "os_pw_enc", + "password", + "ssh_key", + "secret", + "token", + "card_no_enc", + "account_no_enc" + ); + + private ItsmSecuritySanitizer() { + } + + public static Object clean(Object data) { + if (data instanceof Map map) { + Map cleaned = new LinkedHashMap<>(); + for (Map.Entry entry : map.entrySet()) { + String key = String.valueOf(entry.getKey()); + if (isSensitive(key)) { + continue; + } + cleaned.put(key, clean(entry.getValue())); + } + return cleaned; + } + if (data instanceof List list) { + List cleaned = new ArrayList<>(list.size()); + for (Object item : list) { + cleaned.add(clean(item)); + } + return cleaned; + } + return data; + } + + private static boolean isSensitive(String key) { + if (key == null) { + return false; + } + return SENSITIVE_KEYS.contains(key.toLowerCase()); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/inventory/InventoryController.java b/backend/src/main/java/com/zioinfo/mall/inventory/InventoryController.java new file mode 100644 index 0000000..5c7554e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/inventory/InventoryController.java @@ -0,0 +1,60 @@ +package com.zioinfo.mall.inventory; + +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.integration.ErpClient; +import com.zioinfo.mall.inventory.mapper.InventoryMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** 재고 API — /api/mall/inventory. 조회/변경 MANAGER+. ERP 동기화 연계. */ +@RestController +@RequestMapping("/api/mall/inventory") +@RequiredArgsConstructor +@PreAuthorize("hasAnyRole('ADMIN','MANAGER')") +public class InventoryController { + + private final InventoryMapper mapper; + private final ErpClient erpClient; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) Boolean lowStock) { + return ApiResponse.ok(mapper.findAll(lowStock)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(mapper.findById(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MallInventory inv) { + mapper.insert(inv); + return ApiResponse.ok(mapper.findById(inv.getId())); + } + + @PutMapping("/{id}/adjust") + public ApiResponse adjust(@PathVariable Long id, @RequestBody Map req) { + mapper.adjust(id, req.getOrDefault("delta", 0)); + return ApiResponse.ok(mapper.findById(id)); + } + + /** ERP 재고 동기화 — ErpClient.getStock(sku) 결과 반영. 실패 시 PENDING 유지. */ + @PostMapping("/{id}/erp-sync") + public ApiResponse erpSync(@PathVariable Long id) { + MallInventory inv = mapper.findById(id); + if (inv == null) throw new RuntimeException("ERR-INV-404: 재고 정보 없음"); + Map erp = erpClient.getStock(inv.getSku()); + Object qty = erp.get("onHand"); + if (qty == null) qty = erp.get("quantity"); + if (qty != null) { + mapper.setErpSync(id, "SYNCED", ((Number) qty).intValue()); + } else { + mapper.setErpSync(id, "PENDING", null); + } + return ApiResponse.ok(mapper.findById(id)); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/inventory/MallInventory.java b/backend/src/main/java/com/zioinfo/mall/inventory/MallInventory.java new file mode 100644 index 0000000..565ab6a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/inventory/MallInventory.java @@ -0,0 +1,19 @@ +package com.zioinfo.mall.inventory; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 재고 (mall_inventory). productId/optionId 단위. ERP 연계 동기화. */ +@Data +public class MallInventory { + private Long id; + private Long productId; + private Long optionId; + private String sku; + private Integer onHand; // 가용 재고 + private Integer reserved; // 예약(주문 미출고) + private Integer safetyStock; + private String erpSyncStatus; // SYNCED / PENDING / NONE + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/inventory/MallStoreInventory.java b/backend/src/main/java/com/zioinfo/mall/inventory/MallStoreInventory.java new file mode 100644 index 0000000..f7b38f0 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/inventory/MallStoreInventory.java @@ -0,0 +1,19 @@ +package com.zioinfo.mall.inventory; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 매장별 당일 재고 (mall_store_inventory). available=ON/OFF 토글. */ +@Data +public class MallStoreInventory { + private Long id; + private Long storeId; + private Long productId; + private String productName; // 조인 표시용 + private String thumbnail; // 조인 표시용 + private Boolean available; // 당일 입고 ON/OFF + private Integer onHand; + private String updatedBy; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/inventory/StoreInventoryController.java b/backend/src/main/java/com/zioinfo/mall/inventory/StoreInventoryController.java new file mode 100644 index 0000000..b5f9f8f --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/inventory/StoreInventoryController.java @@ -0,0 +1,58 @@ +package com.zioinfo.mall.inventory; + +import com.zioinfo.mall.admin.AuditService; +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.inventory.mapper.StoreInventoryMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 매장별 당일 재고 ON/OFF API — /api/mall/store-inventory. + * 관리자앱 '원터치' 토글. StoreManager는 자기 매장만(서비스 가드는 호출자 검증 권장). + */ +@RestController +@RequestMapping("/api/mall/store-inventory") +@RequiredArgsConstructor +@PreAuthorize("hasAnyRole('ADMIN','MANAGER')") +public class StoreInventoryController { + + private final StoreInventoryMapper mapper; + private final AuditService auditService; + + /** 매장별 마스터 상품 리스트(토글 화면). */ + @GetMapping("/store/{storeId}") + public ApiResponse> byStore(@PathVariable Long storeId) { + return ApiResponse.ok(mapper.findByStore(storeId)); + } + + /** 원터치 ON/OFF 토글 — OFF 시 해당 지역 온라인 즉시 품절. */ + @PutMapping("/store/{storeId}/product/{productId}/toggle") + public ApiResponse toggle(@PathVariable Long storeId, @PathVariable Long productId, + @RequestBody Map req, Authentication auth) { + boolean available = req.getOrDefault("available", true); + mapper.setAvailable(storeId, productId, available, auth.getName()); + auditService.log(auth.getName(), "STORE_INV_TOGGLE", + "store=" + storeId + ",product=" + productId, available ? "ON" : "OFF"); + return ApiResponse.ok(mapper.findOne(storeId, productId)); + } + + /** 재고 수량 조정. */ + @PutMapping("/store/{storeId}/product/{productId}/adjust") + public ApiResponse adjust(@PathVariable Long storeId, @PathVariable Long productId, + @RequestBody Map req) { + mapper.adjustOnHand(storeId, productId, req.getOrDefault("delta", 0)); + return ApiResponse.ok(mapper.findOne(storeId, productId)); + } + + @PostMapping("/upsert") + public ApiResponse upsert(@RequestBody MallStoreInventory si, Authentication auth) { + si.setUpdatedBy(auth.getName()); + mapper.upsert(si); + return ApiResponse.ok(mapper.findOne(si.getStoreId(), si.getProductId())); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/inventory/mapper/InventoryMapper.java b/backend/src/main/java/com/zioinfo/mall/inventory/mapper/InventoryMapper.java new file mode 100644 index 0000000..e079a8f --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/inventory/mapper/InventoryMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mall.inventory.mapper; + +import com.zioinfo.mall.inventory.MallInventory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface InventoryMapper { + List findAll(@Param("lowStock") Boolean lowStock); + MallInventory findById(@Param("id") Long id); + MallInventory findBySku(@Param("sku") String sku); + int insert(MallInventory inv); + int adjust(@Param("id") Long id, @Param("delta") int delta); + int setErpSync(@Param("id") Long id, @Param("status") String status, @Param("onHand") Integer onHand); +} diff --git a/backend/src/main/java/com/zioinfo/mall/inventory/mapper/StoreInventoryMapper.java b/backend/src/main/java/com/zioinfo/mall/inventory/mapper/StoreInventoryMapper.java new file mode 100644 index 0000000..40922a3 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/inventory/mapper/StoreInventoryMapper.java @@ -0,0 +1,20 @@ +package com.zioinfo.mall.inventory.mapper; + +import com.zioinfo.mall.inventory.MallStoreInventory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface StoreInventoryMapper { + /** 매장별 마스터 상품 리스트(ON/OFF 토글용). */ + List findByStore(@Param("storeId") Long storeId); + MallStoreInventory findOne(@Param("storeId") Long storeId, @Param("productId") Long productId); + /** 특정 ZIP의 매장들에서 가용한 상품 ID 집합(스토어프론트 필터). */ + List availableProductIds(@Param("storeId") Long storeId); + int upsert(MallStoreInventory si); + int setAvailable(@Param("storeId") Long storeId, @Param("productId") Long productId, + @Param("available") boolean available, @Param("updatedBy") String updatedBy); + int adjustOnHand(@Param("storeId") Long storeId, @Param("productId") Long productId, @Param("delta") int delta); +} diff --git a/backend/src/main/java/com/zioinfo/mall/loyalty/LoyaltyController.java b/backend/src/main/java/com/zioinfo/mall/loyalty/LoyaltyController.java new file mode 100644 index 0000000..77e386d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/loyalty/LoyaltyController.java @@ -0,0 +1,125 @@ +package com.zioinfo.mall.loyalty; + +import com.zioinfo.mall.admin.AuditService; +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.loyalty.mapper.LoyaltyMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * 등급별 고객관리 API — /api/mall/loyalty. + * + *
    + *
  • 내 등급/혜택/포인트 조회·사용 — 인증 고객
  • + *
  • 등급 기준·혜택 정의 CRUD, 등급 재산정, 포인트 조정 — ADMIN
  • + *
  • 등급별 매출 — MANAGER+
  • + *
+ * 등급 혜택 목록(GET /tiers)은 스토어프론트 노출을 위해 공개(SecurityConfig 화이트리스트). + */ +@RestController +@RequestMapping("/api/mall/loyalty") +@RequiredArgsConstructor +public class LoyaltyController { + + private final LoyaltyService service; + private final LoyaltyMapper mapper; + + // ---------- 등급 혜택 정의 ---------- + /** 등급 혜택 목록 — 공개(스토어프론트 등급 안내). */ + @GetMapping("/tiers") + public ApiResponse> tiers(@RequestParam(defaultValue = "true") boolean activeOnly) { + return ApiResponse.ok(service.tiers(activeOnly)); + } + + @GetMapping("/tiers/{code}") + public ApiResponse tier(@PathVariable String code) { + return ApiResponse.ok(service.tierByCode(code)); + } + + @PostMapping("/tiers") + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse createTier(@RequestBody MallTierBenefit t) { + mapper.insertTier(t); + return ApiResponse.ok(mapper.findTierById(t.getId())); + } + + @PutMapping("/tiers/{id}") + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse updateTier(@PathVariable Long id, @RequestBody MallTierBenefit t) { + t.setId(id); + mapper.updateTier(t); + return ApiResponse.ok(mapper.findTierById(id)); + } + + @DeleteMapping("/tiers/{id}") + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse deleteTier(@PathVariable Long id) { + mapper.deleteTier(id); + return ApiResponse.ok(null); + } + + // ---------- 내 등급 ---------- + /** 내 등급/혜택/누적/포인트 통합 조회. */ + @GetMapping("/me") + public ApiResponse> me(Authentication auth) { + return ApiResponse.ok(service.myStatus(auth.getName())); + } + + // ---------- 포인트 ---------- + @GetMapping("/points/balance") + public ApiResponse> balance(Authentication auth) { + return ApiResponse.ok(Map.of("owner", auth.getName(), "balance", service.balance(auth.getName()))); + } + + @GetMapping("/points/history") + public ApiResponse> history( + @RequestParam(defaultValue = "100") int limit, Authentication auth) { + return ApiResponse.ok(service.history(auth.getName(), limit)); + } + + /** 포인트 사용(결제 시 차감). */ + @PostMapping("/points/use") + public ApiResponse> use(@RequestBody UsePointRequest req, Authentication auth) { + return ApiResponse.ok(service.usePoints(auth.getName(), req.points(), req.orderNo())); + } + + /** 관리자 포인트 조정(가감). */ + @PostMapping("/points/adjust") + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse> adjust(@RequestBody AdjustRequest req, Authentication auth) { + return ApiResponse.ok(service.adjust(req.owner(), req.points(), req.reason(), auth.getName())); + } + + // ---------- 등급 재산정 ---------- + /** 단일 고객 등급 재산정 (본인 또는 ADMIN). */ + @PostMapping("/recalc") + public ApiResponse> recalc(@RequestParam(required = false) String owner, + Authentication auth) { + boolean admin = auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN")); + String target = (admin && owner != null && !owner.isBlank()) ? owner : auth.getName(); + return ApiResponse.ok(service.recalcOne(target)); + } + + /** 전체 회원 등급 일괄 재산정(배치성) — ADMIN. */ + @PostMapping("/recalc-all") + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse> recalcAll() { + return ApiResponse.ok(service.recalcAll()); + } + + // ---------- 등급별 매출 ---------- + @GetMapping("/analytics/by-tier") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse>> byTier(@RequestParam(defaultValue = "30") int days) { + return ApiResponse.ok(service.salesByTier(days)); + } + + public record UsePointRequest(int points, String orderNo) {} + public record AdjustRequest(String owner, int points, String reason) {} +} diff --git a/backend/src/main/java/com/zioinfo/mall/loyalty/LoyaltyService.java b/backend/src/main/java/com/zioinfo/mall/loyalty/LoyaltyService.java new file mode 100644 index 0000000..4807b40 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/loyalty/LoyaltyService.java @@ -0,0 +1,247 @@ +package com.zioinfo.mall.loyalty; + +import com.zioinfo.mall.admin.AuditService; +import com.zioinfo.mall.loyalty.mapper.LoyaltyMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.util.*; + +/** + * 등급별 고객관리 서비스 — 등급 자동 산정 + 포인트 적립/사용/만료. + * + *

등급은 최근 12개월 누적 구매액·주문수를 등급 기준(mall_tier_benefit)과 비교하여 + * 충족하는 가장 높은 등급으로 자동 산정한다. 산정 결과는 mall_member.tier 로 sync 한다. + * 포인트 적립은 등급별 적립률(pointEarnRate)을 적용한다. 외부 호출 없음. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class LoyaltyService { + + private final LoyaltyMapper mapper; + private final AuditService auditService; + + private static final int POINT_EXPIRE_DAYS = 365; + + /** 등급 혜택 목록(rank ASC). */ + public List tiers(boolean activeOnly) { + return mapper.findTiers(activeOnly); + } + + public MallTierBenefit tierByCode(String code) { + return mapper.findTierByCode(code == null ? "BASIC" : code.toUpperCase()); + } + + /** 현재 자격 등급 코드(12개월 누적 기준). */ + public String currentTierCode(String owner) { + return qualify(mapper.spend12m(owner)).getTierCode(); + } + + /** + * 내 등급/혜택 + 12개월 누적 + 포인트 잔액 통합 조회. + * 저장된 등급(mall_member.tier)과 현재 자격을 함께 제공한다. + */ + public Map myStatus(String owner) { + Map spend = mapper.spend12m(owner); + MallTierBenefit qualified = qualify(spend); + Map out = new LinkedHashMap<>(); + out.put("owner", owner); + out.put("spend12m", spend.getOrDefault("spend", 0)); + out.put("orderCount12m", spend.getOrDefault("orderCount", 0)); + out.put("tier", qualified.getTierCode()); + out.put("tierName", qualified.getName()); + out.put("benefit", qualified); + out.put("pointBalance", balance(owner)); + out.put("nextTier", nextTierGap(qualified, spend)); + return out; + } + + /** 12개월 누적값으로 자격 등급 산정(가장 높은 충족 등급). 폴백: BASIC. */ + public MallTierBenefit qualify(Map spend12m) { + BigDecimal spend = toBig(spend12m.get("spend")); + long orders = toLong(spend12m.get("orderCount")); + List tiers = mapper.findTiers(true); + MallTierBenefit best = null; + for (MallTierBenefit t : tiers) { + BigDecimal minSpend = t.getMinSpend12m() == null ? BigDecimal.ZERO : t.getMinSpend12m(); + int minOrders = t.getMinOrders12m() == null ? 0 : t.getMinOrders12m(); + if (spend.compareTo(minSpend) >= 0 && orders >= minOrders) { + if (best == null || (t.getTierRank() != null && best.getTierRank() != null + && t.getTierRank() > best.getTierRank())) { + best = t; + } + } + } + if (best != null) return best; + MallTierBenefit basic = mapper.findTierByCode("BASIC"); + return basic != null ? basic : fallbackBasic(); + } + + /** 다음 등급까지 부족분(있으면). */ + private Map nextTierGap(MallTierBenefit current, Map spend12m) { + List tiers = mapper.findTiers(true); + MallTierBenefit next = null; + int curRank = current.getTierRank() == null ? 1 : current.getTierRank(); + for (MallTierBenefit t : tiers) { + if (t.getTierRank() != null && t.getTierRank() == curRank + 1) { next = t; break; } + } + if (next == null) return Map.of("isTop", true); + BigDecimal spend = toBig(spend12m.get("spend")); + long orders = toLong(spend12m.get("orderCount")); + BigDecimal spendGap = (next.getMinSpend12m() == null ? BigDecimal.ZERO : next.getMinSpend12m()) + .subtract(spend).max(BigDecimal.ZERO); + long orderGap = Math.max(0, (next.getMinOrders12m() == null ? 0 : next.getMinOrders12m()) - orders); + Map gap = new LinkedHashMap<>(); + gap.put("isTop", false); + gap.put("nextTier", next.getTierCode()); + gap.put("nextTierName", next.getName()); + gap.put("spendNeeded", spendGap); + gap.put("ordersNeeded", orderGap); + return gap; + } + + /** 단일 고객 등급 재산정 + member.tier sync. 변경 시 감사로그. */ + @Transactional + public Map recalcOne(String owner) { + Map spend = mapper.spend12m(owner); + MallTierBenefit q = qualify(spend); + mapper.updateMemberTier(owner, q.getTierCode()); + Map out = new LinkedHashMap<>(); + out.put("owner", owner); + out.put("tier", q.getTierCode()); + out.put("spend12m", spend.getOrDefault("spend", 0)); + out.put("orderCount12m", spend.getOrDefault("orderCount", 0)); + return out; + } + + /** 전체 회원 등급 일괄 재산정(배치성). */ + @Transactional + public Map recalcAll() { + List users = mapper.allMemberUsernames(); + int updated = 0; + Map dist = new LinkedHashMap<>(); + for (String u : users) { + MallTierBenefit q = qualify(mapper.spend12m(u)); + mapper.updateMemberTier(u, q.getTierCode()); + dist.merge(q.getTierCode(), 1, Integer::sum); + updated++; + } + auditService.log("LOYALTY_RECALC_ALL", "members", "count=" + updated); + Map out = new LinkedHashMap<>(); + out.put("recalculated", updated); + out.put("distribution", dist); + return out; + } + + // ----------------------------------------------------------- 포인트 + public int balance(String owner) { + Integer b = mapper.balance(owner); + return b == null ? 0 : b; + } + + public List history(String owner, int limit) { + return mapper.history(owner, limit <= 0 || limit > 500 ? 100 : limit); + } + + /** + * 결제 완료 시 등급별 적립률로 포인트 자동 적립(주문 흐름 훅). + * 이미 동일 주문번호로 EARN 기록이 있으면 중복 방지(idempotent). + */ + @Transactional + public int accrueForOrder(String owner, String orderNo, BigDecimal payAmount) { + if (owner == null || payAmount == null || payAmount.signum() <= 0) return 0; + MallTierBenefit tier = qualify(mapper.spend12m(owner)); + BigDecimal rate = tier.getPointEarnRate() == null ? BigDecimal.ONE : tier.getPointEarnRate(); + int points = payAmount.multiply(rate).divide(BigDecimal.valueOf(100), 0, RoundingMode.DOWN).intValue(); + if (points <= 0) return 0; + MallPointLedger e = new MallPointLedger(); + e.setOwner(owner); + e.setEntryType("EARN"); + e.setPoints(points); + e.setOrderNo(orderNo); + e.setReason("Order " + orderNo + " (" + tier.getTierCode() + " " + rate + "%)"); + e.setExpireAt(LocalDate.now().plusDays(POINT_EXPIRE_DAYS)); + mapper.insertPoint(e); + return points; + } + + /** 포인트 사용(차감). 잔액 부족 시 예외. */ + @Transactional + public Map usePoints(String owner, int points, String orderNo) { + if (points <= 0) throw new IllegalArgumentException("ERR-LOY-400: 사용 포인트는 1 이상이어야 합니다"); + int bal = balance(owner); + if (points > bal) throw new RuntimeException("ERR-LOY-409: 포인트 잔액 부족 (잔액 " + bal + ")"); + MallPointLedger e = new MallPointLedger(); + e.setOwner(owner); + e.setEntryType("USE"); + e.setPoints(-points); + e.setOrderNo(orderNo); + e.setReason(orderNo == null ? "Point redemption" : "Redeemed on order " + orderNo); + mapper.insertPoint(e); + Map out = new LinkedHashMap<>(); + out.put("used", points); + out.put("balance", balance(owner)); + return out; + } + + /** 관리자 포인트 조정(가감). */ + @Transactional + public Map adjust(String owner, int points, String reason, String actor) { + MallPointLedger e = new MallPointLedger(); + e.setOwner(owner); + e.setEntryType("ADJUST"); + e.setPoints(points); + e.setReason(reason == null ? "Admin adjustment" : reason); + mapper.insertPoint(e); + auditService.log(actor, "LOYALTY_POINT_ADJUST", owner, "points=" + points); + Map out = new LinkedHashMap<>(); + out.put("owner", owner); + out.put("adjusted", points); + out.put("balance", balance(owner)); + return out; + } + + /** + * VIP 우선 슬롯 판정 헬퍼 — 해당 고객 등급에 priority_slot 혜택이 있는지. + * schedule 모듈이 피크시즌 우선 배정 시 호출. + */ + public boolean hasPrioritySlot(String owner) { + MallTierBenefit t = qualify(mapper.spend12m(owner)); + return Boolean.TRUE.equals(t.getPrioritySlot()); + } + + public List> salesByTier(int days) { + return mapper.salesByTier(days <= 0 ? 30 : days); + } + + // ----------------------------------------------------------- helpers + private BigDecimal toBig(Object o) { + if (o == null) return BigDecimal.ZERO; + if (o instanceof BigDecimal b) return b; + if (o instanceof Number n) return BigDecimal.valueOf(n.doubleValue()); + try { return new BigDecimal(o.toString()); } catch (Exception e) { return BigDecimal.ZERO; } + } + + private long toLong(Object o) { + if (o == null) return 0L; + if (o instanceof Number n) return n.longValue(); + try { return Long.parseLong(o.toString()); } catch (Exception e) { return 0L; } + } + + private MallTierBenefit fallbackBasic() { + MallTierBenefit b = new MallTierBenefit(); + b.setTierCode("BASIC"); + b.setName("일반 (Basic)"); + b.setTierRank(1); + b.setDiscountRate(BigDecimal.ZERO); + b.setPointEarnRate(BigDecimal.ONE); + b.setPrioritySlot(false); + return b; + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/loyalty/MallPointLedger.java b/backend/src/main/java/com/zioinfo/mall/loyalty/MallPointLedger.java new file mode 100644 index 0000000..3422bc4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/loyalty/MallPointLedger.java @@ -0,0 +1,24 @@ +package com.zioinfo.mall.loyalty; + +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 포인트 원장 (mall_point_ledger). + * + *

entryType: EARN(적립 +) / USE(사용 -) / EXPIRE(만료 -) / ADJUST(조정). + * 잔액 = SUM(points). + */ +@Data +public class MallPointLedger { + private Long id; + private String owner; + private String entryType; // EARN/USE/EXPIRE/ADJUST + private Integer points; // 적립 +, 사용/만료 - + private String orderNo; + private String reason; + private LocalDate expireAt; // 적립분 만료일(EARN) + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/loyalty/MallTierBenefit.java b/backend/src/main/java/com/zioinfo/mall/loyalty/MallTierBenefit.java new file mode 100644 index 0000000..ad6bd3e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/loyalty/MallTierBenefit.java @@ -0,0 +1,31 @@ +package com.zioinfo.mall.loyalty; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 등급별 혜택 정의 (mall_tier_benefit). + * + *

tierCode: BASIC/SILVER/GOLD/VIP. 자동 산정 시 12개월 누적 구매액/주문수와 비교. + * 혜택 정의 CRUD는 ADMIN. + */ +@Data +public class MallTierBenefit { + private Long id; + private String tierCode; // BASIC/SILVER/GOLD/VIP + private String name; + private Integer tierRank; // 1=BASIC ... 4=VIP + private BigDecimal minSpend12m; // 등급 진입 누적 구매액(12개월) + private Integer minOrders12m; // 등급 진입 누적 주문수(12개월) + private BigDecimal discountRate; // 등급 할인율(%) + private BigDecimal freeShipThreshold; // 무료배송 임계액(NULL=없음, 0=항상 무료) + private BigDecimal pointEarnRate; // 포인트 적립률(% of pay_amount) + private Boolean prioritySlot; // 피크시즌 우선 타임슬롯 + private Boolean birthdayCoupon; // 생일 쿠폰/꽃 + private Boolean exclusiveEvents; // 전용 이벤트 접근 + private String color; + private Boolean active; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/loyalty/mapper/LoyaltyMapper.java b/backend/src/main/java/com/zioinfo/mall/loyalty/mapper/LoyaltyMapper.java new file mode 100644 index 0000000..8d76339 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/loyalty/mapper/LoyaltyMapper.java @@ -0,0 +1,35 @@ +package com.zioinfo.mall.loyalty.mapper; + +import com.zioinfo.mall.loyalty.MallPointLedger; +import com.zioinfo.mall.loyalty.MallTierBenefit; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface LoyaltyMapper { + // 등급 혜택 정의 + List findTiers(@Param("activeOnly") Boolean activeOnly); + MallTierBenefit findTierByCode(@Param("tierCode") String tierCode); + MallTierBenefit findTierById(@Param("id") Long id); + int insertTier(MallTierBenefit t); + int updateTier(MallTierBenefit t); + int deleteTier(@Param("id") Long id); + + // 12개월 누적 구매 집계 (주문 PAID 이후 상태) + Map spend12m(@Param("owner") String owner); + + // 회원 등급 sync (mall_member.tier) + int updateMemberTier(@Param("username") String username, @Param("tier") String tier); + List allMemberUsernames(); + + // 포인트 원장 + int insertPoint(MallPointLedger e); + Integer balance(@Param("owner") String owner); + List history(@Param("owner") String owner, @Param("limit") int limit); + + // 등급별 매출 (analytics) + List> salesByTier(@Param("days") int days); +} diff --git a/backend/src/main/java/com/zioinfo/mall/member/MallMember.java b/backend/src/main/java/com/zioinfo/mall/member/MallMember.java new file mode 100644 index 0000000..f0f5d59 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/member/MallMember.java @@ -0,0 +1,20 @@ +package com.zioinfo.mall.member; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 회원 (mall_member). CRM 고객과 연계. */ +@Data +public class MallMember { + private Long id; + private String username; + private String displayName; + private String email; + private String phone; + private String defaultZip; + private String defaultAddress; + private String crmCustomerKey; + private String tier; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/member/MemberController.java b/backend/src/main/java/com/zioinfo/mall/member/MemberController.java new file mode 100644 index 0000000..88d43cf --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/member/MemberController.java @@ -0,0 +1,44 @@ +package com.zioinfo.mall.member; + +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.integration.CrmClient; +import com.zioinfo.mall.integration.ItsmSecuritySanitizer; +import com.zioinfo.mall.member.mapper.MemberMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** 회원 API — /api/mall/member. 본인 프로필 + CRM 인사이트 연계(새니타이즈). */ +@RestController +@RequestMapping("/api/mall/member") +@RequiredArgsConstructor +public class MemberController { + + private final MemberMapper mapper; + private final CrmClient crmClient; + + @GetMapping("/me") + public ApiResponse me(Authentication auth) { + return ApiResponse.ok(mapper.findByUsername(auth.getName())); + } + + @PutMapping("/me") + public ApiResponse update(@RequestBody MallMember m, Authentication auth) { + m.setUsername(auth.getName()); + mapper.upsert(m); + return ApiResponse.ok(mapper.findByUsername(auth.getName())); + } + + /** CRM 고객 인사이트(구매 성향) — 응답은 ItsmSecuritySanitizer로 정제. */ + @GetMapping("/me/insight") + public ApiResponse> insight(Authentication auth) { + Map raw = crmClient.getCustomerInsight(auth.getName()); + Object cleaned = ItsmSecuritySanitizer.clean(raw == null ? new LinkedHashMap<>() : raw); + @SuppressWarnings("unchecked") + Map safe = (Map) cleaned; + return ApiResponse.ok(safe); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/member/mapper/MemberMapper.java b/backend/src/main/java/com/zioinfo/mall/member/mapper/MemberMapper.java new file mode 100644 index 0000000..b0d8f86 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/member/mapper/MemberMapper.java @@ -0,0 +1,11 @@ +package com.zioinfo.mall.member.mapper; + +import com.zioinfo.mall.member.MallMember; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +@Mapper +public interface MemberMapper { + MallMember findByUsername(@Param("username") String username); + int upsert(MallMember m); +} diff --git a/backend/src/main/java/com/zioinfo/mall/order/MallOrder.java b/backend/src/main/java/com/zioinfo/mall/order/MallOrder.java new file mode 100644 index 0000000..275cd97 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/order/MallOrder.java @@ -0,0 +1,44 @@ +package com.zioinfo.mall.order; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 주문 (mall_order). + * + *

상태 전이: PENDING → PAID → PREPARING → SHIPPED → DELIVERED → CONFIRMED + * (취소: CANCELLED, 환불: REFUNDED) + */ +@Data +public class MallOrder { + private Long id; + private String orderNo; + private String owner; // username (고객) + private Long storeId; // 처리 매장 + private String fulfillmentType; // DELIVERY / PICKUP + private String status; + private BigDecimal totalAmount; + private BigDecimal discountAmount; + private BigDecimal taxAmount; + private BigDecimal surgeAmount; + private BigDecimal payAmount; + private String receiverName; + private String receiverPhone; + private String address; + private String deliveryZip; + private java.time.LocalDate scheduledDate; + private Long slotId; + private String slotLabel; + private String cardMessage; + private String memo; + private Long couponId; + private String proofPhotoUrl; // 제작완료 꽃사진(Proof of Quality) + private String podPhotoUrl; // 문앞사진(Proof of Delivery) + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + private List items; +} diff --git a/backend/src/main/java/com/zioinfo/mall/order/MallOrderItem.java b/backend/src/main/java/com/zioinfo/mall/order/MallOrderItem.java new file mode 100644 index 0000000..8fcd343 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/order/MallOrderItem.java @@ -0,0 +1,20 @@ +package com.zioinfo.mall.order; + +import lombok.Data; + +import java.math.BigDecimal; + +/** 주문 항목 (mall_order_item). */ +@Data +public class MallOrderItem { + private Long id; + private Long orderId; + private Long productId; + private Long optionId; + private String sizeCode; + private String productName; + private String optionLabel; + private BigDecimal unitPrice; + private Integer quantity; + private BigDecimal lineAmount; +} diff --git a/backend/src/main/java/com/zioinfo/mall/order/OrderController.java b/backend/src/main/java/com/zioinfo/mall/order/OrderController.java new file mode 100644 index 0000000..0777315 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/order/OrderController.java @@ -0,0 +1,76 @@ +package com.zioinfo.mall.order; + +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.order.mapper.OrderMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** 주문 API — /api/mall/order. 인증 고객. 관리자 목록은 MANAGER+. */ +@RestController +@RequestMapping("/api/mall/order") +@RequiredArgsConstructor +public class OrderController { + + private final OrderService service; + private final OrderMapper mapper; + + @GetMapping + public ApiResponse> myOrders(@RequestParam(required = false) String status, Authentication auth) { + return ApiResponse.ok(service.myOrders(auth.getName(), status)); + } + + /** 전체 주문 목록 (운영) — MANAGER+. */ + @GetMapping("/admin") + @org.springframework.security.access.prepost.PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> allOrders(@RequestParam(required = false) String status, + @RequestParam(defaultValue = "200") int limit) { + return ApiResponse.ok(mapper.findAll(status, limit)); + } + + /** 매장별 주문 (Live Order Dashboard — Fulfillment Time 정렬) — MANAGER+. */ + @GetMapping("/store/{storeId}") + @org.springframework.security.access.prepost.PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> storeOrders(@PathVariable Long storeId, + @RequestParam(required = false) String status, + @RequestParam(defaultValue = "200") int limit) { + return ApiResponse.ok(mapper.findByStore(storeId, status, limit)); + } + + /** 제작완료 꽃사진(Proof of Quality) 업로드 → 고객 MMS 발송 — MANAGER+. */ + @PostMapping("/{id}/proof-photo") + @org.springframework.security.access.prepost.PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse proofPhoto(@PathVariable Long id, @RequestBody Map req, Authentication auth) { + return ApiResponse.ok(service.attachProofPhoto(id, req.get("photoUrl"), auth.getName())); + } + + /** 문앞사진(Proof of Delivery) 업로드 — MANAGER+. */ + @PostMapping("/{id}/pod-photo") + @org.springframework.security.access.prepost.PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse podPhoto(@PathVariable Long id, @RequestBody Map req, Authentication auth) { + return ApiResponse.ok(service.attachPodPhoto(id, req.get("photoUrl"), auth.getName())); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id, Authentication auth) { + return ApiResponse.ok(service.detail(id, auth.getName(), isAdmin(auth))); + } + + @PostMapping("/checkout") + public ApiResponse checkout(@RequestBody OrderService.CheckoutRequest req, Authentication auth) { + return ApiResponse.ok(service.checkout(auth.getName(), req)); + } + + @PutMapping("/{id}/status") + public ApiResponse transition(@PathVariable Long id, @RequestBody Map req, Authentication auth) { + return ApiResponse.ok(service.transition(id, auth.getName(), isAdmin(auth), req.get("status"))); + } + + private boolean isAdmin(Authentication auth) { + return auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_MANAGER")); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/order/OrderService.java b/backend/src/main/java/com/zioinfo/mall/order/OrderService.java new file mode 100644 index 0000000..9006ab8 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/order/OrderService.java @@ -0,0 +1,225 @@ +package com.zioinfo.mall.order; + +import com.zioinfo.mall.admin.AuditService; +import com.zioinfo.mall.cart.MallCartItem; +import com.zioinfo.mall.cart.mapper.CartMapper; +import com.zioinfo.mall.integration.CrmClient; +import com.zioinfo.mall.order.mapper.OrderMapper; +import com.zioinfo.mall.product.MallProduct; +import com.zioinfo.mall.product.mapper.ProductMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * 주문 서비스 — 체크아웃·상태 전이. + * + *

상태 전이 규칙(허용 맵)으로 비정상 전이를 차단한다. + * 결제 완료(PAID) 시 재고 차감·판매수 증가, 주문 이력을 CRM 고객 인사이트로 전달. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderMapper orderMapper; + private final CartMapper cartMapper; + private final ProductMapper productMapper; + private final CrmClient crmClient; + private final AuditService auditService; + private final com.zioinfo.mall.realtime.OrderNotifier orderNotifier; + private final com.zioinfo.mall.gateway.SmsSender smsSender; + private final com.zioinfo.mall.loyalty.LoyaltyService loyaltyService; + + /** 허용 상태 전이 그래프. */ + private static final Map> TRANSITIONS = Map.of( + "PENDING", Set.of("PAID", "CANCELLED"), + "PAID", Set.of("PREPARING", "CANCELLED", "REFUNDED"), + "PREPARING", Set.of("SHIPPED", "CANCELLED"), + "SHIPPED", Set.of("DELIVERED"), + "DELIVERED", Set.of("CONFIRMED", "REFUNDED"), + "CONFIRMED", Set.of("REFUNDED") + ); + + public List myOrders(String owner, String status) { + return orderMapper.findByOwner(owner, status); + } + + public MallOrder detail(Long id, String owner, boolean admin) { + MallOrder o = orderMapper.findById(id); + if (o == null) { + throw new RuntimeException("ERR-ORD-404: 주문을 찾을 수 없습니다"); + } + if (!admin && !o.getOwner().equals(owner)) { + throw new RuntimeException("ERR-ORD-403: 본인 주문만 조회할 수 있습니다"); + } + o.setItems(orderMapper.findItems(id)); + return o; + } + + /** 장바구니 기반 주문 생성(PENDING). */ + @Transactional + public MallOrder checkout(String owner, CheckoutRequest req) { + List cart = cartMapper.findByOwner(owner); + if (cart.isEmpty()) { + throw new RuntimeException("ERR-ORD-400: 장바구니가 비어 있습니다"); + } + MallOrder order = new MallOrder(); + order.setOrderNo(generateOrderNo()); + order.setOwner(owner); + order.setStoreId(req.storeId()); + order.setFulfillmentType(req.fulfillmentType() == null ? "DELIVERY" : req.fulfillmentType().toUpperCase()); + order.setStatus("PENDING"); + order.setReceiverName(req.receiverName()); + order.setReceiverPhone(req.receiverPhone()); + order.setAddress(req.address()); + order.setDeliveryZip(req.deliveryZip()); + order.setScheduledDate(req.scheduledDate()); + order.setSlotId(req.slotId()); + order.setSlotLabel(req.slotLabel()); + order.setCardMessage(req.cardMessage()); + order.setMemo(req.memo()); + order.setCouponId(req.couponId()); + + BigDecimal total = BigDecimal.ZERO; + List items = new ArrayList<>(); + for (MallCartItem ci : cart) { + BigDecimal unit = ci.getUnitPrice() == null ? BigDecimal.ZERO : ci.getUnitPrice(); + int qty = ci.getQuantity() == null ? 0 : ci.getQuantity(); + BigDecimal line = unit.multiply(BigDecimal.valueOf(qty)); + MallOrderItem it = new MallOrderItem(); + it.setProductId(ci.getProductId()); + it.setOptionId(ci.getOptionId()); + it.setSizeCode(ci.getSizeCode()); + it.setProductName(ci.getProductName()); + it.setOptionLabel(ci.getOptionLabel()); + it.setUnitPrice(unit); + it.setQuantity(qty); + it.setLineAmount(line); + items.add(it); + total = total.add(line); + } + BigDecimal discount = req.discountAmount() == null ? BigDecimal.ZERO : req.discountAmount(); + BigDecimal tax = req.taxAmount() == null ? BigDecimal.ZERO : req.taxAmount(); + BigDecimal surge = req.surgeAmount() == null ? BigDecimal.ZERO : req.surgeAmount(); + order.setTotalAmount(total); + order.setDiscountAmount(discount); + order.setTaxAmount(tax); + order.setSurgeAmount(surge); + order.setPayAmount(total.subtract(discount).add(tax).add(surge).max(BigDecimal.ZERO)); + orderMapper.insert(order); + for (MallOrderItem it : items) { + it.setOrderId(order.getId()); + orderMapper.insertItem(it); + } + cartMapper.clear(owner); + auditService.log(owner, "ORDER_CREATE", order.getOrderNo(), "amount=" + order.getPayAmount()); + order.setItems(items); + // 실시간 주문 알림 — 매장 관리자앱 Live Order Dashboard 푸시 + orderNotifier.broadcastNewOrder(order); + return order; + } + + /** 상태 전이 — 허용 맵 검증. */ + @Transactional + public MallOrder transition(Long id, String owner, boolean admin, String to) { + MallOrder o = detail(id, owner, admin); + String from = o.getStatus(); + if (to == null || to.isBlank()) { + throw new IllegalArgumentException("ERR-ORD-400: 대상 상태 필수"); + } + to = to.toUpperCase(); + Set allowed = TRANSITIONS.getOrDefault(from, Set.of()); + if (!allowed.contains(to)) { + throw new RuntimeException("ERR-ORD-409: 허용되지 않은 상태 전이 " + from + " -> " + to); + } + // 고객은 취소/구매확정만 가능 + if (!admin && !(to.equals("CANCELLED") || to.equals("CONFIRMED"))) { + throw new RuntimeException("ERR-ORD-403: 해당 상태 전이 권한 없음"); + } + orderMapper.updateStatus(id, to); + if ("PAID".equals(to)) { + for (MallOrderItem it : o.getItems()) { + productMapper.incSales(it.getProductId(), it.getQuantity()); + } + feedCrm(o); + // 등급별 적립률로 포인트 자동 적립 (실패 무시 — 주문 흐름 차단 금지) + try { + int earned = loyaltyService.accrueForOrder(o.getOwner(), o.getOrderNo(), o.getPayAmount()); + if (earned > 0) { + auditService.log(o.getOwner(), "LOYALTY_POINT_EARN", o.getOrderNo(), "points=" + earned); + } + } catch (Exception e) { + log.warn("포인트 적립 실패(무시): {}", e.getMessage()); + } + } + auditService.log(admin ? AuditService.currentActor() : owner, + "ORDER_STATUS", o.getOrderNo(), from + " -> " + to); + MallOrder updated = detail(id, owner, admin); + orderNotifier.broadcastStatus(updated); + return updated; + } + + /** + * 제작완료 꽃사진 첨부 → 고객 MMS 발송(Proof of Quality, Ode à la Rose 패턴). + * MMS는 게이트웨이 어댑터(기본 mock) 경유. 발송 실패는 무시(사진 저장은 유지). + */ + @Transactional + public MallOrder attachProofPhoto(Long id, String photoUrl, String actor) { + MallOrder o = orderMapper.findById(id); + if (o == null) throw new RuntimeException("ERR-ORD-404: 주문을 찾을 수 없습니다"); + orderMapper.setProofPhoto(id, photoUrl); + try { + if (o.getReceiverPhone() != null && photoUrl != null) { + smsSender.sendMms(o.getReceiverPhone(), + "Your bouquet for order " + o.getOrderNo() + " is ready — here's a photo of today's arrangement!", + photoUrl); + } + } catch (Exception e) { + log.warn("Proof MMS 발송 실패(무시): {}", e.getMessage()); + } + auditService.log(actor, "ORDER_PROOF_PHOTO", o.getOrderNo(), "mms-sent"); + return orderMapper.findById(id); + } + + /** 문앞사진(Proof of Delivery) 첨부. */ + @Transactional + public MallOrder attachPodPhoto(Long id, String photoUrl, String actor) { + MallOrder o = orderMapper.findById(id); + if (o == null) throw new RuntimeException("ERR-ORD-404: 주문을 찾을 수 없습니다"); + orderMapper.setPodPhoto(id, photoUrl); + auditService.log(actor, "ORDER_POD_PHOTO", o.getOrderNo(), "pod-attached"); + return orderMapper.findById(id); + } + + /** 주문 이력 → CRM 고객 인사이트 (실패 무시). */ + private void feedCrm(MallOrder o) { + try { + Map payload = new LinkedHashMap<>(); + payload.put("customerKey", o.getOwner()); + payload.put("orderNo", o.getOrderNo()); + payload.put("amount", o.getPayAmount()); + payload.put("source", "MALL"); + crmClient.upsertCustomer(payload); + } catch (Exception e) { + log.warn("CRM 주문 피드 실패(무시): {}", e.getMessage()); + } + } + + private String generateOrderNo() { + return "ORD" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")) + + String.format("%03d", new Random().nextInt(1000)); + } + + public record CheckoutRequest(Long storeId, String fulfillmentType, + String receiverName, String receiverPhone, String address, String deliveryZip, + java.time.LocalDate scheduledDate, Long slotId, String slotLabel, + String cardMessage, String memo, Long couponId, + BigDecimal discountAmount, BigDecimal taxAmount, BigDecimal surgeAmount) {} +} diff --git a/backend/src/main/java/com/zioinfo/mall/order/mapper/OrderMapper.java b/backend/src/main/java/com/zioinfo/mall/order/mapper/OrderMapper.java new file mode 100644 index 0000000..16304c1 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/order/mapper/OrderMapper.java @@ -0,0 +1,24 @@ +package com.zioinfo.mall.order.mapper; + +import com.zioinfo.mall.order.MallOrder; +import com.zioinfo.mall.order.MallOrderItem; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface OrderMapper { + List findByOwner(@Param("owner") String owner, @Param("status") String status); + List findAll(@Param("status") String status, @Param("limit") int limit); + List findByStore(@Param("storeId") Long storeId, @Param("status") String status, @Param("limit") int limit); + MallOrder findById(@Param("id") Long id); + MallOrder findByOrderNo(@Param("orderNo") String orderNo); + int insert(MallOrder o); + int updateStatus(@Param("id") Long id, @Param("status") String status); + int setProofPhoto(@Param("id") Long id, @Param("url") String url); + int setPodPhoto(@Param("id") Long id, @Param("url") String url); + + List findItems(@Param("orderId") Long orderId); + int insertItem(MallOrderItem item); +} diff --git a/backend/src/main/java/com/zioinfo/mall/payment/MallPayment.java b/backend/src/main/java/com/zioinfo/mall/payment/MallPayment.java new file mode 100644 index 0000000..46593e6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/payment/MallPayment.java @@ -0,0 +1,32 @@ +package com.zioinfo.mall.payment; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 결제 (mall_payment). + * + *

보안: card_no_enc / account_no_enc 는 AES-256-GCM 암호화 저장. + * API 응답에는 절대 노출하지 않고 마스킹된 cardMasked / accountMasked 만 노출. + */ +@Data +public class MallPayment { + private Long id; + private Long orderId; + private String method; // CARD / VBANK / TRANSFER + private String pgTid; + private BigDecimal amount; + private String status; // READY / PAID / FAILED / CANCELLED + private String cardNoEnc; // 내부 저장(응답 제외) + private String accountNoEnc; // 내부 저장(응답 제외) + private String vbankNo; // 가상계좌 번호(발급) + private String vbankBank; + private LocalDateTime paidAt; + private LocalDateTime createdAt; + + // 응답 노출용(마스킹) + private String cardMasked; + private String accountMasked; +} diff --git a/backend/src/main/java/com/zioinfo/mall/payment/PaymentController.java b/backend/src/main/java/com/zioinfo/mall/payment/PaymentController.java new file mode 100644 index 0000000..1260848 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/payment/PaymentController.java @@ -0,0 +1,35 @@ +package com.zioinfo.mall.payment; + +import com.zioinfo.mall.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +/** 결제 API — /api/mall/payment. 인증 고객. 카드/계좌번호 응답 미노출(마스킹만). */ +@RestController +@RequestMapping("/api/mall/payment") +@RequiredArgsConstructor +public class PaymentController { + + private final PaymentService service; + + @PostMapping + public ApiResponse pay(@RequestBody PaymentService.PayRequest req, Authentication auth) { + return ApiResponse.ok(service.pay(auth.getName(), isAdmin(auth), req)); + } + + @PostMapping("/{id}/vbank-confirm") + public ApiResponse confirmVbank(@PathVariable Long id, Authentication auth) { + return ApiResponse.ok(service.confirmVbank(auth.getName(), isAdmin(auth), id)); + } + + @GetMapping("/order/{orderId}") + public ApiResponse byOrder(@PathVariable Long orderId, Authentication auth) { + return ApiResponse.ok(service.getByOrder(orderId, auth.getName(), isAdmin(auth))); + } + + private boolean isAdmin(Authentication auth) { + return auth.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN") || a.getAuthority().equals("ROLE_MANAGER")); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/payment/PaymentService.java b/backend/src/main/java/com/zioinfo/mall/payment/PaymentService.java new file mode 100644 index 0000000..1a90d77 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/payment/PaymentService.java @@ -0,0 +1,110 @@ +package com.zioinfo.mall.payment; + +import com.zioinfo.mall.admin.AuditService; +import com.zioinfo.mall.common.CryptoUtil; +import com.zioinfo.mall.order.MallOrder; +import com.zioinfo.mall.order.OrderService; +import com.zioinfo.mall.order.mapper.OrderMapper; +import com.zioinfo.mall.payment.mapper.PaymentMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.Random; +import java.util.UUID; + +/** + * 결제 서비스 — 모의 PG / 가상계좌. + * + *

보안: 카드/계좌 번호는 {@link CryptoUtil} 로 암호화 저장하고, 응답 시 마스킹만 노출(redact()). + * 모의 PG 는 항상 승인(데모) — pgTid 발급. VBANK 는 가상계좌 번호 발급 후 READY. + */ +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentMapper paymentMapper; + private final OrderMapper orderMapper; + private final OrderService orderService; + private final CryptoUtil crypto; + private final AuditService auditService; + + /** 결제 요청 — 주문 소유자만. CARD: 즉시 승인→주문 PAID. VBANK: 가상계좌 발급(READY). */ + @Transactional + public MallPayment pay(String owner, boolean admin, PayRequest req) { + MallOrder order = orderService.detail(req.orderId(), owner, admin); + if (!"PENDING".equals(order.getStatus())) { + throw new RuntimeException("ERR-PAY-409: 결제 가능한 주문 상태가 아닙니다"); + } + MallPayment p = new MallPayment(); + p.setOrderId(order.getId()); + p.setAmount(order.getPayAmount()); + String method = req.method() == null ? "CARD" : req.method().toUpperCase(); + p.setMethod(method); + if (req.cardNo() != null && !req.cardNo().isBlank()) { + p.setCardNoEnc(crypto.encrypt(req.cardNo())); + } + if (req.accountNo() != null && !req.accountNo().isBlank()) { + p.setAccountNoEnc(crypto.encrypt(req.accountNo())); + } + if ("VBANK".equals(method)) { + p.setStatus("READY"); + p.setVbankBank(req.bank() == null ? "지오은행" : req.bank()); + p.setVbankNo(generateVbank()); + paymentMapper.insert(p); + } else { + // 모의 PG 즉시 승인 + p.setStatus("PAID"); + p.setPgTid("PG-" + UUID.randomUUID().toString().substring(0, 12).toUpperCase()); + paymentMapper.insert(p); + orderService.transition(order.getId(), owner, true, "PAID"); + } + auditService.log(owner, "PAYMENT", String.valueOf(order.getOrderNo()), + method + " " + p.getStatus() + " amount=" + p.getAmount()); + return redact(paymentMapper.findById(p.getId())); + } + + /** 가상계좌 입금 확인(모의) — READY → PAID + 주문 PAID. */ + @Transactional + public MallPayment confirmVbank(String owner, boolean admin, Long paymentId) { + MallPayment p = paymentMapper.findById(paymentId); + if (p == null) { + throw new RuntimeException("ERR-PAY-404: 결제 정보 없음"); + } + if (!"READY".equals(p.getStatus())) { + throw new RuntimeException("ERR-PAY-409: 입금 대기 상태가 아닙니다"); + } + paymentMapper.updateStatus(paymentId, "PAID"); + orderService.transition(p.getOrderId(), owner, true, "PAID"); + auditService.log(owner, "PAYMENT_VBANK_CONFIRM", String.valueOf(p.getOrderId()), "PAID"); + return redact(paymentMapper.findById(paymentId)); + } + + public MallPayment getByOrder(Long orderId, String owner, boolean admin) { + // 주문 소유 검증 + orderService.detail(orderId, owner, admin); + MallPayment p = paymentMapper.findByOrderId(orderId); + return p == null ? null : redact(p); + } + + /** 민감 필드 마스킹 — card_no_enc/account_no_enc 는 절대 응답에 포함하지 않는다. */ + private MallPayment redact(MallPayment p) { + if (p.getCardNoEnc() != null && !p.getCardNoEnc().isEmpty()) { + p.setCardMasked(CryptoUtil.maskTail(crypto.decrypt(p.getCardNoEnc()))); + } + if (p.getAccountNoEnc() != null && !p.getAccountNoEnc().isEmpty()) { + p.setAccountMasked(CryptoUtil.maskTail(crypto.decrypt(p.getAccountNoEnc()))); + } + p.setCardNoEnc(null); + p.setAccountNoEnc(null); + return p; + } + + private String generateVbank() { + Random r = new Random(); + return String.format("%03d-%04d-%06d", r.nextInt(1000), r.nextInt(10000), r.nextInt(1000000)); + } + + public record PayRequest(Long orderId, String method, String cardNo, String accountNo, String bank) {} +} diff --git a/backend/src/main/java/com/zioinfo/mall/payment/mapper/PaymentMapper.java b/backend/src/main/java/com/zioinfo/mall/payment/mapper/PaymentMapper.java new file mode 100644 index 0000000..9aa744d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/payment/mapper/PaymentMapper.java @@ -0,0 +1,16 @@ +package com.zioinfo.mall.payment.mapper; + +import com.zioinfo.mall.payment.MallPayment; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface PaymentMapper { + MallPayment findById(@Param("id") Long id); + MallPayment findByOrderId(@Param("orderId") Long orderId); + List findAll(@Param("status") String status, @Param("limit") int limit); + int insert(MallPayment p); + int updateStatus(@Param("id") Long id, @Param("status") String status); +} diff --git a/backend/src/main/java/com/zioinfo/mall/product/MallProduct.java b/backend/src/main/java/com/zioinfo/mall/product/MallProduct.java new file mode 100644 index 0000000..42d631f --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/product/MallProduct.java @@ -0,0 +1,34 @@ +package com.zioinfo.mall.product; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** 상품 (mall_product). 옵션/이미지 포함. */ +@Data +public class MallProduct { + private Long id; + private Long categoryId; + private String sku; + private String name; + private String brand; + private String description; + private BigDecimal price; + private BigDecimal salePrice; + private String status; // ON_SALE / SOLD_OUT / HIDDEN + private Integer stock; // 단순 재고(옵션 미사용 시) — inventory 모듈과 동기 + private String thumbnail; + private BigDecimal ratingAvg; + private Integer reviewCount; + private Integer salesCount; + private String occasion; // BIRTHDAY/ANNIVERSARY/SYMPATHY/... + private String flowerType; // ROSES/TULIPS/LILIES/... + private Integer shelfLifeDays; // 생화 시한성(유통기한 일수) + private LocalDateTime createdAt; + + private List options; + private List images; + private List sizes; // 3사이즈(Original/Deluxe/Grand) +} diff --git a/backend/src/main/java/com/zioinfo/mall/product/MallProductImage.java b/backend/src/main/java/com/zioinfo/mall/product/MallProductImage.java new file mode 100644 index 0000000..36562c1 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/product/MallProductImage.java @@ -0,0 +1,13 @@ +package com.zioinfo.mall.product; + +import lombok.Data; + +/** 상품 이미지 (mall_product_image). */ +@Data +public class MallProductImage { + private Long id; + private Long productId; + private String url; + private Integer sortOrder; + private Boolean isMain; +} diff --git a/backend/src/main/java/com/zioinfo/mall/product/MallProductOption.java b/backend/src/main/java/com/zioinfo/mall/product/MallProductOption.java new file mode 100644 index 0000000..11ce53d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/product/MallProductOption.java @@ -0,0 +1,18 @@ +package com.zioinfo.mall.product; + +import lombok.Data; + +import java.math.BigDecimal; + +/** 상품 옵션 (mall_product_option). 예: 색상=빨강, 사이즈=L. */ +@Data +public class MallProductOption { + private Long id; + private Long productId; + private String optionName; // 예: "색상" + private String optionValue; // 예: "빨강" + private BigDecimal extraPrice; + private Integer stock; + private String skuSuffix; + private String optionType; // VASE / CARD_MESSAGE / WRAP +} diff --git a/backend/src/main/java/com/zioinfo/mall/product/MallProductSize.java b/backend/src/main/java/com/zioinfo/mall/product/MallProductSize.java new file mode 100644 index 0000000..1c1b6da --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/product/MallProductSize.java @@ -0,0 +1,17 @@ +package com.zioinfo.mall.product; + +import lombok.Data; + +import java.math.BigDecimal; + +/** 상품 사이즈 (mall_product_size). ORIGINAL/DELUXE/GRAND — Bouqs 패턴. */ +@Data +public class MallProductSize { + private Long id; + private Long productId; + private String sizeCode; // ORIGINAL / DELUXE / GRAND + private String label; + private BigDecimal price; + private Integer stemCount; + private Integer sortOrder; +} diff --git a/backend/src/main/java/com/zioinfo/mall/product/ProductController.java b/backend/src/main/java/com/zioinfo/mall/product/ProductController.java new file mode 100644 index 0000000..d4a9a47 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/product/ProductController.java @@ -0,0 +1,57 @@ +package com.zioinfo.mall.product; + +import com.zioinfo.mall.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +/** 상품 API — /api/mall/product. GET 공개(스토어프론트), 변경 MANAGER+. */ +@RestController +@RequestMapping("/api/mall/product") +@RequiredArgsConstructor +public class ProductController { + + private final ProductService service; + + @GetMapping + public ApiResponse> list( + @RequestParam(required = false) Long categoryId, + @RequestParam(required = false) String keyword, + @RequestParam(required = false) String status, + @RequestParam(required = false) String occasion, + @RequestParam(required = false) String flowerType, + @RequestParam(required = false) java.math.BigDecimal maxPrice, + @RequestParam(required = false) String sort, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size) { + return ApiResponse.ok(service.search(categoryId, keyword, status, occasion, flowerType, maxPrice, sort, page, size)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(service.detail(id)); + } + + @PostMapping + public ApiResponse create(@RequestBody MallProduct p) { + return ApiResponse.ok(service.create(p)); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, @RequestBody MallProduct p) { + return ApiResponse.ok(service.update(id, p)); + } + + @PutMapping("/{id}/status") + public ApiResponse status(@PathVariable Long id, @RequestBody Map req) { + service.updateStatus(id, req.get("status")); + return ApiResponse.ok(null); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + service.delete(id); + return ApiResponse.ok(null); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/product/ProductService.java b/backend/src/main/java/com/zioinfo/mall/product/ProductService.java new file mode 100644 index 0000000..c2256ab --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/product/ProductService.java @@ -0,0 +1,106 @@ +package com.zioinfo.mall.product; + +import com.zioinfo.mall.product.mapper.ProductMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class ProductService { + + private final ProductMapper mapper; + + public Map search(Long categoryId, String keyword, String status, + String occasion, String flowerType, java.math.BigDecimal maxPrice, + String sort, int page, int size) { + int safeSize = (size <= 0 || size > 100) ? 20 : size; + int safePage = Math.max(page, 0); + List items = mapper.search(categoryId, keyword, status, occasion, flowerType, maxPrice, + sort, safeSize, safePage * safeSize); + int total = mapper.countSearch(categoryId, keyword, status, occasion, flowerType, maxPrice); + Map out = new LinkedHashMap<>(); + out.put("items", items); + out.put("total", total); + out.put("page", safePage); + out.put("size", safeSize); + return out; + } + + /** 상세 — 옵션·이미지 포함. */ + public MallProduct detail(Long id) { + MallProduct p = mapper.findById(id); + if (p == null) { + throw new RuntimeException("ERR-PRD-404: 상품을 찾을 수 없습니다"); + } + p.setOptions(mapper.findOptions(id)); + p.setImages(mapper.findImages(id)); + p.setSizes(mapper.findSizes(id)); + return p; + } + + @Transactional + public MallProduct create(MallProduct p) { + if (p.getName() == null || p.getName().isBlank()) { + throw new IllegalArgumentException("ERR-PRD-400: 상품명 필수"); + } + if (p.getPrice() == null) { + throw new IllegalArgumentException("ERR-PRD-400: 가격 필수"); + } + mapper.insert(p); + persistChildren(p); + return detail(p.getId()); + } + + @Transactional + public MallProduct update(Long id, MallProduct p) { + p.setId(id); + mapper.update(p); + if (p.getOptions() != null) { + mapper.deleteOptions(id); + } + if (p.getImages() != null) { + mapper.deleteImages(id); + } + if (p.getSizes() != null) { + mapper.deleteSizes(id); + } + persistChildren(p); + return detail(id); + } + + private void persistChildren(MallProduct p) { + if (p.getOptions() != null) { + for (MallProductOption o : p.getOptions()) { + o.setProductId(p.getId()); + mapper.insertOption(o); + } + } + if (p.getImages() != null) { + for (MallProductImage img : p.getImages()) { + img.setProductId(p.getId()); + mapper.insertImage(img); + } + } + if (p.getSizes() != null) { + for (MallProductSize s : p.getSizes()) { + s.setProductId(p.getId()); + mapper.insertSize(s); + } + } + } + + public void updateStatus(Long id, String status) { + mapper.updateStatus(id, status); + } + + public void delete(Long id) { + mapper.deleteOptions(id); + mapper.deleteImages(id); + mapper.delete(id); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/product/mapper/ProductMapper.java b/backend/src/main/java/com/zioinfo/mall/product/mapper/ProductMapper.java new file mode 100644 index 0000000..102cc88 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/product/mapper/ProductMapper.java @@ -0,0 +1,47 @@ +package com.zioinfo.mall.product.mapper; + +import com.zioinfo.mall.product.MallProduct; +import com.zioinfo.mall.product.MallProductImage; +import com.zioinfo.mall.product.MallProductOption; +import com.zioinfo.mall.product.MallProductSize; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface ProductMapper { + List search(@Param("categoryId") Long categoryId, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("occasion") String occasion, + @Param("flowerType") String flowerType, + @Param("maxPrice") java.math.BigDecimal maxPrice, + @Param("sort") String sort, + @Param("limit") int limit, + @Param("offset") int offset); + int countSearch(@Param("categoryId") Long categoryId, + @Param("keyword") String keyword, + @Param("status") String status, + @Param("occasion") String occasion, + @Param("flowerType") String flowerType, + @Param("maxPrice") java.math.BigDecimal maxPrice); + MallProduct findById(@Param("id") Long id); + int insert(MallProduct p); + int update(MallProduct p); + int updateStatus(@Param("id") Long id, @Param("status") String status); + int delete(@Param("id") Long id); + int incSales(@Param("id") Long id, @Param("qty") int qty); + + List findOptions(@Param("productId") Long productId); + int insertOption(MallProductOption o); + int deleteOptions(@Param("productId") Long productId); + + List findImages(@Param("productId") Long productId); + int insertImage(MallProductImage img); + int deleteImages(@Param("productId") Long productId); + + List findSizes(@Param("productId") Long productId); + int insertSize(MallProductSize s); + int deleteSizes(@Param("productId") Long productId); +} diff --git a/backend/src/main/java/com/zioinfo/mall/promotion/MallCoupon.java b/backend/src/main/java/com/zioinfo/mall/promotion/MallCoupon.java new file mode 100644 index 0000000..25c4ad9 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/promotion/MallCoupon.java @@ -0,0 +1,23 @@ +package com.zioinfo.mall.promotion; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** 쿠폰 (mall_coupon). discountType: RATE(%) / AMOUNT(원). */ +@Data +public class MallCoupon { + private Long id; + private String code; + private String name; + private String discountType; + private BigDecimal discountValue; + private BigDecimal minOrderAmount; + private BigDecimal maxDiscount; + private LocalDate validFrom; + private LocalDate validTo; + private Integer issuedLimit; + private Integer issuedCount; + private Boolean active; +} diff --git a/backend/src/main/java/com/zioinfo/mall/promotion/MallPromotion.java b/backend/src/main/java/com/zioinfo/mall/promotion/MallPromotion.java new file mode 100644 index 0000000..6d945d8 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/promotion/MallPromotion.java @@ -0,0 +1,18 @@ +package com.zioinfo.mall.promotion; + +import lombok.Data; + +import java.time.LocalDate; + +/** 기획전 (mall_promotion). 배너·기간·연결 카테고리. */ +@Data +public class MallPromotion { + private Long id; + private String title; + private String banner; + private String description; + private Long categoryId; + private LocalDate startDate; + private LocalDate endDate; + private Boolean active; +} diff --git a/backend/src/main/java/com/zioinfo/mall/promotion/PromotionController.java b/backend/src/main/java/com/zioinfo/mall/promotion/PromotionController.java new file mode 100644 index 0000000..e0c8d36 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/promotion/PromotionController.java @@ -0,0 +1,117 @@ +package com.zioinfo.mall.promotion; + +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.promotion.mapper.PromotionMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 프로모션 API — /api/mall/promotion. GET 공개(쿠폰/기획전 노출), 변경 MANAGER+. + * + *

쿠폰 검증/할인 계산은 POST /validate 로 제공(주문 금액 기준). + */ +@RestController +@RequestMapping("/api/mall/promotion") +@RequiredArgsConstructor +public class PromotionController { + + private final PromotionMapper mapper; + + // ---------- 기획전 ---------- + @GetMapping("/featured") + public ApiResponse> featured() { + return ApiResponse.ok(mapper.findPromotions(true)); + } + + @PostMapping("/featured") + public ApiResponse createPromotion(@RequestBody MallPromotion p) { + mapper.insertPromotion(p); + return ApiResponse.ok(mapper.findPromotionById(p.getId())); + } + + @PutMapping("/featured/{id}") + public ApiResponse updatePromotion(@PathVariable Long id, @RequestBody MallPromotion p) { + p.setId(id); + mapper.updatePromotion(p); + return ApiResponse.ok(mapper.findPromotionById(id)); + } + + @DeleteMapping("/featured/{id}") + public ApiResponse deletePromotion(@PathVariable Long id) { + mapper.deletePromotion(id); + return ApiResponse.ok(null); + } + + // ---------- 쿠폰 ---------- + @GetMapping("/coupons") + public ApiResponse> coupons(@RequestParam(defaultValue = "true") boolean activeOnly) { + return ApiResponse.ok(mapper.findCoupons(activeOnly)); + } + + @PostMapping("/coupons") + public ApiResponse createCoupon(@RequestBody MallCoupon c) { + mapper.insertCoupon(c); + return ApiResponse.ok(mapper.findCouponById(c.getId())); + } + + @PutMapping("/coupons/{id}") + public ApiResponse updateCoupon(@PathVariable Long id, @RequestBody MallCoupon c) { + c.setId(id); + mapper.updateCoupon(c); + return ApiResponse.ok(mapper.findCouponById(id)); + } + + @DeleteMapping("/coupons/{id}") + public ApiResponse deleteCoupon(@PathVariable Long id) { + mapper.deleteCoupon(id); + return ApiResponse.ok(null); + } + + /** 쿠폰 검증 + 할인 금액 계산. */ + @PostMapping("/validate") + public ApiResponse> validate(@RequestBody ValidateRequest req) { + MallCoupon c = mapper.findCouponByCode(req.code()); + Map out = new LinkedHashMap<>(); + if (c == null || !Boolean.TRUE.equals(c.getActive())) { + out.put("valid", false); + out.put("reason", "존재하지 않거나 비활성 쿠폰"); + return ApiResponse.ok(out); + } + LocalDate today = LocalDate.now(); + if ((c.getValidFrom() != null && today.isBefore(c.getValidFrom())) + || (c.getValidTo() != null && today.isAfter(c.getValidTo()))) { + out.put("valid", false); + out.put("reason", "쿠폰 유효기간이 아닙니다"); + return ApiResponse.ok(out); + } + BigDecimal amount = req.orderAmount() == null ? BigDecimal.ZERO : req.orderAmount(); + if (c.getMinOrderAmount() != null && amount.compareTo(c.getMinOrderAmount()) < 0) { + out.put("valid", false); + out.put("reason", "최소 주문금액 미달 (" + c.getMinOrderAmount() + ")"); + return ApiResponse.ok(out); + } + BigDecimal discount; + if ("RATE".equalsIgnoreCase(c.getDiscountType())) { + discount = amount.multiply(c.getDiscountValue()).divide(BigDecimal.valueOf(100)); + if (c.getMaxDiscount() != null && discount.compareTo(c.getMaxDiscount()) > 0) { + discount = c.getMaxDiscount(); + } + } else { + discount = c.getDiscountValue(); + } + discount = discount.min(amount); + out.put("valid", true); + out.put("couponId", c.getId()); + out.put("discountAmount", discount); + out.put("payAmount", amount.subtract(discount).max(BigDecimal.ZERO)); + return ApiResponse.ok(out); + } + + public record ValidateRequest(String code, BigDecimal orderAmount) {} +} diff --git a/backend/src/main/java/com/zioinfo/mall/promotion/mapper/PromotionMapper.java b/backend/src/main/java/com/zioinfo/mall/promotion/mapper/PromotionMapper.java new file mode 100644 index 0000000..93d86a4 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/promotion/mapper/PromotionMapper.java @@ -0,0 +1,27 @@ +package com.zioinfo.mall.promotion.mapper; + +import com.zioinfo.mall.promotion.MallCoupon; +import com.zioinfo.mall.promotion.MallPromotion; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface PromotionMapper { + // 쿠폰 + List findCoupons(@Param("activeOnly") Boolean activeOnly); + MallCoupon findCouponById(@Param("id") Long id); + MallCoupon findCouponByCode(@Param("code") String code); + int insertCoupon(MallCoupon c); + int updateCoupon(MallCoupon c); + int incCouponIssued(@Param("id") Long id); + int deleteCoupon(@Param("id") Long id); + + // 기획전 + List findPromotions(@Param("activeOnly") Boolean activeOnly); + MallPromotion findPromotionById(@Param("id") Long id); + int insertPromotion(MallPromotion p); + int updatePromotion(MallPromotion p); + int deletePromotion(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/zioinfo/mall/realtime/OrderNotifier.java b/backend/src/main/java/com/zioinfo/mall/realtime/OrderNotifier.java new file mode 100644 index 0000000..3ea81da --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/realtime/OrderNotifier.java @@ -0,0 +1,49 @@ +package com.zioinfo.mall.realtime; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.zioinfo.mall.order.MallOrder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 주문 이벤트 발행기 — 신규 주문/상태 변경을 WebSocket으로 브로드캐스트. + * 관리자앱 Live Order Dashboard가 구독한다. 직렬화 실패는 무시(주문 흐름 차단 금지). + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class OrderNotifier { + + private final OrderWebSocketHandler handler; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public void broadcastNewOrder(MallOrder order) { + emit("ORDER_NEW", order); + } + + public void broadcastStatus(MallOrder order) { + emit("ORDER_STATUS", order); + } + + private void emit(String event, MallOrder order) { + try { + Map payload = new LinkedHashMap<>(); + payload.put("event", event); + payload.put("orderNo", order.getOrderNo()); + payload.put("storeId", order.getStoreId()); + payload.put("status", order.getStatus()); + payload.put("fulfillmentType", order.getFulfillmentType()); + payload.put("scheduledDate", order.getScheduledDate()); + payload.put("slotLabel", order.getSlotLabel()); + payload.put("payAmount", order.getPayAmount()); + payload.put("receiverName", order.getReceiverName()); + handler.broadcast(objectMapper.writeValueAsString(payload)); + } catch (Exception e) { + log.warn("주문 이벤트 발행 실패(무시): {}", e.getMessage()); + } + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/realtime/OrderWebSocketHandler.java b/backend/src/main/java/com/zioinfo/mall/realtime/OrderWebSocketHandler.java new file mode 100644 index 0000000..d9854b6 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/realtime/OrderWebSocketHandler.java @@ -0,0 +1,47 @@ +package com.zioinfo.mall.realtime; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 매장 관리자앱 실시간 주문 알림 WebSocket 핸들러. + * + *

엔드포인트: /ws/mall/orders. 연결된 모든 세션에 신규 주문/상태 변경 이벤트를 브로드캐스트한다. + * 클라이언트(관리자앱)는 store_id로 필터링한다. + */ +@Slf4j +@Component +public class OrderWebSocketHandler extends TextWebSocketHandler { + + private final Set sessions = ConcurrentHashMap.newKeySet(); + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + sessions.add(session); + log.debug("Mall WS 연결: {} (총 {})", session.getId(), sessions.size()); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + sessions.remove(session); + } + + /** 모든 연결 세션에 JSON 텍스트 브로드캐스트. */ + public void broadcast(String json) { + TextMessage msg = new TextMessage(json); + for (WebSocketSession s : sessions) { + try { + if (s.isOpen()) s.sendMessage(msg); + } catch (Exception e) { + log.warn("Mall WS 전송 실패(무시): {}", e.getMessage()); + } + } + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/review/MallReview.java b/backend/src/main/java/com/zioinfo/mall/review/MallReview.java new file mode 100644 index 0000000..6950109 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/review/MallReview.java @@ -0,0 +1,20 @@ +package com.zioinfo.mall.review; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 리뷰 (mall_review). rating 1~5. */ +@Data +public class MallReview { + private Long id; + private Long productId; + private Long orderId; + private String author; // username + private Integer rating; + private String title; + private String content; + private String imageUrl; + private Integer helpfulCount; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/review/ReviewController.java b/backend/src/main/java/com/zioinfo/mall/review/ReviewController.java new file mode 100644 index 0000000..7d0f149 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/review/ReviewController.java @@ -0,0 +1,58 @@ +package com.zioinfo.mall.review; + +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.review.mapper.ReviewMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** 리뷰 API — /api/mall/review. GET 공개, 작성/삭제 인증 고객. */ +@RestController +@RequestMapping("/api/mall/review") +@RequiredArgsConstructor +public class ReviewController { + + private final ReviewMapper mapper; + + @GetMapping("/product/{productId}") + public ApiResponse> byProduct(@PathVariable Long productId, + @RequestParam(defaultValue = "50") int limit) { + return ApiResponse.ok(mapper.findByProduct(productId, limit)); + } + + @GetMapping("/product/{productId}/stats") + public ApiResponse> stats(@PathVariable Long productId) { + return ApiResponse.ok(mapper.stats(productId)); + } + + @PostMapping + @Transactional + public ApiResponse write(@RequestBody MallReview r, Authentication auth) { + r.setAuthor(auth.getName()); + if (r.getRating() == null || r.getRating() < 1 || r.getRating() > 5) { + throw new IllegalArgumentException("ERR-REV-400: 평점은 1~5"); + } + mapper.insert(r); + mapper.applyStats(r.getProductId()); + return ApiResponse.ok(mapper.findById(r.getId())); + } + + @PutMapping("/{id}/helpful") + public ApiResponse helpful(@PathVariable Long id) { + mapper.incHelpful(id); + return ApiResponse.ok("ok"); + } + + @DeleteMapping("/{id}") + @Transactional + public ApiResponse delete(@PathVariable Long id, Authentication auth) { + MallReview r = mapper.findById(id); + mapper.delete(id, auth.getName()); + if (r != null) mapper.applyStats(r.getProductId()); + return ApiResponse.ok("deleted"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/review/mapper/ReviewMapper.java b/backend/src/main/java/com/zioinfo/mall/review/mapper/ReviewMapper.java new file mode 100644 index 0000000..9f17c78 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/review/mapper/ReviewMapper.java @@ -0,0 +1,21 @@ +package com.zioinfo.mall.review.mapper; + +import com.zioinfo.mall.review.MallReview; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface ReviewMapper { + List findByProduct(@Param("productId") Long productId, @Param("limit") int limit); + MallReview findById(@Param("id") Long id); + int insert(MallReview r); + int delete(@Param("id") Long id, @Param("author") String author); + int incHelpful(@Param("id") Long id); + /** 상품 평점 통계 {avg, cnt}. */ + Map stats(@Param("productId") Long productId); + int applyStats(@Param("productId") Long productId); + List recentContents(@Param("productId") Long productId, @Param("limit") int limit); +} diff --git a/backend/src/main/java/com/zioinfo/mall/schedule/MallHoliday.java b/backend/src/main/java/com/zioinfo/mall/schedule/MallHoliday.java new file mode 100644 index 0000000..9cbaf30 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/schedule/MallHoliday.java @@ -0,0 +1,17 @@ +package com.zioinfo.mall.schedule; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** 공휴일 블록아웃/서지 (mall_holiday). storeId=null 이면 전 매장. */ +@Data +public class MallHoliday { + private Long id; + private LocalDate holidayDate; + private String name; + private Boolean blocked; + private BigDecimal surgeMultiplier; + private Long storeId; +} diff --git a/backend/src/main/java/com/zioinfo/mall/schedule/MallScheduleSlot.java b/backend/src/main/java/com/zioinfo/mall/schedule/MallScheduleSlot.java new file mode 100644 index 0000000..2860e75 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/schedule/MallScheduleSlot.java @@ -0,0 +1,21 @@ +package com.zioinfo.mall.schedule; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** 타임슬롯 (mall_schedule_slot). 매장×날짜×슬롯 용량/예약/서지. */ +@Data +public class MallScheduleSlot { + private Long id; + private Long storeId; + private LocalDate slotDate; + private String slotLabel; + private String startTime; + private String endTime; + private Integer capacity; + private Integer booked; + private BigDecimal surgeMultiplier; + private Boolean active; +} diff --git a/backend/src/main/java/com/zioinfo/mall/schedule/ScheduleController.java b/backend/src/main/java/com/zioinfo/mall/schedule/ScheduleController.java new file mode 100644 index 0000000..5f33c7f --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/schedule/ScheduleController.java @@ -0,0 +1,55 @@ +package com.zioinfo.mall.schedule; + +import com.zioinfo.mall.admin.AuditService; +import com.zioinfo.mall.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +/** + * 스케줄 API — /api/mall/schedule. + * 가용 슬롯 조회는 공개(체크아웃 캘린더). 슬롯/공휴일 정의는 MANAGER+. + */ +@RestController +@RequestMapping("/api/mall/schedule") +@RequiredArgsConstructor +public class ScheduleController { + + private final ScheduleService service; + private final AuditService auditService; + + /** 가용 슬롯 + 당일마감 + 서지 — 공개. */ + @GetMapping("/availability") + public ApiResponse> availability( + @RequestParam Long storeId, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { + return ApiResponse.ok(service.availability(storeId, date)); + } + + /** 공휴일 블록아웃/서지 목록 — 공개(캘린더 표시용). */ + @GetMapping("/holidays") + public ApiResponse> holidays() { + return ApiResponse.ok(service.holidays()); + } + + @PostMapping("/slot") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse createSlot(@RequestBody MallScheduleSlot s) { + return ApiResponse.ok(service.createSlot(s)); + } + + /** 피크시즌 차단/서지 설정 (관리자앱 ⑥). */ + @PostMapping("/holiday") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse upsertHoliday(@RequestBody MallHoliday h) { + MallHoliday saved = service.upsertHoliday(h); + auditService.log("HOLIDAY_SET", String.valueOf(h.getHolidayDate()), + "blocked=" + h.getBlocked() + ",surge=" + h.getSurgeMultiplier()); + return ApiResponse.ok(saved); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/schedule/ScheduleService.java b/backend/src/main/java/com/zioinfo/mall/schedule/ScheduleService.java new file mode 100644 index 0000000..f88781b --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/schedule/ScheduleService.java @@ -0,0 +1,93 @@ +package com.zioinfo.mall.schedule; + +import com.zioinfo.mall.schedule.mapper.ScheduleMapper; +import com.zioinfo.mall.store.MallStore; +import com.zioinfo.mall.store.mapper.StoreMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 스케줄 서비스 — 가용 슬롯·당일배송 마감 판정·서지프라이싱 계산. + * + *

당일배송 가능 여부는 매장 로컬타임 기준 same_day_cutoff와 비교한다. + */ +@Service +@RequiredArgsConstructor +public class ScheduleService { + + private final ScheduleMapper mapper; + private final StoreMapper storeMapper; + + /** + * 매장/날짜 가용 슬롯 조회. 응답에 당일배송 가능 여부와 서지 배수 포함. + */ + public Map availability(Long storeId, LocalDate date) { + MallStore store = storeMapper.findById(storeId); + if (store == null) { + throw new RuntimeException("ERR-SCH-404: 매장을 찾을 수 없습니다"); + } + List slots = mapper.findSlots(storeId, date); + boolean blocked = false; + BigDecimal surge = BigDecimal.ONE; + MallHoliday holiday = mapper.findHoliday(date, storeId); + if (holiday != null) { + blocked = Boolean.TRUE.equals(holiday.getBlocked()); + if (holiday.getSurgeMultiplier() != null) surge = holiday.getSurgeMultiplier(); + } + Map out = new LinkedHashMap<>(); + out.put("storeId", storeId); + out.put("date", date); + out.put("blocked", blocked); + out.put("surgeMultiplier", surge); + out.put("sameDayAvailable", isSameDayAvailable(store, date)); + out.put("sameDayCutoff", store.getSameDayCutoff()); + out.put("slots", slots); + return out; + } + + /** 당일배송 마감 판정 — 매장 로컬타임 < cutoff 이면 당일 가능. */ + public boolean isSameDayAvailable(MallStore store, LocalDate date) { + try { + ZoneId zone = ZoneId.of(store.getTimezone() == null ? "America/New_York" : store.getTimezone()); + ZonedDateTime now = ZonedDateTime.now(zone); + if (!date.equals(now.toLocalDate())) { + return date.isAfter(now.toLocalDate()); // 미래 날짜는 항상 가능 + } + String cutoff = store.getSameDayCutoff() == null ? "12:00" : store.getSameDayCutoff(); + LocalTime cut = LocalTime.parse(cutoff.length() == 5 ? cutoff : "12:00"); + return now.toLocalTime().isBefore(cut); + } catch (Exception e) { + return false; + } + } + + /** 서지 배수 조회(주문 가격 계산용). */ + public BigDecimal surgeMultiplier(Long storeId, LocalDate date) { + MallHoliday h = mapper.findHoliday(date, storeId); + if (h != null && h.getSurgeMultiplier() != null) return h.getSurgeMultiplier(); + return BigDecimal.ONE; + } + + public MallScheduleSlot createSlot(MallScheduleSlot s) { + mapper.insertSlot(s); + return mapper.findSlot(s.getId()); + } + + public List holidays() { + return mapper.findHolidays(); + } + + public MallHoliday upsertHoliday(MallHoliday h) { + mapper.upsertHoliday(h); + return mapper.findHoliday(h.getHolidayDate(), h.getStoreId()); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/schedule/mapper/ScheduleMapper.java b/backend/src/main/java/com/zioinfo/mall/schedule/mapper/ScheduleMapper.java new file mode 100644 index 0000000..e23982d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/schedule/mapper/ScheduleMapper.java @@ -0,0 +1,23 @@ +package com.zioinfo.mall.schedule.mapper; + +import com.zioinfo.mall.schedule.MallHoliday; +import com.zioinfo.mall.schedule.MallScheduleSlot; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.time.LocalDate; +import java.util.List; + +@Mapper +public interface ScheduleMapper { + List findSlots(@Param("storeId") Long storeId, @Param("slotDate") LocalDate slotDate); + MallScheduleSlot findSlot(@Param("id") Long id); + int insertSlot(MallScheduleSlot s); + int bookSlot(@Param("id") Long id); + int setSlotActive(@Param("id") Long id, @Param("active") boolean active); + + List findHolidays(); + MallHoliday findHoliday(@Param("date") LocalDate date, @Param("storeId") Long storeId); + int upsertHoliday(MallHoliday h); + int setHolidayBlocked(@Param("id") Long id, @Param("blocked") boolean blocked); +} diff --git a/backend/src/main/java/com/zioinfo/mall/store/MallStore.java b/backend/src/main/java/com/zioinfo/mall/store/MallStore.java new file mode 100644 index 0000000..7d6b82a --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/store/MallStore.java @@ -0,0 +1,29 @@ +package com.zioinfo.mall.store; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** 매장 (mall_store). 10+ 미국 매장: 운영시간·배송반경·당일마감·처리용량. */ +@Data +public class MallStore { + private Long id; + private String code; + private String name; + private String city; + private String state; + private String zip; + private String address; + private String phone; + private String timezone; + private String openTime; + private String closeTime; + private String sameDayCutoff; // 당일배송 마감(매장 로컬타임) + private BigDecimal deliveryRadiusMi; // 배송 반경(마일) + private Integer dailyCapacity; // 일일 처리 용량 + private BigDecimal lat; + private BigDecimal lng; + private Boolean active; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/store/StoreController.java b/backend/src/main/java/com/zioinfo/mall/store/StoreController.java new file mode 100644 index 0000000..8335910 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/store/StoreController.java @@ -0,0 +1,57 @@ +package com.zioinfo.mall.store; + +import com.zioinfo.mall.admin.AuditService; +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.store.mapper.StoreMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** 매장 API — /api/mall/store. 목록/상세 공개(스토어프론트), 변경 MANAGER+. */ +@RestController +@RequestMapping("/api/mall/store") +@RequiredArgsConstructor +public class StoreController { + + private final StoreMapper mapper; + private final AuditService auditService; + + /** 매장 목록 — 공개(픽업 선택용). */ + @GetMapping + public ApiResponse> list(@RequestParam(defaultValue = "true") boolean activeOnly) { + return ApiResponse.ok(mapper.findAll(activeOnly)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(mapper.findById(id)); + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse create(@RequestBody MallStore s) { + mapper.insert(s); + auditService.log("STORE_CREATE", s.getCode(), s.getName()); + return ApiResponse.ok(mapper.findById(s.getId())); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse update(@PathVariable Long id, @RequestBody MallStore s) { + s.setId(id); + mapper.update(s); + auditService.log("STORE_UPDATE", String.valueOf(id), s.getName()); + return ApiResponse.ok(mapper.findById(id)); + } + + @PutMapping("/{id}/active") + @PreAuthorize("hasRole('ADMIN')") + public ApiResponse setActive(@PathVariable Long id, @RequestBody java.util.Map req) { + boolean active = req.getOrDefault("active", true); + mapper.setActive(id, active); + auditService.log("STORE_ACTIVE", String.valueOf(id), String.valueOf(active)); + return ApiResponse.ok(mapper.findById(id)); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/store/mapper/StoreMapper.java b/backend/src/main/java/com/zioinfo/mall/store/mapper/StoreMapper.java new file mode 100644 index 0000000..c0e9082 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/store/mapper/StoreMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mall.store.mapper; + +import com.zioinfo.mall.store.MallStore; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface StoreMapper { + List findAll(@Param("activeOnly") boolean activeOnly); + MallStore findById(@Param("id") Long id); + MallStore findByCode(@Param("code") String code); + int insert(MallStore s); + int update(MallStore s); + int setActive(@Param("id") Long id, @Param("active") boolean active); +} diff --git a/backend/src/main/java/com/zioinfo/mall/subscription/MallSubscription.java b/backend/src/main/java/com/zioinfo/mall/subscription/MallSubscription.java new file mode 100644 index 0000000..e1f3791 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/subscription/MallSubscription.java @@ -0,0 +1,26 @@ +package com.zioinfo.mall.subscription; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 정기구독 (mall_subscription). 주간/격주/월간. */ +@Data +public class MallSubscription { + private Long id; + private String owner; + private Long storeId; + private Long productId; + private String sizeCode; + private String frequency; // WEEKLY / BIWEEKLY / MONTHLY + private String deliveryZip; + private String address; + private String receiverName; + private String receiverPhone; + private LocalDate nextDeliveryDate; + private String status; // ACTIVE / PAUSED / CANCELLED + private BigDecimal price; + private LocalDateTime createdAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/subscription/SubscriptionController.java b/backend/src/main/java/com/zioinfo/mall/subscription/SubscriptionController.java new file mode 100644 index 0000000..5760da9 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/subscription/SubscriptionController.java @@ -0,0 +1,61 @@ +package com.zioinfo.mall.subscription; + +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.subscription.mapper.SubscriptionMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +/** 구독 API — /api/mall/subscription. 인증 고객 본인 구독. 전체 목록 MANAGER+. */ +@RestController +@RequestMapping("/api/mall/subscription") +@RequiredArgsConstructor +public class SubscriptionController { + + private final SubscriptionMapper mapper; + + @GetMapping + public ApiResponse> mine(Authentication auth) { + return ApiResponse.ok(mapper.findByOwner(auth.getName())); + } + + @GetMapping("/admin") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> all(@RequestParam(required = false) String status) { + return ApiResponse.ok(mapper.findAll(status)); + } + + @PostMapping + public ApiResponse subscribe(@RequestBody MallSubscription s, Authentication auth) { + s.setOwner(auth.getName()); + if (s.getNextDeliveryDate() == null) { + s.setNextDeliveryDate(nextDate(s.getFrequency())); + } + mapper.insert(s); + return ApiResponse.ok(mapper.findById(s.getId())); + } + + /** 구독 상태 변경(PAUSE/RESUME/CANCEL). 본인만. */ + @PutMapping("/{id}/status") + public ApiResponse status(@PathVariable Long id, @RequestBody Map req, Authentication auth) { + MallSubscription s = mapper.findById(id); + if (s == null || !s.getOwner().equals(auth.getName())) { + throw new RuntimeException("ERR-SUB-403: 본인 구독만 변경할 수 있습니다"); + } + String to = req.getOrDefault("status", "ACTIVE").toUpperCase(); + mapper.updateStatus(id, to); + return ApiResponse.ok(mapper.findById(id)); + } + + private LocalDate nextDate(String freq) { + LocalDate base = LocalDate.now(); + if ("MONTHLY".equalsIgnoreCase(freq)) return base.plusMonths(1); + if ("BIWEEKLY".equalsIgnoreCase(freq)) return base.plusWeeks(2); + return base.plusWeeks(1); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/subscription/mapper/SubscriptionMapper.java b/backend/src/main/java/com/zioinfo/mall/subscription/mapper/SubscriptionMapper.java new file mode 100644 index 0000000..cca9abd --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/subscription/mapper/SubscriptionMapper.java @@ -0,0 +1,17 @@ +package com.zioinfo.mall.subscription.mapper; + +import com.zioinfo.mall.subscription.MallSubscription; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface SubscriptionMapper { + List findByOwner(@Param("owner") String owner); + List findAll(@Param("status") String status); + MallSubscription findById(@Param("id") Long id); + int insert(MallSubscription s); + int updateStatus(@Param("id") Long id, @Param("status") String status); + int updateNextDate(@Param("id") Long id, @Param("nextDeliveryDate") java.time.LocalDate next); +} diff --git a/backend/src/main/java/com/zioinfo/mall/transfer/MallTransfer.java b/backend/src/main/java/com/zioinfo/mall/transfer/MallTransfer.java new file mode 100644 index 0000000..4055115 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/transfer/MallTransfer.java @@ -0,0 +1,22 @@ +package com.zioinfo.mall.transfer; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 매장간 재고 이양 (mall_transfer). 요청·승인·이동. */ +@Data +public class MallTransfer { + private Long id; + private Long fromStoreId; + private Long toStoreId; + private Long productId; + private Integer quantity; + private String status; // REQUESTED / APPROVED / REJECTED / COMPLETED + private String requestedBy; + private String approvedBy; + private String reason; + private Boolean aiRecommended; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/backend/src/main/java/com/zioinfo/mall/transfer/TransferController.java b/backend/src/main/java/com/zioinfo/mall/transfer/TransferController.java new file mode 100644 index 0000000..18917e2 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/transfer/TransferController.java @@ -0,0 +1,72 @@ +package com.zioinfo.mall.transfer; + +import com.zioinfo.mall.admin.AuditService; +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.inventory.mapper.StoreInventoryMapper; +import com.zioinfo.mall.transfer.mapper.TransferMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 매장간 재고 이양 API — /api/mall/transfer. MANAGER+. + * 승인 시 from 매장 차감 + to 매장 증가(재고 이동). AI 추천은 ai 모듈에서 생성. + */ +@RestController +@RequestMapping("/api/mall/transfer") +@RequiredArgsConstructor +@PreAuthorize("hasAnyRole('ADMIN','MANAGER')") +public class TransferController { + + private final TransferMapper mapper; + private final StoreInventoryMapper storeInventoryMapper; + private final AuditService auditService; + + @GetMapping + public ApiResponse> list(@RequestParam(required = false) String status, + @RequestParam(required = false) Long storeId) { + return ApiResponse.ok(mapper.findAll(status, storeId)); + } + + @GetMapping("/{id}") + public ApiResponse get(@PathVariable Long id) { + return ApiResponse.ok(mapper.findById(id)); + } + + /** 이양 요청 발송. */ + @PostMapping + public ApiResponse request(@RequestBody MallTransfer t, Authentication auth) { + t.setRequestedBy(auth.getName()); + t.setStatus("REQUESTED"); + mapper.insert(t); + auditService.log(auth.getName(), "TRANSFER_REQUEST", + "from=" + t.getFromStoreId() + ",to=" + t.getToStoreId(), "product=" + t.getProductId() + " qty=" + t.getQuantity()); + return ApiResponse.ok(mapper.findById(t.getId())); + } + + /** 승인 → 재고 이동(from 차감, to 증가). */ + @PutMapping("/{id}/approve") + @Transactional + public ApiResponse approve(@PathVariable Long id, Authentication auth) { + MallTransfer t = mapper.findById(id); + if (t == null) throw new RuntimeException("ERR-TRF-404: 이양 요청 없음"); + if (!"REQUESTED".equals(t.getStatus())) { + throw new RuntimeException("ERR-TRF-409: 처리 가능한 상태가 아님(" + t.getStatus() + ")"); + } + storeInventoryMapper.adjustOnHand(t.getFromStoreId(), t.getProductId(), -t.getQuantity()); + storeInventoryMapper.adjustOnHand(t.getToStoreId(), t.getProductId(), t.getQuantity()); + mapper.updateStatus(id, "COMPLETED", auth.getName()); + auditService.log(auth.getName(), "TRANSFER_APPROVE", String.valueOf(id), "moved=" + t.getQuantity()); + return ApiResponse.ok(mapper.findById(id)); + } + + @PutMapping("/{id}/reject") + public ApiResponse reject(@PathVariable Long id, Authentication auth) { + mapper.updateStatus(id, "REJECTED", auth.getName()); + return ApiResponse.ok(mapper.findById(id)); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/transfer/mapper/TransferMapper.java b/backend/src/main/java/com/zioinfo/mall/transfer/mapper/TransferMapper.java new file mode 100644 index 0000000..3a1b54d --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/transfer/mapper/TransferMapper.java @@ -0,0 +1,15 @@ +package com.zioinfo.mall.transfer.mapper; + +import com.zioinfo.mall.transfer.MallTransfer; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface TransferMapper { + List findAll(@Param("status") String status, @Param("storeId") Long storeId); + MallTransfer findById(@Param("id") Long id); + int insert(MallTransfer t); + int updateStatus(@Param("id") Long id, @Param("status") String status, @Param("approvedBy") String approvedBy); +} diff --git a/backend/src/main/java/com/zioinfo/mall/wishlist/WishlistController.java b/backend/src/main/java/com/zioinfo/mall/wishlist/WishlistController.java new file mode 100644 index 0000000..c5c9e3f --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/wishlist/WishlistController.java @@ -0,0 +1,36 @@ +package com.zioinfo.mall.wishlist; + +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.wishlist.mapper.WishlistMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** 찜 API — /api/mall/wishlist. 인증 고객. */ +@RestController +@RequestMapping("/api/mall/wishlist") +@RequiredArgsConstructor +public class WishlistController { + + private final WishlistMapper mapper; + + @GetMapping + public ApiResponse>> list(Authentication auth) { + return ApiResponse.ok(mapper.findByOwner(auth.getName())); + } + + @PostMapping("/{productId}") + public ApiResponse add(@PathVariable Long productId, Authentication auth) { + mapper.add(auth.getName(), productId); + return ApiResponse.ok("added"); + } + + @DeleteMapping("/{productId}") + public ApiResponse remove(@PathVariable Long productId, Authentication auth) { + mapper.remove(auth.getName(), productId); + return ApiResponse.ok("removed"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/wishlist/mapper/WishlistMapper.java b/backend/src/main/java/com/zioinfo/mall/wishlist/mapper/WishlistMapper.java new file mode 100644 index 0000000..77b047e --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/wishlist/mapper/WishlistMapper.java @@ -0,0 +1,16 @@ +package com.zioinfo.mall.wishlist.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; +import java.util.Map; + +@Mapper +public interface WishlistMapper { + /** 찜한 상품(상품 정보 조인). */ + List> findByOwner(@Param("owner") String owner); + int add(@Param("owner") String owner, @Param("productId") Long productId); + int remove(@Param("owner") String owner, @Param("productId") Long productId); + int exists(@Param("owner") String owner, @Param("productId") Long productId); +} diff --git a/backend/src/main/java/com/zioinfo/mall/zone/MallZone.java b/backend/src/main/java/com/zioinfo/mall/zone/MallZone.java new file mode 100644 index 0000000..5d2c7d8 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/zone/MallZone.java @@ -0,0 +1,18 @@ +package com.zioinfo.mall.zone; + +import lombok.Data; + +import java.math.BigDecimal; + +/** 배송권역 (mall_zone). ZIP → 매장 매핑 + 반경. */ +@Data +public class MallZone { + private Long id; + private Long storeId; + private String zip; + private String city; + private String state; + private BigDecimal radiusMi; + private Boolean surgeEligible; + private Boolean active; +} diff --git a/backend/src/main/java/com/zioinfo/mall/zone/ZoneController.java b/backend/src/main/java/com/zioinfo/mall/zone/ZoneController.java new file mode 100644 index 0000000..615cb99 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/zone/ZoneController.java @@ -0,0 +1,74 @@ +package com.zioinfo.mall.zone; + +import com.zioinfo.mall.common.ApiResponse; +import com.zioinfo.mall.store.MallStore; +import com.zioinfo.mall.store.mapper.StoreMapper; +import com.zioinfo.mall.zone.mapper.ZoneMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 배송권역 API — /api/mall/zone. + * ZIP 조회(고객 진입 필터, 공개)는 가용 매장과 배송 가능 여부를 판정한다(Floom 패턴). + */ +@RestController +@RequestMapping("/api/mall/zone") +@RequiredArgsConstructor +public class ZoneController { + + private final ZoneMapper mapper; + private final StoreMapper storeMapper; + + /** + * ZIP 진입 조회 — 공개. 해당 ZIP을 커버하는 매장 목록과 deliverable 판정. + * 응답: { zip, deliverable, stores:[{storeId,storeName,radiusMi}] } + */ + @GetMapping("/lookup") + public ApiResponse> lookup(@RequestParam String zip) { + List zones = mapper.findByZip(zip); + List> stores = new ArrayList<>(); + for (MallZone z : zones) { + MallStore s = storeMapper.findById(z.getStoreId()); + if (s == null || Boolean.FALSE.equals(s.getActive())) continue; + Map m = new LinkedHashMap<>(); + m.put("storeId", s.getId()); + m.put("storeCode", s.getCode()); + m.put("storeName", s.getName()); + m.put("radiusMi", z.getRadiusMi()); + m.put("sameDayCutoff", s.getSameDayCutoff()); + m.put("timezone", s.getTimezone()); + stores.add(m); + } + Map out = new LinkedHashMap<>(); + out.put("zip", zip); + out.put("deliverable", !stores.isEmpty()); + out.put("stores", stores); + return ApiResponse.ok(out); + } + + @GetMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse> list(@RequestParam(required = false) Long storeId) { + return ApiResponse.ok(storeId != null ? mapper.findByStore(storeId) : mapper.findAll()); + } + + @PostMapping + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse create(@RequestBody MallZone z) { + mapper.insert(z); + return ApiResponse.ok(z); + } + + @DeleteMapping("/{id}") + @PreAuthorize("hasAnyRole('ADMIN','MANAGER')") + public ApiResponse delete(@PathVariable Long id) { + mapper.delete(id); + return ApiResponse.ok("deleted"); + } +} diff --git a/backend/src/main/java/com/zioinfo/mall/zone/mapper/ZoneMapper.java b/backend/src/main/java/com/zioinfo/mall/zone/mapper/ZoneMapper.java new file mode 100644 index 0000000..797f0b7 --- /dev/null +++ b/backend/src/main/java/com/zioinfo/mall/zone/mapper/ZoneMapper.java @@ -0,0 +1,16 @@ +package com.zioinfo.mall.zone.mapper; + +import com.zioinfo.mall.zone.MallZone; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface ZoneMapper { + List findByZip(@Param("zip") String zip); + List findByStore(@Param("storeId") Long storeId); + List findAll(); + int insert(MallZone z); + int delete(@Param("id") Long id); +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..393865a --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,52 @@ +server: + port: 8011 +spring: + application: + name: guardia-mall + datasource: + url: ${DB_URL:jdbc:postgresql://localhost:5432/mall_db} + username: ${DB_USER:mall_user} + password: ${DB_PASS:mall_pass2026} + driver-class-name: org.postgresql.Driver + servlet: + multipart: + max-file-size: 20MB + max-request-size: 20MB +mybatis: + mapper-locations: classpath:mapper/*.xml + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl +springdoc: + api-docs: + path: /api/mall/docs + swagger-ui: + path: /api/mall/swagger +mall: + # 외부 게이트웨이 어댑터 선택 — 기본 mock(개발/데모 외부호출 0). 운영 시 환경변수로 오버라이드. + payment: + provider: ${MALL_PAYMENT_PROVIDER:mock} # mock | stripe + tax: + provider: ${MALL_TAX_PROVIDER:mock} # mock | taxjar | avalara + address: + provider: ${MALL_ADDRESS_PROVIDER:mock} # mock | google | smarty + sms: + provider: ${MALL_SMS_PROVIDER:mock} # mock | twilio + email: + provider: ${MALL_EMAIL_PROVIDER:mock} # mock | sendgrid +guardia: + itsm-url: ${ITSM_URL:http://localhost:9001} + erp-url: ${ERP_URL:http://localhost:8003} + crm-url: ${CRM_URL:http://localhost:8004} + ocr-url: ${OCR_URL:http://localhost:8005} + ollama-url: ${OLLAMA_URL:http://localhost:11434} + ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3} + crypto: + secret: ${CRYPTO_SECRET:guardia-mall-aes-256-gcm-master-key-2026-zioinfo} + jwt: + secret: ${JWT_SECRET:guardia-mall-jwt-secret-2026-minimum-256bit-key-zioinfo} + expiration: 86400000 +logging: + level: + com.zioinfo.mall: DEBUG + org.mybatis: WARN diff --git a/backend/src/main/resources/db/schema.sql b/backend/src/main/resources/db/schema.sql new file mode 100644 index 0000000..e2804e7 --- /dev/null +++ b/backend/src/main/resources/db/schema.sql @@ -0,0 +1,550 @@ +-- ============================================================================ +-- GUARDiA Mall — PostgreSQL 스키마 (mall_db) +-- 미국 다지점 꽃집 옴니채널 e-커머스 +-- 매장·ZIP배송권역·매장별재고ON/OFF·상품/3사이즈/옵션·장바구니·주문·스케줄링/타임슬롯· +-- 당일배송·구독·서지프라이싱·배송/기사라우팅·매장간재고이양·리뷰·회원·CS·분석·찜 +-- 모든 테이블 IF NOT EXISTS, 시드는 ON CONFLICT DO NOTHING (멱등). +-- ============================================================================ + +-- --------------------------------------------------------------------------- +-- 0. 공통 (인증/감사/설정/AI) +-- --------------------------------------------------------------------------- +-- 인증/관리자 계정 (auth UserMapper + admin AdminUserMapper 가 사용하는 테이블) +CREATE TABLE IF NOT EXISTS mall_account ( + id BIGSERIAL PRIMARY KEY, username VARCHAR(200) UNIQUE NOT NULL, password_hash VARCHAR(500) NOT NULL, + role VARCHAR(50) DEFAULT 'USER', display_name VARCHAR(200), + store_id BIGINT, -- StoreManager 배정 매장 (SuperAdmin=NULL) + is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT NOW() +); +-- 시드 비밀번호 = admin123 (bcrypt $2a$10) +INSERT INTO mall_account (username, password_hash, role, display_name, store_id) VALUES +('admin', '$2a$10$Uo4GFiJ2TZy9KlGRALfDt.gcE0e7..57rXBRaEv1mJG2XqANG3OI2', 'ADMIN', 'Super Admin (대표)', NULL), +('manager', '$2a$10$Uo4GFiJ2TZy9KlGRALfDt.gcE0e7..57rXBRaEv1mJG2XqANG3OI2', 'MANAGER', 'Store Manager (1호점)', 1), +('jane', '$2a$10$Uo4GFiJ2TZy9KlGRALfDt.gcE0e7..57rXBRaEv1mJG2XqANG3OI2', 'USER', 'Jane Doe', NULL) +ON CONFLICT (username) DO NOTHING; + +CREATE TABLE IF NOT EXISTS mall_audit_log ( + id BIGSERIAL PRIMARY KEY, actor VARCHAR(200), action VARCHAR(100), target VARCHAR(300), detail TEXT, created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mall_audit_action ON mall_audit_log(action); + +CREATE TABLE IF NOT EXISTS mall_setting (key VARCHAR(200) PRIMARY KEY, value TEXT, updated_at TIMESTAMP DEFAULT NOW()); +INSERT INTO mall_setting (key, value) VALUES +('ollama_text_model','llama3'), +('company_name','Montvale Florist'), +('company_tagline','100% Florist-Designed and Hand-Delivered!'), +('company_address','6 Railroad Ave, Montvale, NJ 07645'), +('company_phone','(201) 690-6721'), +('company_email','wecare@montvalefloristnj.com'), +('company_founded','2010'), +('company_rating','4.9'), +('company_review_count','44893'), +('social_instagram','@themontvaleflorist'), +('social_facebook','montvaleflorist1'), +('social_pinterest','montvaleflorist'), +('social_yelp','montvale-florist-montvale-3'), +('hours_weekday','Mon-Fri 9:00 AM - 5:30 PM'), +('hours_saturday','Sat 9:00 AM - 4:00 PM'), +('hours_sunday','Sun 9:00 AM - 12:00 PM'), +('payment_provider','mock'),('tax_provider','mock'),('address_provider','mock'), +('sms_provider','mock'),('email_provider','mock') +ON CONFLICT (key) DO NOTHING; + +CREATE TABLE IF NOT EXISTS mall_ai_result ( + id BIGSERIAL PRIMARY KEY, ai_type VARCHAR(60), prompt TEXT, result_json TEXT, model VARCHAR(60), + created_by VARCHAR(200), created_at TIMESTAMP DEFAULT NOW() +); + +-- --------------------------------------------------------------------------- +-- 1. 매장 (Stores) — 10+ 미국 매장 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mall_store ( + id BIGSERIAL PRIMARY KEY, code VARCHAR(40) UNIQUE NOT NULL, name VARCHAR(200) NOT NULL, + city VARCHAR(120), state VARCHAR(10), zip VARCHAR(10), address VARCHAR(400), + phone VARCHAR(40), timezone VARCHAR(60) DEFAULT 'America/New_York', + open_time VARCHAR(10) DEFAULT '09:00', close_time VARCHAR(10) DEFAULT '18:00', + same_day_cutoff VARCHAR(10) DEFAULT '12:00', -- 당일배송 마감 (매장 로컬타임) + delivery_radius_mi NUMERIC(6,2) DEFAULT 15.0, -- 배송 반경(마일) + daily_capacity INTEGER DEFAULT 50, -- 일일 처리 용량(주문수) + lat NUMERIC(10,6), lng NUMERIC(10,6), + active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT NOW() +); +-- 실데이터: Montvale Florist = 플래그십 1호점(id=1). 나머지는 트라이스테이트 확장 매장. +-- Montvale 운영시간/마감(당일배송): 월-금 9:00-17:30(마감 13:00=2PM 사이트표기 기준 보수적 1PM), 토 9:00-16:00(마감 12:00), 일 9:00-12:00(마감 10:00) +-- (id 명시: manager 계정 store_id=1 정합) +INSERT INTO mall_store (id, code, name, city, state, zip, address, phone, timezone, open_time, close_time, same_day_cutoff, delivery_radius_mi, daily_capacity, lat, lng) VALUES +(1,'MONTVALE','Montvale Florist','Montvale','NJ','07645','6 Railroad Ave','+1-201-690-6721','America/New_York','09:00','17:30','13:00',15.0,80,41.046700,-74.023100), +(2,'HACKENSACK','Montvale Florist — Hackensack','Hackensack','NJ','07601','120 Main St','+1-201-555-0102','America/New_York','09:00','17:30','13:00',12.0,60,40.885900,-74.043400), +(3,'PARAMUS','Montvale Florist — Paramus','Paramus','NJ','07652','201 Bergen Town Ctr','+1-201-555-0103','America/New_York','09:00','17:30','13:00',14.0,55,40.944500,-74.071500), +(4,'FORTLEE','Montvale Florist — Fort Lee','Fort Lee','NJ','07024','1605 Center Ave','+1-201-555-0104','America/New_York','09:00','17:30','13:00',12.0,50,40.850800,-73.970900), +(5,'ENGLEWOOD','Montvale Florist — Englewood','Englewood','NJ','07631','25 N Dean St','+1-201-555-0105','America/New_York','09:00','17:30','13:00',13.0,45,40.892900,-73.972700), +(6,'RIDGEWOOD','Montvale Florist — Ridgewood','Ridgewood','NJ','07450','10 E Ridgewood Ave','+1-201-555-0106','America/New_York','09:00','17:30','13:00',13.0,45,40.979500,-74.116600), +(7,'FAIRLAWN','Montvale Florist — Fair Lawn','Fair Lawn','NJ','07410','22-15 Broadway','+1-201-555-0107','America/New_York','09:00','17:30','13:00',12.0,40,40.940300,-74.131500), +(8,'TEANECK','Montvale Florist — Teaneck','Teaneck','NJ','07666','100 Cedar Ln','+1-201-555-0108','America/New_York','09:00','17:30','13:00',12.0,40,40.897700,-74.016000), +(9,'GARFIELD','Montvale Florist — Garfield','Garfield','NJ','07026','50 Outwater Ln','+1-973-555-0109','America/New_York','09:00','17:30','13:00',11.0,35,40.881400,-74.108500), +(10,'NYACK','Montvale Florist — Nyack (NY)','Nyack','NY','10960','30 S Broadway','+1-845-555-0110','America/New_York','09:00','17:30','13:00',14.0,40,41.090900,-73.917800), +(11,'NANUET','Montvale Florist — Nanuet (NY)','Nanuet','NY','10954','75 W Route 59','+1-845-555-0111','America/New_York','09:00','17:30','13:00',15.0,35,41.088600,-74.013700) +ON CONFLICT (code) DO NOTHING; +SELECT setval(pg_get_serial_sequence('mall_store','id'), GREATEST((SELECT MAX(id) FROM mall_store),1)); + +-- --------------------------------------------------------------------------- +-- 2. 배송권역 (Delivery_Zones) — ZIP → 매장 매핑 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mall_zone ( + id BIGSERIAL PRIMARY KEY, store_id BIGINT NOT NULL, zip VARCHAR(10) NOT NULL, + city VARCHAR(120), state VARCHAR(10), radius_mi NUMERIC(6,2) DEFAULT 15.0, + surge_eligible BOOLEAN DEFAULT TRUE, active BOOLEAN DEFAULT TRUE +); +CREATE INDEX IF NOT EXISTS idx_mall_zone_zip ON mall_zone(zip); +-- 실데이터: Montvale Florist 무료배송 권역 (NJ 07010–07699, NY 10520–10994) +-- 대표 ZIP을 플래그십(1) + 트라이스테이트 확장 매장에 매핑. surge 기본 ON. +INSERT INTO mall_zone (store_id, zip, city, state, radius_mi) VALUES +-- 플래그십 Montvale & 인근 (Bergen County core) +(1,'07645','Montvale','NJ',15.0),(1,'07677','Woodcliff Lake','NJ',15.0),(1,'07675','Westwood','NJ',15.0), +(1,'07656','Park Ridge','NJ',15.0),(1,'07647','Northvale','NJ',15.0),(1,'07648','Norwood','NJ',15.0), +(1,'07646','New Milford','NJ',15.0),(1,'07627','Demarest','NJ',15.0),(1,'07620','Alpine','NJ',15.0), +-- Hackensack +(2,'07601','Hackensack','NJ',12.0),(2,'07602','Hackensack','NJ',12.0),(2,'07603','Bogota','NJ',12.0), +(2,'07606','South Hackensack','NJ',12.0), +-- Paramus +(3,'07652','Paramus','NJ',14.0),(3,'07653','Paramus','NJ',14.0),(3,'07663','Saddle Brook','NJ',14.0), +-- Fort Lee +(4,'07024','Fort Lee','NJ',12.0),(4,'07020','Edgewater','NJ',12.0),(4,'07010','Cliffside Park','NJ',12.0), +(4,'07022','Fairview','NJ',12.0), +-- Englewood +(5,'07631','Englewood','NJ',13.0),(5,'07632','Englewood Cliffs','NJ',13.0),(5,'07650','Palisades Park','NJ',13.0), +(5,'07666','Teaneck','NJ',13.0), +-- Ridgewood +(6,'07450','Ridgewood','NJ',13.0),(6,'07452','Glen Rock','NJ',13.0),(6,'07432','Midland Park','NJ',13.0), +(6,'07446','Ramsey','NJ',13.0),(6,'07401','Allendale','NJ',13.0), +-- Fair Lawn +(7,'07410','Fair Lawn','NJ',12.0),(7,'07470','Wayne','NJ',12.0),(7,'07012','Clifton','NJ',12.0), +-- Teaneck +(8,'07670','Tenafly','NJ',12.0),(8,'07621','Bergenfield','NJ',12.0),(8,'07624','Closter','NJ',12.0), +-- Garfield +(9,'07026','Garfield','NJ',11.0),(9,'07055','Passaic','NJ',11.0),(9,'07057','Wallington','NJ',11.0), +-- Nyack NY (Rockland) +(10,'10960','Nyack','NY',14.0),(10,'10994','West Nyack','NY',14.0),(10,'10989','Valley Cottage','NY',14.0), +(10,'10968','Piermont','NY',14.0),(10,'10520','Croton-on-Hudson','NY',14.0), +-- Nanuet NY (Rockland) +(11,'10954','Nanuet','NY',15.0),(11,'10901','Suffern','NY',15.0),(11,'10952','Monsey','NY',15.0), +(11,'10977','Spring Valley','NY',15.0),(11,'10956','New City','NY',15.0) +ON CONFLICT DO NOTHING; + +-- --------------------------------------------------------------------------- +-- 3. 카탈로그 / 상품 / 사이즈 / 옵션 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mall_category ( + id BIGSERIAL PRIMARY KEY, parent_id BIGINT, name VARCHAR(200) NOT NULL, code VARCHAR(60), + sort_order INTEGER DEFAULT 0, active BOOLEAN DEFAULT TRUE +); +-- 실데이터: Montvale Florist (montvalefloristnj.com) 카테고리 트리 +-- Occasions / Flower Type / Plants / Colors / Collections / Price Range +INSERT INTO mall_category (id, parent_id, name, code, sort_order) VALUES +-- 최상위 +(1,NULL,'Occasions','OCCASION',1),(2,NULL,'Flower Type','FLOWER',2), +(15,NULL,'Plants','PLANTS',3),(16,NULL,'Colors','COLOR',4), +(17,NULL,'Collections','COLLECTION',5),(18,NULL,'Price Range','PRICE',6), +-- Occasions +(3,1,'Birthday','BIRTHDAY',1),(4,1,'Anniversary','ANNIVERSARY',2),(5,1,'Sympathy & Funeral','SYMPATHY',3), +(6,1,'Get Well','GETWELL',4),(7,1,'Love & Romance','ROMANCE',5),(8,1,'Congratulations','CONGRATS',6), +(19,1,'Thinking of You','THINKING',7),(20,1,'Thank You','THANKYOU',8),(21,1,'Just Because','JUSTBECAUSE',9), +-- Flower Type +(9,2,'Roses','ROSES',1),(10,2,'Tulips','TULIPS',2),(11,2,'Lilies','LILIES',3), +(12,2,'Sunflowers','SUNFLOWERS',4),(13,2,'Hydrangeas','HYDRANGEAS',5),(14,2,'Mixed','MIXED',6), +-- Plants +(22,15,'Blooming Plants','BLOOMING_PLANTS',1),(23,15,'Green Plants','GREEN_PLANTS',2), +-- Colors +(24,16,'Red','RED',1),(25,16,'Pink','PINK',2),(26,16,'White','WHITE',3), +(27,16,'Purple','PURPLE',4),(28,16,'Yellow','YELLOW',5),(29,16,'Green','GREEN',6),(30,16,'Mixed','MIXED_COLOR',7), +-- Collections +(31,17,'Premium Collection','PREMIUM',1),(32,17,'Gift Baskets','GIFT_BASKETS',2), +(33,17,'Wedding','WEDDING',3),(34,17,'Custom Designs','CUSTOM',4),(35,17,'Florist''s Choice Daily Deal','DAILY_DEAL',5), +-- Price Range +(36,18,'Under $50','UNDER_50',1),(37,18,'$50 - $60','PR_50_60',2), +(38,18,'$60 - $80','PR_60_80',3),(39,18,'Over $80','OVER_80',4) +ON CONFLICT (id) DO NOTHING; +SELECT setval(pg_get_serial_sequence('mall_category','id'), GREATEST((SELECT MAX(id) FROM mall_category),1)); + +-- 상품: 행사/꽃종류/유통기한/사이즈는 별도 테이블(mall_product_size) +CREATE TABLE IF NOT EXISTS mall_product ( + id BIGSERIAL PRIMARY KEY, category_id BIGINT, sku VARCHAR(80), name VARCHAR(300) NOT NULL, + brand VARCHAR(120), description TEXT, price NUMERIC(12,2) NOT NULL, sale_price NUMERIC(12,2), + status VARCHAR(30) DEFAULT 'ON_SALE', stock INTEGER DEFAULT 0, thumbnail VARCHAR(600), + occasion VARCHAR(60), -- BIRTHDAY/ANNIVERSARY/... + flower_type VARCHAR(60), -- ROSES/TULIPS/... + shelf_life_days INTEGER DEFAULT 5, -- 생화 시한성(유통기한 일수) + rating_avg NUMERIC(3,2) DEFAULT 0, review_count INTEGER DEFAULT 0, sales_count INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT NOW() +); + +-- 3사이즈 (Original / Deluxe / Grand) — Bouqs 패턴 +CREATE TABLE IF NOT EXISTS mall_product_size ( + id BIGSERIAL PRIMARY KEY, product_id BIGINT NOT NULL, + size_code VARCHAR(20) NOT NULL, -- ORIGINAL / DELUXE / GRAND + label VARCHAR(60), price NUMERIC(12,2) NOT NULL, stem_count INTEGER, sort_order INTEGER DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_mall_psize_product ON mall_product_size(product_id); + +-- 옵션 (화병/카드메시지/포장) +CREATE TABLE IF NOT EXISTS mall_product_option ( + id BIGSERIAL PRIMARY KEY, product_id BIGINT NOT NULL, option_name VARCHAR(120), + option_value VARCHAR(200), extra_price NUMERIC(12,2) DEFAULT 0, stock INTEGER DEFAULT 0, + sku_suffix VARCHAR(60), option_type VARCHAR(40) -- VASE / CARD_MESSAGE / WRAP +); +CREATE TABLE IF NOT EXISTS mall_product_image ( + id BIGSERIAL PRIMARY KEY, product_id BIGINT NOT NULL, url VARCHAR(600), sort_order INTEGER DEFAULT 0, is_main BOOLEAN DEFAULT FALSE +); + +-- 실데이터: Montvale Florist 실상품(실명·실가) — image_url=/img/products/. +-- brand='Montvale Florist'. price = 사이트 표기 "From" 가격. 이미지는 backend static에서 그대로 서빙. +INSERT INTO mall_product (id, category_id, sku, name, brand, description, price, sale_price, status, stock, thumbnail, occasion, flower_type, shelf_life_days, rating_avg, review_count, sales_count) VALUES +(1,35,'MV-DAILY-DEAL','Florist''s Choice Daily Deal','Montvale Florist','Can''t decide? How about a surprise! A creative mix of seasonal blooms arranged with the designer''s freshest selection. Measures 10"-20" H (size will vary). 100% Florist-Designed and Hand-Delivered.',69.99,NULL,'ON_SALE',120,'/img/products/florists-choice-daily-deal.png','JUSTBECAUSE','MIXED',6,4.9,142,640), +(2,3,'MV-RADIANT-RAINBOW','Radiant Rainbow™','Montvale Florist','A vibrant, joyful arrangement bursting with a rainbow of seasonal blooms. Perfect for birthdays and any happy celebration.',79.95,NULL,'ON_SALE',90,'/img/products/radiant-rainbow.jpg','BIRTHDAY','MIXED',6,4.9,118,420), +(3,5,'MV-HALCYON-HEART','Halcyon Heart™','Montvale Florist','A serene, heartfelt tribute of soft blooms expressing peace, love, and remembrance.',79.95,NULL,'ON_SALE',60,'/img/products/halcyon-heart.jpg','SYMPATHY','MIXED',6,4.8,64,180), +(4,5,'MV-GRACEFUL-EMBRACE','Graceful Embrace','Montvale Florist','An elegant arrangement of gentle blooms offering comfort and a graceful embrace of sympathy.',79.95,NULL,'ON_SALE',55,'/img/products/graceful-embrace.jpg','SYMPATHY','MIXED',6,4.7,52,150), +(5,31,'MV-BLOOMING-DAYDREAM','Blooming Daydream','Montvale Florist','A premium, dreamy bouquet of lush garden blooms — a beautiful daydream brought to life.',99.95,NULL,'ON_SALE',45,'/img/products/blooming-daydream.jpg','ANNIVERSARY','MIXED',5,4.9,40,160), +(6,3,'MV-SPRING-SERENADE','Spring Serenade','Montvale Florist','A fresh, fragrant medley of spring flowers that sings of the season.',79.95,NULL,'ON_SALE',60,'/img/products/spring-serenade.jpg','BIRTHDAY','MIXED',5,4.7,38,140), +(7,23,'MV-GREEN-GRACE','Green Grace','Montvale Florist','A lush, verdant arrangement of greens and textured foliage — graceful and grounding.',99.95,NULL,'ON_SALE',40,'/img/products/green-grace.jpg','SYMPATHY','GREEN',7,4.8,30,110), +(8,9,'MV-12-RED-ROSES','12 Classic Red Roses','Montvale Florist','A timeless dozen of premium long-stemmed red roses — the ultimate expression of love.',99.95,NULL,'ON_SALE',100,'/img/products/classic-red-roses.jpg','ROMANCE','ROSES',6,4.9,210,720), +(9,20,'MV-JOYFUL-THANKS','Joyful Thanks™','Montvale Florist','A cheerful thank-you bouquet of bright seasonal blooms to express heartfelt gratitude.',59.95,NULL,'ON_SALE',80,'/img/products/joyful-thanks.jpg','THANKYOU','MIXED',6,4.6,58,210), +(10,31,'MV-BREATHTAKING-BEAUTY','Breathtaking Beauty™','Montvale Florist','A stunning premium arrangement of luxurious blooms that takes the breath away.',99.95,NULL,'ON_SALE',45,'/img/products/breathtaking-beauty.jpg','ROMANCE','MIXED',5,4.9,72,260), +(11,9,'MV-SWEETEST-ROSES','Sweetest Roses™','Montvale Florist','An opulent display of the sweetest premium roses — grand romance in full bloom.',199.99,NULL,'ON_SALE',25,'/img/products/sweetest-roses.jpg','ROMANCE','ROSES',6,5.0,44,120), +(12,32,'MV-FRUIT-BASKET','Fruit Basket','Montvale Florist','A bountiful gourmet fruit basket filled with fresh seasonal fruit — a wholesome, comforting gift.',99.95,NULL,'ON_SALE',40,'/img/products/fruit-basket.jpg','GETWELL','MIXED',10,4.6,33,140), +(13,13,'MV-HYDRANGEA-HIGHLIGHT','Hydrangea Highlight','Montvale Florist','Lush hydrangeas take center stage in this elegant, full-bodied arrangement.',79.95,NULL,'ON_SALE',45,'/img/products/hydrangea-highlight.jpg','BIRTHDAY','HYDRANGEAS',5,4.8,40,150), +(14,3,'MV-FULL-OF-SMILES','Full of Smiles','Montvale Florist','A bright, playful bouquet bursting with color to bring a smile to anyone''s day.',59.95,NULL,'ON_SALE',75,'/img/products/full-of-smiles.jpg','GETWELL','MIXED',6,4.6,71,260), +(15,35,'MV-FC-BIRTHDAY','Florist''s Choice for Birthday','Montvale Florist','Let our designer create a one-of-a-kind birthday arrangement with the freshest blooms of the day.',69.95,NULL,'ON_SALE',70,'/img/products/florists-choice-birthday.jpg','BIRTHDAY','MIXED',6,4.7,64,230), +(16,7,'MV-RASPBERRIES-BLISS','Raspberries & Bliss','Montvale Florist','A romantic blend of berry-toned blooms — pure blissful indulgence.',79.99,NULL,'ON_SALE',50,'/img/products/raspberries-and-bliss.jpg','ROMANCE','MIXED',6,4.7,32,120), +(17,31,'MV-MAGNIFICENT-MIDNIGHT','Magnificent Midnight','Montvale Florist','A dramatic premium arrangement of deep, moody blooms — magnificently bold.',199.95,NULL,'ON_SALE',25,'/img/products/magnificent-midnight.jpg','ANNIVERSARY','MIXED',5,4.9,28,90), +(18,31,'MV-FAIRY-BLOSSOM','Fairy Blossom Bouquet','Montvale Florist','A whimsical, ethereal bouquet of delicate blossoms — a touch of fairytale magic.',124.99,NULL,'ON_SALE',35,'/img/products/fairy-blossom-bouquet.jpg','ANNIVERSARY','MIXED',5,4.8,21,80), +(19,9,'MV-12-WHITE-ROSES','Dozen White Roses','Montvale Florist','A pristine dozen of premium white roses — pure elegance for any tribute or celebration.',99.95,NULL,'ON_SALE',70,'/img/products/dozen-white-roses.jpg','SYMPATHY','ROSES',6,4.8,55,200), +(20,9,'MV-LOVE-BRETT','Love Brett','Montvale Florist','A heartfelt rose arrangement designed to say "I love you" in the language of flowers.',99.95,NULL,'ON_SALE',45,'/img/products/love-brett.gif','ROMANCE','ROSES',6,4.6,18,60), +(21,9,'MV-SWEET-HONEY-KISSES','Sweet Honey Kisses','Montvale Florist','Warm honey and blush tones blend in this sweet, romantic rose bouquet.',79.95,NULL,'ON_SALE',50,'/img/products/sweet-honey-kisses.jpg','ROMANCE','ROSES',6,4.6,24,90), +(22,27,'MV-LAVENDER-LUST','Lavender Lust','Montvale Florist','A dreamy lavender-hued arrangement — soft, romantic, and utterly captivating.',79.95,NULL,'ON_SALE',45,'/img/products/lavender-lust.jpg','ROMANCE','MIXED',6,4.7,26,95), +(23,5,'MV-GARDEN-MEMORIES','Garden of Memories','Montvale Florist','A gentle garden-style arrangement honoring cherished memories with grace.',69.95,NULL,'ON_SALE',50,'/img/products/garden-of-memories.jpg','SYMPATHY','MIXED',6,4.7,30,110), +(24,11,'MV-TENDER-LILY-SPRAY','Tender Sentiments Lily Spray™','Montvale Florist','A graceful funeral lily spray expressing tender sentiments and deep sympathy.',159.95,NULL,'ON_SALE',20,'/img/products/tender-sentiments-lily-spray.jpg','SYMPATHY','LILIES',7,4.8,15,40), +(25,22,'MV-PEACE-LILY-PLANT','Peace Lily Plant','Montvale Florist','A lush, easy-care peace lily plant — a living symbol of peace and lasting remembrance.',79.95,NULL,'ON_SALE',55,'/img/products/peace-lily-plant.jpg','SYMPATHY','LILIES',30,4.8,42,160), +(26,32,'MV-TWILIGHT-BASKET','Love''s Twilight Sympathy Basket™','Montvale Florist','A serene basket of soft twilight blooms offering comfort and sympathy.',129.95,NULL,'ON_SALE',30,'/img/products/loves-twilight-sympathy-basket.jpg','SYMPATHY','MIXED',6,4.7,18,60), +(27,5,'MV-IN-LOVING-MEMORY','In Loving Memory','Montvale Florist','A dignified tribute arrangement crafted in loving memory of those dearly missed.',159.95,NULL,'ON_SALE',25,'/img/products/in-loving-memory.jpg','SYMPATHY','MIXED',6,4.8,22,70), +(28,32,'MV-AMETHYST-BASKET','Amethyst Sunset Sympathy Basket™','Montvale Florist','A rich basket of amethyst and sunset-toned blooms expressing heartfelt condolence.',129.95,NULL,'ON_SALE',28,'/img/products/amethyst-sunset-sympathy-basket.jpg','SYMPATHY','PURPLE',6,4.7,16,55), +(29,5,'MV-CALMING-COMFORT','Calming Comfort™','Montvale Florist','A soothing arrangement of soft pastel blooms offering calm and comfort in times of loss.',129.95,NULL,'ON_SALE',30,'/img/products/calming-comfort.jpg','SYMPATHY','MIXED',6,4.8,20,65), +(30,23,'MV-VERDANT-GARDENS','Verdant Gardens','Montvale Florist','A fresh, green garden arrangement full of lush foliage and natural texture.',79.95,NULL,'ON_SALE',45,'/img/products/verdant-gardens.jpg','SYMPATHY','GREEN',7,4.6,18,60), +(31,31,'MV-WITH-OPEN-HEARTS','With Open Hearts™','Montvale Florist','A grand, heartfelt premium tribute of abundant blooms offered with open hearts.',249.95,NULL,'ON_SALE',18,'/img/products/with-open-hearts.jpg','SYMPATHY','MIXED',6,4.9,12,40), +(32,7,'MV-STRAWBERRY-BLUSH','Strawberry Blush','Montvale Florist','A romantic blush-and-berry arrangement, sweet and softly captivating.',99.95,NULL,'ON_SALE',45,'/img/products/strawberry-blush.jpg','ROMANCE','PINK',6,4.7,24,85), +(33,35,'MV-FC-ROMANCE','Florist''s Choice for Romance','Montvale Florist','A designer''s romantic masterpiece, crafted with the most luxurious blooms of the day.',149.99,NULL,'ON_SALE',35,'/img/products/florists-choice-romance.jpg','ROMANCE','MIXED',6,4.8,20,70), +(34,7,'MV-DELIGHTFUL-DUALITY','Delightful Duality','Montvale Florist','A striking two-tone arrangement balancing bold and soft blooms in delightful harmony.',99.95,NULL,'ON_SALE',40,'/img/products/delightful-duality.jpg','ROMANCE','MIXED',6,4.7,19,65), +(35,31,'MV-NOTHING-COMPARES','Nothing Compares','Montvale Florist','An unrivaled premium arrangement of the rarest, most exquisite blooms — nothing compares.',299.95,NULL,'ON_SALE',15,'/img/products/nothing-compares.jpg','ROMANCE','MIXED',5,5.0,14,45), +(36,23,'MV-EMERALD-GARDEN','Emerald Garden','Montvale Florist','A rich emerald-green plant garden, lush and long-lasting.',119.95,NULL,'ON_SALE',30,'/img/products/emerald-garden.jpg','SYMPATHY','GREEN',30,4.7,17,55), +(37,5,'MV-FOREVER-SYMPATHY','Forever With Sympathy','Montvale Florist','A timeless sympathy arrangement of soft blooms expressing lasting condolence.',79.95,NULL,'ON_SALE',40,'/img/products/forever-with-sympathy.jpg','SYMPATHY','MIXED',6,4.6,16,55) +ON CONFLICT (id) DO NOTHING; +SELECT setval(pg_get_serial_sequence('mall_product','id'), GREATEST((SELECT MAX(id) FROM mall_product),1)); + +-- 상품 메인 이미지 시드 (detail 갤러리) — thumbnail 경로를 main 이미지로 +INSERT INTO mall_product_image (product_id, url, sort_order, is_main) +SELECT p.id, p.thumbnail, 0, TRUE FROM mall_product p +WHERE p.thumbnail IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM mall_product_image i WHERE i.product_id=p.id); + +-- 3사이즈 시드 (모든 상품에 Original/Deluxe/Grand) +INSERT INTO mall_product_size (product_id, size_code, label, price, stem_count, sort_order) +SELECT p.id, 'ORIGINAL','Original', p.price, 12, 1 FROM mall_product p +WHERE NOT EXISTS (SELECT 1 FROM mall_product_size s WHERE s.product_id=p.id AND s.size_code='ORIGINAL'); +INSERT INTO mall_product_size (product_id, size_code, label, price, stem_count, sort_order) +SELECT p.id, 'DELUXE','Deluxe', ROUND(p.price*1.4,2), 18, 2 FROM mall_product p +WHERE NOT EXISTS (SELECT 1 FROM mall_product_size s WHERE s.product_id=p.id AND s.size_code='DELUXE'); +INSERT INTO mall_product_size (product_id, size_code, label, price, stem_count, sort_order) +SELECT p.id, 'GRAND','Grand', ROUND(p.price*1.9,2), 24, 3 FROM mall_product p +WHERE NOT EXISTS (SELECT 1 FROM mall_product_size s WHERE s.product_id=p.id AND s.size_code='GRAND'); + +-- 옵션 시드 (화병/카드/포장) +INSERT INTO mall_product_option (product_id, option_name, option_value, extra_price, stock, option_type) +SELECT p.id, 'Vase','Glass Vase Upgrade', 12.00, 999, 'VASE' FROM mall_product p +WHERE NOT EXISTS (SELECT 1 FROM mall_product_option o WHERE o.product_id=p.id AND o.option_type='VASE'); +INSERT INTO mall_product_option (product_id, option_name, option_value, extra_price, stock, option_type) +SELECT p.id, 'Wrap','Premium Gift Wrap', 6.00, 999, 'WRAP' FROM mall_product p +WHERE NOT EXISTS (SELECT 1 FROM mall_product_option o WHERE o.product_id=p.id AND o.option_type='WRAP'); +INSERT INTO mall_product_option (product_id, option_name, option_value, extra_price, stock, option_type) +SELECT p.id, 'Card','Personalized Card Message', 0.00, 999, 'CARD_MESSAGE' FROM mall_product p +WHERE NOT EXISTS (SELECT 1 FROM mall_product_option o WHERE o.product_id=p.id AND o.option_type='CARD_MESSAGE'); + +-- --------------------------------------------------------------------------- +-- 4. 매장별 재고 ON/OFF (Store_Inventory) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mall_store_inventory ( + id BIGSERIAL PRIMARY KEY, store_id BIGINT NOT NULL, product_id BIGINT NOT NULL, + available BOOLEAN DEFAULT TRUE, -- 당일 입고 ON/OFF + on_hand INTEGER DEFAULT 0, + updated_by VARCHAR(200), updated_at TIMESTAMP DEFAULT NOW(), + UNIQUE(store_id, product_id) +); +CREATE INDEX IF NOT EXISTS idx_mall_sinv_store ON mall_store_inventory(store_id); +-- 모든 매장 × 모든 상품 = 가용(ON) 기본 시드 +INSERT INTO mall_store_inventory (store_id, product_id, available, on_hand) +SELECT s.id, p.id, TRUE, 25 FROM mall_store s CROSS JOIN mall_product p +WHERE NOT EXISTS (SELECT 1 FROM mall_store_inventory si WHERE si.store_id=s.id AND si.product_id=p.id); + +-- 단일 재고 (옵션 없는 ERP 동기 — 기존 호환) +CREATE TABLE IF NOT EXISTS mall_inventory ( + id BIGSERIAL PRIMARY KEY, product_id BIGINT, option_id BIGINT, sku VARCHAR(80), + on_hand INTEGER DEFAULT 0, reserved INTEGER DEFAULT 0, safety_stock INTEGER DEFAULT 0, + erp_sync_status VARCHAR(20) DEFAULT 'NONE', updated_at TIMESTAMP DEFAULT NOW() +); + +-- --------------------------------------------------------------------------- +-- 5. 스케줄링 (타임슬롯·당일마감·공휴일 블록아웃·서지프라이싱) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mall_schedule_slot ( + id BIGSERIAL PRIMARY KEY, store_id BIGINT NOT NULL, slot_date DATE NOT NULL, + slot_label VARCHAR(60) NOT NULL, -- 'Morning 9-12', 'Afternoon 12-3', 'Evening 3-6' + start_time VARCHAR(10), end_time VARCHAR(10), + capacity INTEGER DEFAULT 20, booked INTEGER DEFAULT 0, + surge_multiplier NUMERIC(4,2) DEFAULT 1.00, -- 서지프라이싱 배수 + active BOOLEAN DEFAULT TRUE +); +CREATE INDEX IF NOT EXISTS idx_mall_slot_store_date ON mall_schedule_slot(store_id, slot_date); + +-- 공휴일 블록아웃 + 서지 (밸런타인/어머니날) +CREATE TABLE IF NOT EXISTS mall_holiday ( + id BIGSERIAL PRIMARY KEY, holiday_date DATE NOT NULL, name VARCHAR(120), + blocked BOOLEAN DEFAULT FALSE, -- 주문 차단 + surge_multiplier NUMERIC(4,2) DEFAULT 1.00, + store_id BIGINT, -- NULL=전 매장 + UNIQUE(holiday_date, store_id) +); +INSERT INTO mall_holiday (holiday_date, name, blocked, surge_multiplier, store_id) VALUES +('2026-02-14','Valentine''s Day', FALSE, 1.50, NULL), +('2026-05-10','Mother''s Day', FALSE, 1.40, NULL), +('2026-12-25','Christmas Day', TRUE, 1.00, NULL) +ON CONFLICT (holiday_date, store_id) DO NOTHING; + +-- --------------------------------------------------------------------------- +-- 6. 장바구니 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mall_cart_item ( + id BIGSERIAL PRIMARY KEY, owner VARCHAR(200) NOT NULL, product_id BIGINT, option_id BIGINT, + size_code VARCHAR(20), product_name VARCHAR(300), option_label VARCHAR(200), + unit_price NUMERIC(12,2), quantity INTEGER DEFAULT 1, + card_message VARCHAR(500), created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mall_cart_owner ON mall_cart_item(owner); + +-- --------------------------------------------------------------------------- +-- 7. 주문 (로컬배송 vs 매장픽업, 예약 날짜·슬롯) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mall_order ( + id BIGSERIAL PRIMARY KEY, order_no VARCHAR(40) UNIQUE NOT NULL, owner VARCHAR(200) NOT NULL, + store_id BIGINT, -- 처리 매장 + fulfillment_type VARCHAR(20) DEFAULT 'DELIVERY', -- DELIVERY / PICKUP + status VARCHAR(30) DEFAULT 'PENDING', + total_amount NUMERIC(12,2), discount_amount NUMERIC(12,2) DEFAULT 0, + tax_amount NUMERIC(12,2) DEFAULT 0, surge_amount NUMERIC(12,2) DEFAULT 0, + pay_amount NUMERIC(12,2), + receiver_name VARCHAR(200), receiver_phone VARCHAR(60), address VARCHAR(600), + delivery_zip VARCHAR(10), + scheduled_date DATE, slot_id BIGINT, slot_label VARCHAR(60), + card_message VARCHAR(500), memo VARCHAR(500), coupon_id BIGINT, + proof_photo_url VARCHAR(600), -- 꽃사진(제작완료) Proof of Quality + pod_photo_url VARCHAR(600), -- 문앞사진 Proof of Delivery + created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mall_order_store ON mall_order(store_id); +CREATE INDEX IF NOT EXISTS idx_mall_order_status ON mall_order(status); + +CREATE TABLE IF NOT EXISTS mall_order_item ( + id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL, product_id BIGINT, option_id BIGINT, + size_code VARCHAR(20), product_name VARCHAR(300), option_label VARCHAR(200), + unit_price NUMERIC(12,2), quantity INTEGER, line_amount NUMERIC(12,2) +); + +-- --------------------------------------------------------------------------- +-- 8. 결제 (민감정보 AES 암호화 *_enc) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mall_payment ( + id BIGSERIAL PRIMARY KEY, order_id BIGINT, method VARCHAR(40), pg_tid VARCHAR(120), + amount NUMERIC(12,2), status VARCHAR(30) DEFAULT 'READY', + card_no_enc VARCHAR(500), account_no_enc VARCHAR(500), + vbank_no VARCHAR(60), vbank_bank VARCHAR(80), + paid_at TIMESTAMP, created_at TIMESTAMP DEFAULT NOW() +); + +-- --------------------------------------------------------------------------- +-- 9. 구독 (주간/격주/월간) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mall_subscription ( + id BIGSERIAL PRIMARY KEY, owner VARCHAR(200) NOT NULL, store_id BIGINT, product_id BIGINT, + size_code VARCHAR(20), frequency VARCHAR(20) DEFAULT 'WEEKLY', -- WEEKLY/BIWEEKLY/MONTHLY + delivery_zip VARCHAR(10), address VARCHAR(600), receiver_name VARCHAR(200), receiver_phone VARCHAR(60), + next_delivery_date DATE, status VARCHAR(20) DEFAULT 'ACTIVE', -- ACTIVE/PAUSED/CANCELLED + price NUMERIC(12,2), created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mall_sub_owner ON mall_subscription(owner); + +-- --------------------------------------------------------------------------- +-- 10. 매장간 재고 이양 (Transfer) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mall_transfer ( + id BIGSERIAL PRIMARY KEY, from_store_id BIGINT NOT NULL, to_store_id BIGINT NOT NULL, + product_id BIGINT NOT NULL, quantity INTEGER NOT NULL, + status VARCHAR(20) DEFAULT 'REQUESTED', -- REQUESTED/APPROVED/REJECTED/COMPLETED + requested_by VARCHAR(200), approved_by VARCHAR(200), reason VARCHAR(400), + ai_recommended BOOLEAN DEFAULT FALSE, + created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() +); + +-- --------------------------------------------------------------------------- +-- 11. 배송 / 기사 라우팅 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mall_delivery ( + id BIGSERIAL PRIMARY KEY, order_id BIGINT, store_id BIGINT, + carrier VARCHAR(120), tracking_no VARCHAR(120), + driver_name VARCHAR(120), driver_phone VARCHAR(60), + status VARCHAR(30) DEFAULT 'READY', -- READY/IN_TRANSIT/OUT_FOR_DELIVERY/DELIVERED/FAILED + receiver_name VARCHAR(200), address VARCHAR(600), delivery_zip VARCHAR(10), + lat NUMERIC(10,6), lng NUMERIC(10,6), route_seq INTEGER, eta VARCHAR(40), pod_photo_url VARCHAR(600), + shipped_at TIMESTAMP, delivered_at TIMESTAMP, + created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() +); + +-- --------------------------------------------------------------------------- +-- 12. 리뷰 / 회원 / CS / 찜 +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mall_review ( + id BIGSERIAL PRIMARY KEY, product_id BIGINT, order_id BIGINT, author VARCHAR(200), + rating INTEGER, title VARCHAR(300), content TEXT, image_url VARCHAR(600), + helpful_count INTEGER DEFAULT 0, store_id BIGINT, created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mall_review_product ON mall_review(product_id); +INSERT INTO mall_review (product_id, author, rating, title, content, store_id) VALUES +(8,'jane',5,'Stunning red roses','A perfect classic dozen — arrived fresh and beautifully arranged. My wife loved them!',1), +(2,'jane',5,'So radiant!','The Radiant Rainbow lived up to its name — vibrant and full of life.',1), +(1,'jane',5,'Best surprise','The Florist''s Choice Daily Deal was even prettier than I imagined. Trust the designers!',1), +(14,'jane',4,'Brought a smile','Full of Smiles really did brighten the whole room.',1) +ON CONFLICT DO NOTHING; + +CREATE TABLE IF NOT EXISTS mall_member ( + id BIGSERIAL PRIMARY KEY, username VARCHAR(200) UNIQUE, display_name VARCHAR(200), + email VARCHAR(200), phone VARCHAR(60), default_zip VARCHAR(10), default_address VARCHAR(600), + crm_customer_key VARCHAR(120), tier VARCHAR(40) DEFAULT 'STANDARD', + created_at TIMESTAMP DEFAULT NOW() +); +INSERT INTO mall_member (username, display_name, email, default_zip) VALUES +('jane','Jane Doe','jane@example.com','07645') ON CONFLICT (username) DO NOTHING; + +CREATE TABLE IF NOT EXISTS mall_cs_ticket ( + id BIGSERIAL PRIMARY KEY, owner VARCHAR(200), order_no VARCHAR(40), category VARCHAR(60), + subject VARCHAR(300), content TEXT, status VARCHAR(20) DEFAULT 'OPEN', + itsm_sr_id VARCHAR(60), ai_reply TEXT, created_at TIMESTAMP DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS mall_wishlist ( + id BIGSERIAL PRIMARY KEY, owner VARCHAR(200) NOT NULL, product_id BIGINT NOT NULL, + created_at TIMESTAMP DEFAULT NOW(), UNIQUE(owner, product_id) +); + +-- --------------------------------------------------------------------------- +-- 13. 프로모션 (기존 호환) +-- --------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS mall_promotion ( + id BIGSERIAL PRIMARY KEY, title VARCHAR(300), banner VARCHAR(600), description TEXT, + category_id BIGINT, start_date DATE, end_date DATE, active BOOLEAN DEFAULT TRUE +); +CREATE TABLE IF NOT EXISTS mall_coupon ( + id BIGSERIAL PRIMARY KEY, code VARCHAR(60) UNIQUE, name VARCHAR(200), + discount_type VARCHAR(20) DEFAULT 'FIXED', discount_value NUMERIC(12,2), + min_order_amount NUMERIC(12,2) DEFAULT 0, max_discount NUMERIC(12,2), + valid_from DATE, valid_to DATE, issued_limit INTEGER, issued_count INTEGER DEFAULT 0, + active BOOLEAN DEFAULT TRUE +); +INSERT INTO mall_coupon (code, name, discount_type, discount_value, min_order_amount) VALUES +('WELCOME10','Welcome $10 Off','FIXED',10.00,50.00), +('SPRING15','Spring 15%','PERCENT',15.00,40.00) +ON CONFLICT (code) DO NOTHING; + +-- --------------------------------------------------------------------------- +-- 14. 등급별 고객관리 (Loyalty) — 등급기준·등급별 혜택·포인트 원장 +-- --------------------------------------------------------------------------- +-- 등급 기준(누적 구매액/주문수 임계). 자동 산정 시 12개월 누적값과 비교. +-- tier_code: BASIC / SILVER / GOLD / VIP (rank ASC = 낮은 등급) +CREATE TABLE IF NOT EXISTS mall_tier_benefit ( + id BIGSERIAL PRIMARY KEY, + tier_code VARCHAR(20) UNIQUE NOT NULL, -- BASIC/SILVER/GOLD/VIP + name VARCHAR(80) NOT NULL, + tier_rank INTEGER NOT NULL, -- 1=BASIC ... 4=VIP + min_spend_12m NUMERIC(12,2) DEFAULT 0, -- 등급 진입 누적 구매액(12개월) + min_orders_12m INTEGER DEFAULT 0, -- 등급 진입 누적 주문수(12개월) + discount_rate NUMERIC(5,2) DEFAULT 0, -- 등급 할인율(%) + free_ship_threshold NUMERIC(12,2), -- 무료배송 임계액(NULL=없음, 0=항상 무료) + point_earn_rate NUMERIC(5,2) DEFAULT 1.0, -- 포인트 적립률(% of pay_amount) + priority_slot BOOLEAN DEFAULT FALSE, -- 피크시즌 우선 타임슬롯 + birthday_coupon BOOLEAN DEFAULT FALSE, -- 생일 쿠폰/꽃 + exclusive_events BOOLEAN DEFAULT FALSE, -- 전용 이벤트 접근 + color VARCHAR(20), -- UI 배지 색상 + active BOOLEAN DEFAULT TRUE, updated_at TIMESTAMP DEFAULT NOW() +); +INSERT INTO mall_tier_benefit + (tier_code, name, tier_rank, min_spend_12m, min_orders_12m, discount_rate, free_ship_threshold, point_earn_rate, priority_slot, birthday_coupon, exclusive_events, color) VALUES +('BASIC','일반 (Basic)', 1, 0.00, 0, 0.00, NULL, 1.0, FALSE, FALSE, FALSE, '#9CA3AF'), +('SILVER','실버 (Silver)',2, 300.00, 3, 3.00, 75.00, 1.5, FALSE, TRUE, FALSE, '#94A3B8'), +('GOLD','골드 (Gold)', 3, 1000.00, 8, 5.00, 50.00, 2.0, TRUE, TRUE, TRUE, '#D4AF37'), +('VIP','VIP', 4, 3000.00,20, 8.00, 0.00, 3.0, TRUE, TRUE, TRUE, '#7C3AED') +ON CONFLICT (tier_code) DO NOTHING; + +-- 포인트 원장(ledger): 적립(EARN)/사용(USE)/만료(EXPIRE)/조정(ADJUST). 잔액=SUM(points). +CREATE TABLE IF NOT EXISTS mall_point_ledger ( + id BIGSERIAL PRIMARY KEY, owner VARCHAR(200) NOT NULL, + entry_type VARCHAR(20) NOT NULL, -- EARN/USE/EXPIRE/ADJUST + points INTEGER NOT NULL, -- 적립 +, 사용/만료 - + order_no VARCHAR(40), reason VARCHAR(300), + expire_at DATE, -- 적립분 만료일(EARN) + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mall_point_owner ON mall_point_ledger(owner); + +-- --------------------------------------------------------------------------- +-- 15. 이벤트 / 캠페인 (Event) +-- --------------------------------------------------------------------------- +-- event_type: DISCOUNT/POINT_BONUS/GIFT/TIER_ONLY/SEASON +CREATE TABLE IF NOT EXISTS mall_event ( + id BIGSERIAL PRIMARY KEY, code VARCHAR(60) UNIQUE, title VARCHAR(300) NOT NULL, + event_type VARCHAR(30) DEFAULT 'DISCOUNT', + description TEXT, + start_date DATE, end_date DATE, + target_tiers VARCHAR(120), -- 콤마구분 등급코드(빈값=전체) 예: 'GOLD,VIP' + store_ids VARCHAR(200), -- 콤마구분 매장ID(빈값=전 매장/권역) + coupon_id BIGINT, -- 연계 쿠폰(이벤트 참여 시 발급) + bonus_point_rate NUMERIC(5,2), -- POINT_BONUS 추가 적립률(%) + gift_description VARCHAR(300), -- GIFT 사은품 설명 + status VARCHAR(20) DEFAULT 'DRAFT', -- DRAFT/PUBLISHED/ENDED + active BOOLEAN DEFAULT TRUE, + created_by VARCHAR(200), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mall_event_status ON mall_event(status); + +-- 스토어프론트 배너(이미지/문구/링크) +CREATE TABLE IF NOT EXISTS mall_event_banner ( + id BIGSERIAL PRIMARY KEY, event_id BIGINT NOT NULL, + image_url VARCHAR(600), headline VARCHAR(300), subtext VARCHAR(400), + link_url VARCHAR(600), sort_order INTEGER DEFAULT 0, active BOOLEAN DEFAULT TRUE +); +CREATE INDEX IF NOT EXISTS idx_mall_evtbanner_event ON mall_event_banner(event_id); + +-- 이벤트 참여 이력(성과 집계용) +CREATE TABLE IF NOT EXISTS mall_event_participation ( + id BIGSERIAL PRIMARY KEY, event_id BIGINT NOT NULL, owner VARCHAR(200) NOT NULL, + coupon_id BIGINT, order_no VARCHAR(40), reward VARCHAR(200), + created_at TIMESTAMP DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_mall_evtpart_event ON mall_event_participation(event_id); + +-- 샘플 시즌 이벤트 시드 (밸런타인·어머니날) +INSERT INTO mall_event (id, code, title, event_type, description, start_date, end_date, target_tiers, coupon_id, bonus_point_rate, status, active, created_by) VALUES +(1,'VDAY2026','Valentine''s Day Bloom Sale','SEASON','Celebrate love with 15% off select bouquets.','2026-02-07','2026-02-14','', 2, NULL,'PUBLISHED',TRUE,'system'), +(2,'MDAY2026','Mother''s Day Special','SEASON','Honor Mom — bonus points + free delivery on Deluxe.','2026-05-03','2026-05-10','', NULL, 2.0,'PUBLISHED',TRUE,'system'), +(3,'VIPSPRING','VIP Spring Preview','TIER_ONLY','Exclusive early access for Gold & VIP members.','2026-03-01','2026-03-31','GOLD,VIP', NULL, NULL,'PUBLISHED',TRUE,'system') +ON CONFLICT (id) DO NOTHING; +SELECT setval(pg_get_serial_sequence('mall_event','id'), GREATEST((SELECT MAX(id) FROM mall_event),1)); + +INSERT INTO mall_event_banner (event_id, image_url, headline, subtext, link_url, sort_order) VALUES +(1,'/img/event-valentine.jpg','Valentine''s Day Bloom Sale','15% off romantic roses & more','/events/VDAY2026',1), +(2,'/img/event-mother.jpg','Mother''s Day Special','Bonus points + free delivery on Deluxe','/events/MDAY2026',1) +ON CONFLICT DO NOTHING; diff --git a/backend/src/main/resources/mapper/AdminUserMapper.xml b/backend/src/main/resources/mapper/AdminUserMapper.xml new file mode 100644 index 0000000..0279811 --- /dev/null +++ b/backend/src/main/resources/mapper/AdminUserMapper.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + INSERT INTO mall_account (username, password_hash, role, display_name, is_active) + VALUES (#{username}, #{passwordHash}, #{role}, #{displayName}, #{active}) + + + UPDATE mall_account SET role = #{role} WHERE id = #{id} + UPDATE mall_account SET is_active = #{active} WHERE id = #{id} + UPDATE mall_account SET password_hash = #{passwordHash} WHERE id = #{id} + DELETE FROM mall_account WHERE id = #{id} + + + + + diff --git a/backend/src/main/resources/mapper/AnalyticsMapper.xml b/backend/src/main/resources/mapper/AnalyticsMapper.xml new file mode 100644 index 0000000..5bf8c40 --- /dev/null +++ b/backend/src/main/resources/mapper/AnalyticsMapper.xml @@ -0,0 +1,37 @@ + + + + + + + + + diff --git a/backend/src/main/resources/mapper/AuditLogMapper.xml b/backend/src/main/resources/mapper/AuditLogMapper.xml new file mode 100644 index 0000000..5d054fe --- /dev/null +++ b/backend/src/main/resources/mapper/AuditLogMapper.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + INSERT INTO mall_audit_log (actor, action, target, detail) + VALUES (#{actor}, #{action}, #{target}, #{detail}) + + + + + diff --git a/backend/src/main/resources/mapper/CartMapper.xml b/backend/src/main/resources/mapper/CartMapper.xml new file mode 100644 index 0000000..8db80d4 --- /dev/null +++ b/backend/src/main/resources/mapper/CartMapper.xml @@ -0,0 +1,24 @@ + + + + + + + + INSERT INTO mall_cart_item (owner, product_id, option_id, size_code, card_message, quantity) + VALUES (#{owner}, #{productId}, #{optionId}, #{sizeCode}, #{cardMessage}, COALESCE(#{quantity},1)) + + UPDATE mall_cart_item SET quantity = #{quantity} WHERE id = #{id} AND owner = #{owner} + DELETE FROM mall_cart_item WHERE id = #{id} AND owner = #{owner} + DELETE FROM mall_cart_item WHERE owner = #{owner} + diff --git a/backend/src/main/resources/mapper/CategoryMapper.xml b/backend/src/main/resources/mapper/CategoryMapper.xml new file mode 100644 index 0000000..4e8df76 --- /dev/null +++ b/backend/src/main/resources/mapper/CategoryMapper.xml @@ -0,0 +1,23 @@ + + + + + + + + INSERT INTO mall_category (parent_id, name, code, sort_order, active) + VALUES (#{parentId}, #{name}, #{code}, COALESCE(#{sortOrder},0), COALESCE(#{active},TRUE)) + + + UPDATE mall_category SET parent_id=#{parentId}, name=#{name}, code=#{code}, + sort_order=COALESCE(#{sortOrder},sort_order), active=COALESCE(#{active},active) WHERE id=#{id} + + DELETE FROM mall_category WHERE id=#{id} + diff --git a/backend/src/main/resources/mapper/CsMapper.xml b/backend/src/main/resources/mapper/CsMapper.xml new file mode 100644 index 0000000..190af3a --- /dev/null +++ b/backend/src/main/resources/mapper/CsMapper.xml @@ -0,0 +1,20 @@ + + + + + + + + INSERT INTO mall_cs_ticket (owner, order_no, category, subject, content, status) + VALUES (#{owner}, #{orderNo}, #{category}, #{subject}, #{content}, COALESCE(#{status},'OPEN')) + + UPDATE mall_cs_ticket SET itsm_sr_id = #{srId} WHERE id = #{id} + UPDATE mall_cs_ticket SET ai_reply = #{aiReply}, status = 'ANSWERED' WHERE id = #{id} + UPDATE mall_cs_ticket SET status = #{status} WHERE id = #{id} + diff --git a/backend/src/main/resources/mapper/DeliveryMapper.xml b/backend/src/main/resources/mapper/DeliveryMapper.xml new file mode 100644 index 0000000..2d45caf --- /dev/null +++ b/backend/src/main/resources/mapper/DeliveryMapper.xml @@ -0,0 +1,25 @@ + + + + + + + + INSERT INTO mall_delivery (order_id, carrier, tracking_no, status, receiver_name, address, shipped_at) + VALUES (#{orderId}, #{carrier}, #{trackingNo}, COALESCE(#{status},'READY'), #{receiverName}, #{address}, #{shippedAt}) + + + UPDATE mall_delivery SET carrier=#{carrier}, tracking_no=#{trackingNo}, status=COALESCE(#{status},status), + receiver_name=#{receiverName}, address=#{address} WHERE id=#{id} + + + UPDATE mall_delivery SET status=#{status}, + shipped_at = CASE WHEN #{status}='IN_TRANSIT' AND shipped_at IS NULL THEN NOW() ELSE shipped_at END, + delivered_at = CASE WHEN #{status}='DELIVERED' THEN NOW() ELSE delivered_at END + WHERE id=#{id} + + diff --git a/backend/src/main/resources/mapper/EventMapper.xml b/backend/src/main/resources/mapper/EventMapper.xml new file mode 100644 index 0000000..29968e8 --- /dev/null +++ b/backend/src/main/resources/mapper/EventMapper.xml @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + INSERT INTO mall_event + (code, title, event_type, description, start_date, end_date, target_tiers, store_ids, + coupon_id, bonus_point_rate, gift_description, status, active, created_by) + VALUES + (#{code}, #{title}, COALESCE(#{eventType},'DISCOUNT'), #{description}, #{startDate}, #{endDate}, + #{targetTiers}, #{storeIds}, #{couponId}, #{bonusPointRate}, #{giftDescription}, + COALESCE(#{status},'DRAFT'), COALESCE(#{active},TRUE), #{createdBy}) + + + UPDATE mall_event SET + title=#{title}, event_type=COALESCE(#{eventType},event_type), description=#{description}, + start_date=#{startDate}, end_date=#{endDate}, target_tiers=#{targetTiers}, store_ids=#{storeIds}, + coupon_id=#{couponId}, bonus_point_rate=#{bonusPointRate}, gift_description=#{giftDescription}, + status=COALESCE(#{status},status), active=COALESCE(#{active},active), updated_at=NOW() + WHERE id=#{id} + + UPDATE mall_event SET status=#{status}, updated_at=NOW() WHERE id=#{id} + DELETE FROM mall_event WHERE id=#{id} + + + + + + + INSERT INTO mall_event_banner (event_id, image_url, headline, subtext, link_url, sort_order, active) + VALUES (#{eventId}, #{imageUrl}, #{headline}, #{subtext}, #{linkUrl}, COALESCE(#{sortOrder},0), COALESCE(#{active},TRUE)) + + DELETE FROM mall_event_banner WHERE id=#{id} + DELETE FROM mall_event_banner WHERE event_id=#{eventId} + + + + INSERT INTO mall_event_participation (event_id, owner, coupon_id, order_no, reward) + VALUES (#{eventId}, #{owner}, #{couponId}, #{orderNo}, #{reward}) + + + + diff --git a/backend/src/main/resources/mapper/InventoryMapper.xml b/backend/src/main/resources/mapper/InventoryMapper.xml new file mode 100644 index 0000000..fc4e766 --- /dev/null +++ b/backend/src/main/resources/mapper/InventoryMapper.xml @@ -0,0 +1,20 @@ + + + + + + + + INSERT INTO mall_inventory (product_id, option_id, sku, on_hand, reserved, safety_stock, erp_sync_status) + VALUES (#{productId}, #{optionId}, #{sku}, COALESCE(#{onHand},0), COALESCE(#{reserved},0), COALESCE(#{safetyStock},0), COALESCE(#{erpSyncStatus},'NONE')) + + UPDATE mall_inventory SET on_hand = GREATEST(COALESCE(on_hand,0) + #{delta}, 0), updated_at = NOW() WHERE id = #{id} + + UPDATE mall_inventory SET erp_sync_status = #{status}, + on_hand = COALESCE(#{onHand}, on_hand), updated_at = NOW() WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/LoyaltyMapper.xml b/backend/src/main/resources/mapper/LoyaltyMapper.xml new file mode 100644 index 0000000..f1c66b6 --- /dev/null +++ b/backend/src/main/resources/mapper/LoyaltyMapper.xml @@ -0,0 +1,86 @@ + + + + + + + + + + INSERT INTO mall_tier_benefit + (tier_code, name, tier_rank, min_spend_12m, min_orders_12m, discount_rate, free_ship_threshold, + point_earn_rate, priority_slot, birthday_coupon, exclusive_events, color, active) + VALUES + (#{tierCode}, #{name}, #{tierRank}, COALESCE(#{minSpend12m},0), COALESCE(#{minOrders12m},0), + COALESCE(#{discountRate},0), #{freeShipThreshold}, COALESCE(#{pointEarnRate},1.0), + COALESCE(#{prioritySlot},FALSE), COALESCE(#{birthdayCoupon},FALSE), COALESCE(#{exclusiveEvents},FALSE), + #{color}, COALESCE(#{active},TRUE)) + + + UPDATE mall_tier_benefit SET + name=#{name}, tier_rank=#{tierRank}, + min_spend_12m=COALESCE(#{minSpend12m},min_spend_12m), + min_orders_12m=COALESCE(#{minOrders12m},min_orders_12m), + discount_rate=COALESCE(#{discountRate},discount_rate), + free_ship_threshold=#{freeShipThreshold}, + point_earn_rate=COALESCE(#{pointEarnRate},point_earn_rate), + priority_slot=COALESCE(#{prioritySlot},priority_slot), + birthday_coupon=COALESCE(#{birthdayCoupon},birthday_coupon), + exclusive_events=COALESCE(#{exclusiveEvents},exclusive_events), + color=#{color}, active=COALESCE(#{active},active), updated_at=NOW() + WHERE id=#{id} + + DELETE FROM mall_tier_benefit WHERE id=#{id} + + + + + + + UPDATE mall_member SET tier = #{tier} WHERE username = #{username} + + + + + + INSERT INTO mall_point_ledger (owner, entry_type, points, order_no, reason, expire_at) + VALUES (#{owner}, #{entryType}, #{points}, #{orderNo}, #{reason}, #{expireAt}) + + + + + + + + diff --git a/backend/src/main/resources/mapper/MemberMapper.xml b/backend/src/main/resources/mapper/MemberMapper.xml new file mode 100644 index 0000000..9430eb6 --- /dev/null +++ b/backend/src/main/resources/mapper/MemberMapper.xml @@ -0,0 +1,14 @@ + + + + + + INSERT INTO mall_member (username, display_name, email, phone, default_zip, default_address, crm_customer_key, tier) + VALUES (#{username}, #{displayName}, #{email}, #{phone}, #{defaultZip}, #{defaultAddress}, #{crmCustomerKey}, COALESCE(#{tier},'STANDARD')) + ON CONFLICT (username) DO UPDATE SET + display_name = EXCLUDED.display_name, email = EXCLUDED.email, phone = EXCLUDED.phone, + default_zip = EXCLUDED.default_zip, default_address = EXCLUDED.default_address + + diff --git a/backend/src/main/resources/mapper/OrderMapper.xml b/backend/src/main/resources/mapper/OrderMapper.xml new file mode 100644 index 0000000..c94c3ce --- /dev/null +++ b/backend/src/main/resources/mapper/OrderMapper.xml @@ -0,0 +1,47 @@ + + + + + + + + + + INSERT INTO mall_order (order_no, owner, store_id, fulfillment_type, status, total_amount, discount_amount, + tax_amount, surge_amount, pay_amount, receiver_name, receiver_phone, address, delivery_zip, + scheduled_date, slot_id, slot_label, card_message, memo, coupon_id) + VALUES (#{orderNo}, #{owner}, #{storeId}, COALESCE(#{fulfillmentType},'DELIVERY'), COALESCE(#{status},'PENDING'), + #{totalAmount}, COALESCE(#{discountAmount},0), COALESCE(#{taxAmount},0), COALESCE(#{surgeAmount},0), + #{payAmount}, #{receiverName}, #{receiverPhone}, #{address}, #{deliveryZip}, + #{scheduledDate}, #{slotId}, #{slotLabel}, #{cardMessage}, #{memo}, #{couponId}) + + UPDATE mall_order SET status = #{status}, updated_at = NOW() WHERE id = #{id} + UPDATE mall_order SET proof_photo_url = #{url}, updated_at = NOW() WHERE id = #{id} + UPDATE mall_order SET pod_photo_url = #{url}, updated_at = NOW() WHERE id = #{id} + + + + INSERT INTO mall_order_item (order_id, product_id, option_id, size_code, product_name, option_label, unit_price, quantity, line_amount) + VALUES (#{orderId}, #{productId}, #{optionId}, #{sizeCode}, #{productName}, #{optionLabel}, #{unitPrice}, #{quantity}, #{lineAmount}) + + diff --git a/backend/src/main/resources/mapper/PaymentMapper.xml b/backend/src/main/resources/mapper/PaymentMapper.xml new file mode 100644 index 0000000..1c76c0e --- /dev/null +++ b/backend/src/main/resources/mapper/PaymentMapper.xml @@ -0,0 +1,17 @@ + + + + + + + + INSERT INTO mall_payment (order_id, method, pg_tid, amount, status, card_no_enc, account_no_enc, vbank_no, vbank_bank, paid_at) + VALUES (#{orderId}, #{method}, #{pgTid}, #{amount}, COALESCE(#{status},'READY'), + #{cardNoEnc}, #{accountNoEnc}, #{vbankNo}, #{vbankBank}, #{paidAt}) + + UPDATE mall_payment SET status = #{status}, paid_at = CASE WHEN #{status}='PAID' THEN NOW() ELSE paid_at END WHERE id = #{id} + diff --git a/backend/src/main/resources/mapper/ProductMapper.xml b/backend/src/main/resources/mapper/ProductMapper.xml new file mode 100644 index 0000000..a601fe6 --- /dev/null +++ b/backend/src/main/resources/mapper/ProductMapper.xml @@ -0,0 +1,81 @@ + + + + + + + AND category_id = #{categoryId} + AND (name ILIKE '%' || #{keyword} || '%' OR brand ILIKE '%' || #{keyword} || '%' OR description ILIKE '%' || #{keyword} || '%') + AND status = #{status} + AND occasion = #{occasion} + AND flower_type = #{flowerType} + AND COALESCE(sale_price, price) <= #{maxPrice} + + + + + + + + + + + INSERT INTO mall_product (category_id, sku, name, brand, description, price, sale_price, status, stock, thumbnail, + occasion, flower_type, shelf_life_days) + VALUES (#{categoryId}, #{sku}, #{name}, #{brand}, #{description}, #{price}, #{salePrice}, + COALESCE(#{status},'ON_SALE'), COALESCE(#{stock},0), #{thumbnail}, + #{occasion}, #{flowerType}, COALESCE(#{shelfLifeDays},5)) + + + + UPDATE mall_product SET category_id=#{categoryId}, name=#{name}, brand=#{brand}, description=#{description}, + price=#{price}, sale_price=#{salePrice}, status=COALESCE(#{status},status), stock=COALESCE(#{stock},stock), + thumbnail=#{thumbnail}, occasion=COALESCE(#{occasion},occasion), flower_type=COALESCE(#{flowerType},flower_type), + shelf_life_days=COALESCE(#{shelfLifeDays},shelf_life_days) WHERE id=#{id} + + UPDATE mall_product SET status=#{status} WHERE id=#{id} + UPDATE mall_product SET sales_count = COALESCE(sales_count,0) + #{qty}, stock = GREATEST(COALESCE(stock,0) - #{qty}, 0) WHERE id=#{id} + DELETE FROM mall_product WHERE id=#{id} + + + + INSERT INTO mall_product_option (product_id, option_name, option_value, extra_price, stock, sku_suffix, option_type) + VALUES (#{productId}, #{optionName}, #{optionValue}, COALESCE(#{extraPrice},0), COALESCE(#{stock},0), #{skuSuffix}, #{optionType}) + + DELETE FROM mall_product_option WHERE product_id = #{productId} + + + + INSERT INTO mall_product_size (product_id, size_code, label, price, stem_count, sort_order) + VALUES (#{productId}, #{sizeCode}, #{label}, #{price}, #{stemCount}, COALESCE(#{sortOrder},0)) + + DELETE FROM mall_product_size WHERE product_id = #{productId} + + + + INSERT INTO mall_product_image (product_id, url, sort_order, is_main) + VALUES (#{productId}, #{url}, COALESCE(#{sortOrder},0), COALESCE(#{isMain},FALSE)) + + DELETE FROM mall_product_image WHERE product_id = #{productId} + + diff --git a/backend/src/main/resources/mapper/PromotionMapper.xml b/backend/src/main/resources/mapper/PromotionMapper.xml new file mode 100644 index 0000000..9819fbd --- /dev/null +++ b/backend/src/main/resources/mapper/PromotionMapper.xml @@ -0,0 +1,42 @@ + + + + + + + + + + INSERT INTO mall_coupon (code, name, discount_type, discount_value, min_order_amount, max_discount, valid_from, valid_to, issued_limit, issued_count, active) + VALUES (#{code}, #{name}, #{discountType}, #{discountValue}, COALESCE(#{minOrderAmount},0), #{maxDiscount}, #{validFrom}, #{validTo}, #{issuedLimit}, 0, COALESCE(#{active},TRUE)) + + + UPDATE mall_coupon SET name=#{name}, discount_type=#{discountType}, discount_value=#{discountValue}, + min_order_amount=COALESCE(#{minOrderAmount},min_order_amount), max_discount=#{maxDiscount}, + valid_from=#{validFrom}, valid_to=#{validTo}, issued_limit=#{issuedLimit}, active=COALESCE(#{active},active) WHERE id=#{id} + + UPDATE mall_coupon SET issued_count = COALESCE(issued_count,0)+1 WHERE id=#{id} + DELETE FROM mall_coupon WHERE id=#{id} + + + + + + INSERT INTO mall_promotion (title, banner, description, category_id, start_date, end_date, active) + VALUES (#{title}, #{banner}, #{description}, #{categoryId}, #{startDate}, #{endDate}, COALESCE(#{active},TRUE)) + + + UPDATE mall_promotion SET title=#{title}, banner=#{banner}, description=#{description}, category_id=#{categoryId}, + start_date=#{startDate}, end_date=#{endDate}, active=COALESCE(#{active},active) WHERE id=#{id} + + DELETE FROM mall_promotion WHERE id=#{id} + + diff --git a/backend/src/main/resources/mapper/ReviewMapper.xml b/backend/src/main/resources/mapper/ReviewMapper.xml new file mode 100644 index 0000000..0604b35 --- /dev/null +++ b/backend/src/main/resources/mapper/ReviewMapper.xml @@ -0,0 +1,26 @@ + + + + + + + INSERT INTO mall_review (product_id, order_id, author, rating, title, content, image_url, store_id, helpful_count) + VALUES (#{productId}, #{orderId}, #{author}, #{rating}, #{title}, #{content}, #{imageUrl}, #{storeId}, 0) + + DELETE FROM mall_review WHERE id = #{id} AND author = #{author} + UPDATE mall_review SET helpful_count = COALESCE(helpful_count,0) + 1 WHERE id = #{id} + + + UPDATE mall_product SET + rating_avg = (SELECT COALESCE(ROUND(AVG(rating)::numeric,2),0) FROM mall_review WHERE product_id = #{productId}), + review_count = (SELECT COUNT(*) FROM mall_review WHERE product_id = #{productId}) + WHERE id = #{productId} + + + diff --git a/backend/src/main/resources/mapper/ScheduleMapper.xml b/backend/src/main/resources/mapper/ScheduleMapper.xml new file mode 100644 index 0000000..29da6e8 --- /dev/null +++ b/backend/src/main/resources/mapper/ScheduleMapper.xml @@ -0,0 +1,33 @@ + + + + + + + INSERT INTO mall_schedule_slot (store_id, slot_date, slot_label, start_time, end_time, capacity, booked, surge_multiplier, active) + VALUES (#{storeId}, #{slotDate}, #{slotLabel}, #{startTime}, #{endTime}, COALESCE(#{capacity},20), + COALESCE(#{booked},0), COALESCE(#{surgeMultiplier},1.00), COALESCE(#{active},TRUE)) + + UPDATE mall_schedule_slot SET booked = COALESCE(booked,0) + 1 WHERE id = #{id} AND booked < capacity + UPDATE mall_schedule_slot SET active = #{active} WHERE id = #{id} + + + + + INSERT INTO mall_holiday (holiday_date, name, blocked, surge_multiplier, store_id) + VALUES (#{holidayDate}, #{name}, COALESCE(#{blocked},FALSE), COALESCE(#{surgeMultiplier},1.00), #{storeId}) + ON CONFLICT (holiday_date, store_id) DO UPDATE SET + name = EXCLUDED.name, blocked = EXCLUDED.blocked, surge_multiplier = EXCLUDED.surge_multiplier + + UPDATE mall_holiday SET blocked = #{blocked} WHERE id = #{id} + diff --git a/backend/src/main/resources/mapper/SettingMapper.xml b/backend/src/main/resources/mapper/SettingMapper.xml new file mode 100644 index 0000000..51d3ca3 --- /dev/null +++ b/backend/src/main/resources/mapper/SettingMapper.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + INSERT INTO mall_setting (key, value, updated_at) + VALUES (#{key}, #{value}, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + + + diff --git a/backend/src/main/resources/mapper/StoreInventoryMapper.xml b/backend/src/main/resources/mapper/StoreInventoryMapper.xml new file mode 100644 index 0000000..42f77df --- /dev/null +++ b/backend/src/main/resources/mapper/StoreInventoryMapper.xml @@ -0,0 +1,31 @@ + + + + + + + + INSERT INTO mall_store_inventory (store_id, product_id, available, on_hand, updated_by, updated_at) + VALUES (#{storeId}, #{productId}, COALESCE(#{available},TRUE), COALESCE(#{onHand},0), #{updatedBy}, NOW()) + ON CONFLICT (store_id, product_id) DO UPDATE SET + available = EXCLUDED.available, on_hand = EXCLUDED.on_hand, updated_by = EXCLUDED.updated_by, updated_at = NOW() + + + INSERT INTO mall_store_inventory (store_id, product_id, available, updated_by, updated_at) + VALUES (#{storeId}, #{productId}, #{available}, #{updatedBy}, NOW()) + ON CONFLICT (store_id, product_id) DO UPDATE SET + available = #{available}, updated_by = #{updatedBy}, updated_at = NOW() + + + UPDATE mall_store_inventory SET on_hand = GREATEST(COALESCE(on_hand,0) + #{delta}, 0), updated_at = NOW() + WHERE store_id = #{storeId} AND product_id = #{productId} + + diff --git a/backend/src/main/resources/mapper/StoreMapper.xml b/backend/src/main/resources/mapper/StoreMapper.xml new file mode 100644 index 0000000..073e9e9 --- /dev/null +++ b/backend/src/main/resources/mapper/StoreMapper.xml @@ -0,0 +1,26 @@ + + + + + + + + INSERT INTO mall_store (code, name, city, state, zip, address, phone, timezone, open_time, close_time, + same_day_cutoff, delivery_radius_mi, daily_capacity, lat, lng, active) + VALUES (#{code}, #{name}, #{city}, #{state}, #{zip}, #{address}, #{phone}, COALESCE(#{timezone},'America/New_York'), + COALESCE(#{openTime},'09:00'), COALESCE(#{closeTime},'18:00'), COALESCE(#{sameDayCutoff},'12:00'), + COALESCE(#{deliveryRadiusMi},15.0), COALESCE(#{dailyCapacity},50), #{lat}, #{lng}, COALESCE(#{active},TRUE)) + + + UPDATE mall_store SET name=#{name}, city=#{city}, state=#{state}, zip=#{zip}, address=#{address}, phone=#{phone}, + timezone=COALESCE(#{timezone},timezone), open_time=COALESCE(#{openTime},open_time), close_time=COALESCE(#{closeTime},close_time), + same_day_cutoff=COALESCE(#{sameDayCutoff},same_day_cutoff), delivery_radius_mi=COALESCE(#{deliveryRadiusMi},delivery_radius_mi), + daily_capacity=COALESCE(#{dailyCapacity},daily_capacity), lat=COALESCE(#{lat},lat), lng=COALESCE(#{lng},lng) + WHERE id=#{id} + + UPDATE mall_store SET active=#{active} WHERE id=#{id} + diff --git a/backend/src/main/resources/mapper/SubscriptionMapper.xml b/backend/src/main/resources/mapper/SubscriptionMapper.xml new file mode 100644 index 0000000..8fc5c68 --- /dev/null +++ b/backend/src/main/resources/mapper/SubscriptionMapper.xml @@ -0,0 +1,21 @@ + + + + + + + + INSERT INTO mall_subscription (owner, store_id, product_id, size_code, frequency, delivery_zip, address, + receiver_name, receiver_phone, next_delivery_date, status, price) + VALUES (#{owner}, #{storeId}, #{productId}, #{sizeCode}, COALESCE(#{frequency},'WEEKLY'), #{deliveryZip}, #{address}, + #{receiverName}, #{receiverPhone}, #{nextDeliveryDate}, COALESCE(#{status},'ACTIVE'), #{price}) + + UPDATE mall_subscription SET status = #{status} WHERE id = #{id} + UPDATE mall_subscription SET next_delivery_date = #{nextDeliveryDate} WHERE id = #{id} + diff --git a/backend/src/main/resources/mapper/TransferMapper.xml b/backend/src/main/resources/mapper/TransferMapper.xml new file mode 100644 index 0000000..da385b1 --- /dev/null +++ b/backend/src/main/resources/mapper/TransferMapper.xml @@ -0,0 +1,21 @@ + + + + + + + INSERT INTO mall_transfer (from_store_id, to_store_id, product_id, quantity, status, requested_by, reason, ai_recommended) + VALUES (#{fromStoreId}, #{toStoreId}, #{productId}, #{quantity}, COALESCE(#{status},'REQUESTED'), + #{requestedBy}, #{reason}, COALESCE(#{aiRecommended},FALSE)) + + + UPDATE mall_transfer SET status = #{status}, approved_by = #{approvedBy}, updated_at = NOW() WHERE id = #{id} + + diff --git a/backend/src/main/resources/mapper/UserMapper.xml b/backend/src/main/resources/mapper/UserMapper.xml new file mode 100644 index 0000000..a9d98da --- /dev/null +++ b/backend/src/main/resources/mapper/UserMapper.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + INSERT INTO mall_account (username, password_hash, role, display_name, is_active) + VALUES (#{username}, #{passwordHash}, #{role}, #{displayName}, #{active}) + + + + + diff --git a/backend/src/main/resources/mapper/WishlistMapper.xml b/backend/src/main/resources/mapper/WishlistMapper.xml new file mode 100644 index 0000000..1f5e8e1 --- /dev/null +++ b/backend/src/main/resources/mapper/WishlistMapper.xml @@ -0,0 +1,16 @@ + + + + + + INSERT INTO mall_wishlist (owner, product_id) VALUES (#{owner}, #{productId}) + ON CONFLICT (owner, product_id) DO NOTHING + + DELETE FROM mall_wishlist WHERE owner = #{owner} AND product_id = #{productId} + + diff --git a/backend/src/main/resources/mapper/ZoneMapper.xml b/backend/src/main/resources/mapper/ZoneMapper.xml new file mode 100644 index 0000000..7d4c0c6 --- /dev/null +++ b/backend/src/main/resources/mapper/ZoneMapper.xml @@ -0,0 +1,16 @@ + + + + + + + + INSERT INTO mall_zone (store_id, zip, city, state, radius_mi, surge_eligible, active) + VALUES (#{storeId}, #{zip}, #{city}, #{state}, COALESCE(#{radiusMi},15.0), COALESCE(#{surgeEligible},TRUE), COALESCE(#{active},TRUE)) + + DELETE FROM mall_zone WHERE id = #{id} + diff --git a/backend/src/main/resources/static/assets/index-C47e4sku.css b/backend/src/main/resources/static/assets/index-C47e4sku.css new file mode 100644 index 0000000..060f70a --- /dev/null +++ b/backend/src/main/resources/static/assets/index-C47e4sku.css @@ -0,0 +1 @@ +*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.inset-0{top:0;right:0;bottom:0;left:0}.-bottom-24{bottom:-6rem}.-bottom-6{bottom:-1.5rem}.-bottom-\[1px\]{bottom:-1px}.-left-20{left:-5rem}.-right-1{right:-.25rem}.-right-16{right:-4rem}.-top-1{top:-.25rem}.-top-20{top:-5rem}.bottom-0{bottom:0}.left-0{left:0}.left-3{left:.75rem}.right-0{right:0}.right-2{right:.5rem}.right-3{right:.75rem}.right-6{right:1.5rem}.top-0{top:0}.top-2{top:.5rem}.top-20{top:5rem}.top-3{top:.75rem}.z-0{z-index:0}.z-10{z-index:10}.z-30{z-index:30}.z-\[1\]{z-index:1}.z-\[2\]{z-index:2}.col-span-2{grid-column:span 2 / span 2}.col-span-3{grid-column:span 3 / span 3}.col-span-4{grid-column:span 4 / span 4}.m-auto{margin:auto}.mx-auto{margin-left:auto;margin-right:auto}.-mt-0\.5{margin-top:-.125rem}.mb-0\.5{margin-bottom:.125rem}.mb-1{margin-bottom:.25rem}.mb-1\.5{margin-bottom:.375rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-5{margin-bottom:1.25rem}.mb-6{margin-bottom:1.5rem}.mb-7{margin-bottom:1.75rem}.mb-8{margin-bottom:2rem}.ml-1{margin-left:.25rem}.ml-2{margin-left:.5rem}.ml-auto{margin-left:auto}.mt-0\.5{margin-top:.125rem}.mt-1{margin-top:.25rem}.mt-1\.5{margin-top:.375rem}.mt-16{margin-top:4rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-5{margin-top:1.25rem}.mt-6{margin-top:1.5rem}.mt-7{margin-top:1.75rem}.block{display:block}.inline-block{display:inline-block}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.hidden{display:none}.aspect-\[4\/5\]{aspect-ratio:4/5}.aspect-\[5\/4\]{aspect-ratio:5/4}.h-11{height:2.75rem}.h-12{height:3rem}.h-14{height:3.5rem}.h-16{height:4rem}.h-20{height:5rem}.h-44{height:11rem}.h-6{height:1.5rem}.h-9{height:2.25rem}.h-\[18px\]{height:18px}.h-\[2px\]{height:2px}.h-\[72px\]{height:72px}.h-fit{height:-moz-fit-content;height:fit-content}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-32{max-height:8rem}.min-h-\[78vh\]{min-height:78vh}.min-h-screen{min-height:100vh}.w-11{width:2.75rem}.w-12{width:3rem}.w-14{width:3.5rem}.w-16{width:4rem}.w-20{width:5rem}.w-24{width:6rem}.w-28{width:7rem}.w-32{width:8rem}.w-44{width:11rem}.w-48{width:12rem}.w-56{width:14rem}.w-6{width:1.5rem}.w-60{width:15rem}.w-8{width:2rem}.w-9{width:2.25rem}.w-\[360px\]{width:360px}.w-full{width:100%}.min-w-\[18px\]{min-width:18px}.max-w-3xl{max-width:48rem}.max-w-4xl{max-width:56rem}.max-w-5xl{max-width:64rem}.max-w-6xl{max-width:72rem}.max-w-lg{max-width:32rem}.max-w-md{max-width:28rem}.max-w-sm{max-width:24rem}.max-w-xl{max-width:36rem}.flex-1{flex:1 1 0%}.shrink-0{flex-shrink:0}@keyframes heartbeat{0%,to{transform:scale(1)}30%{transform:scale(1.3)}60%{transform:scale(.95)}}.animate-heartbeat{animation:heartbeat .6s ease-in-out}.cursor-not-allowed{cursor:not-allowed}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-end{align-items:flex-end}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-0\.5{gap:.125rem}.gap-1{gap:.25rem}.gap-1\.5{gap:.375rem}.gap-10{gap:2.5rem}.gap-2{gap:.5rem}.gap-2\.5{gap:.625rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-5{gap:1.25rem}.gap-6{gap:1.5rem}.gap-7{gap:1.75rem}.gap-8{gap:2rem}.space-y-1\.5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.375rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.375rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-20>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-5>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.25rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-blush-100\/60>:not([hidden])~:not([hidden]){border-color:#fbe8ef99}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-nowrap{white-space:nowrap}.whitespace-pre-line{white-space:pre-line}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:1rem}.rounded-3xl{border-radius:1.75rem}.rounded-4xl{border-radius:2.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.375rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-r{border-right-width:1px}.border-r-2{border-right-width:2px}.border-t{border-top-width:1px}.border-amber-400\/30{border-color:#fbbf244d}.border-amber-500\/30{border-color:#f59e0b4d}.border-bloom{--tw-border-opacity: 1;border-color:rgb(208 90 130 / var(--tw-border-opacity, 1))}.border-blush-100{--tw-border-opacity: 1;border-color:rgb(251 232 239 / var(--tw-border-opacity, 1))}.border-blush-100\/60{border-color:#fbe8ef99}.border-blush-100\/70{border-color:#fbe8efb3}.border-blush-400{--tw-border-opacity: 1;border-color:rgb(224 122 156 / var(--tw-border-opacity, 1))}.border-blush-50{--tw-border-opacity: 1;border-color:rgb(253 244 247 / var(--tw-border-opacity, 1))}.border-blush-500{--tw-border-opacity: 1;border-color:rgb(208 90 130 / var(--tw-border-opacity, 1))}.border-brand{--tw-border-opacity: 1;border-color:rgb(0 160 200 / var(--tw-border-opacity, 1))}.border-cream\/10{border-color:#fdfaf51a}.border-edge{--tw-border-opacity: 1;border-color:rgb(38 48 74 / var(--tw-border-opacity, 1))}.border-edge\/50{border-color:#26304a80}.border-emerald-500\/30{border-color:#10b9814d}.border-rose-500\/30{border-color:#f43f5e4d}.border-sky-500\/30{border-color:#0ea5e94d}.border-slate-300\/30{border-color:#cbd5e14d}.border-slate-500\/30{border-color:#64748b4d}.border-slate-600\/30{border-color:#4755694d}.border-violet-500\/30{border-color:#8b5cf64d}.border-white\/70{border-color:#ffffffb3}.bg-amber-400\/15{background-color:#fbbf2426}.bg-amber-500\/15{background-color:#f59e0b26}.bg-bloom{--tw-bg-opacity: 1;background-color:rgb(208 90 130 / var(--tw-bg-opacity, 1))}.bg-blush-50{--tw-bg-opacity: 1;background-color:rgb(253 244 247 / var(--tw-bg-opacity, 1))}.bg-blush-500{--tw-bg-opacity: 1;background-color:rgb(208 90 130 / var(--tw-bg-opacity, 1))}.bg-brand{--tw-bg-opacity: 1;background-color:rgb(0 160 200 / var(--tw-bg-opacity, 1))}.bg-brand2{--tw-bg-opacity: 1;background-color:rgb(0 90 140 / var(--tw-bg-opacity, 1))}.bg-card{--tw-bg-opacity: 1;background-color:rgb(26 34 52 / var(--tw-bg-opacity, 1))}.bg-cream{--tw-bg-opacity: 1;background-color:rgb(253 250 245 / var(--tw-bg-opacity, 1))}.bg-cream\/10{background-color:#fdfaf51a}.bg-cream\/90{background-color:#fdfaf5e6}.bg-emerald-500\/15{background-color:#10b98126}.bg-gold{--tw-bg-opacity: 1;background-color:rgb(196 163 90 / var(--tw-bg-opacity, 1))}.bg-ink{--tw-bg-opacity: 1;background-color:rgb(11 15 23 / var(--tw-bg-opacity, 1))}.bg-ivory{--tw-bg-opacity: 1;background-color:rgb(251 246 238 / var(--tw-bg-opacity, 1))}.bg-leaf\/10{background-color:#4e6e431a}.bg-panel{--tw-bg-opacity: 1;background-color:rgb(19 25 39 / var(--tw-bg-opacity, 1))}.bg-petal{--tw-bg-opacity: 1;background-color:rgb(251 232 239 / var(--tw-bg-opacity, 1))}.bg-rose-500\/15{background-color:#f43f5e26}.bg-sage-700{--tw-bg-opacity: 1;background-color:rgb(64 88 55 / var(--tw-bg-opacity, 1))}.bg-sage-800{--tw-bg-opacity: 1;background-color:rgb(54 72 47 / var(--tw-bg-opacity, 1))}.bg-sky-500\/15{background-color:#0ea5e926}.bg-slate-300\/20{background-color:#cbd5e133}.bg-slate-500\/15{background-color:#64748b26}.bg-slate-600\/20{background-color:#47556933}.bg-transparent{background-color:transparent}.bg-violet-500\/15{background-color:#8b5cf626}.bg-white{--tw-bg-opacity: 1;background-color:rgb(255 255 255 / var(--tw-bg-opacity, 1))}.bg-white\/15{background-color:#ffffff26}.bg-white\/60{background-color:#fff9}.bg-white\/85{background-color:#ffffffd9}.bg-white\/90{background-color:#ffffffe6}.bg-gradient-to-br{background-image:linear-gradient(to bottom right,var(--tw-gradient-stops))}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.bg-gradient-to-t{background-image:linear-gradient(to top,var(--tw-gradient-stops))}.from-amber-400{--tw-gradient-from: #fbbf24 var(--tw-gradient-from-position);--tw-gradient-to: rgb(251 191 36 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-bloom2{--tw-gradient-from: #993456 var(--tw-gradient-from-position);--tw-gradient-to: rgb(153 52 86 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-blush-100{--tw-gradient-from: #fbe8ef var(--tw-gradient-from-position);--tw-gradient-to: rgb(251 232 239 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-blush-200{--tw-gradient-from: #f6cdda var(--tw-gradient-from-position);--tw-gradient-to: rgb(246 205 218 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-blush-50{--tw-gradient-from: #fdf4f7 var(--tw-gradient-from-position);--tw-gradient-to: rgb(253 244 247 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-blush-900\/10{--tw-gradient-from: rgb(107 42 65 / .1) var(--tw-gradient-from-position);--tw-gradient-to: rgb(107 42 65 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-blush-900\/70{--tw-gradient-from: rgb(107 42 65 / .7) var(--tw-gradient-from-position);--tw-gradient-to: rgb(107 42 65 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-blush-900\/75{--tw-gradient-from: rgb(107 42 65 / .75) var(--tw-gradient-from-position);--tw-gradient-to: rgb(107 42 65 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-sage-700{--tw-gradient-from: #405837 var(--tw-gradient-from-position);--tw-gradient-to: rgb(64 88 55 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-sage-900\/40{--tw-gradient-from: rgb(46 61 41 / .4) var(--tw-gradient-from-position);--tw-gradient-to: rgb(46 61 41 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-slate-300{--tw-gradient-from: #cbd5e1 var(--tw-gradient-from-position);--tw-gradient-to: rgb(203 213 225 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-slate-400{--tw-gradient-from: #94a3b8 var(--tw-gradient-from-position);--tw-gradient-to: rgb(148 163 184 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.from-violet-500{--tw-gradient-from: #8b5cf6 var(--tw-gradient-from-position);--tw-gradient-to: rgb(139 92 246 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.via-blush-100{--tw-gradient-to: rgb(251 232 239 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), #fbe8ef var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-blush-900\/20{--tw-gradient-to: rgb(107 42 65 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(107 42 65 / .2) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-blush-900\/40{--tw-gradient-to: rgb(107 42 65 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), rgb(107 42 65 / .4) var(--tw-gradient-via-position), var(--tw-gradient-to)}.via-cream{--tw-gradient-to: rgb(253 250 245 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), #fdfaf5 var(--tw-gradient-via-position), var(--tw-gradient-to)}.to-amber-500{--tw-gradient-to: #f59e0b var(--tw-gradient-to-position)}.to-bloom{--tw-gradient-to: #d05a82 var(--tw-gradient-to-position)}.to-fuchsia-500{--tw-gradient-to: #d946ef var(--tw-gradient-to-position)}.to-ivory{--tw-gradient-to: #fbf6ee var(--tw-gradient-to-position)}.to-sage-100{--tw-gradient-to: #e6ede2 var(--tw-gradient-to-position)}.to-sage-50{--tw-gradient-to: #f4f7f3 var(--tw-gradient-to-position)}.to-sage-800{--tw-gradient-to: #36482f var(--tw-gradient-to-position)}.to-slate-400{--tw-gradient-to: #94a3b8 var(--tw-gradient-to-position)}.to-slate-500{--tw-gradient-to: #64748b var(--tw-gradient-to-position)}.to-transparent{--tw-gradient-to: transparent var(--tw-gradient-to-position)}.object-cover{-o-object-fit:cover;object-fit:cover}.p-1{padding:.25rem}.p-1\.5{padding:.375rem}.p-10{padding:2.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-5{padding:1.25rem}.p-6{padding:1.5rem}.p-7{padding:1.75rem}.p-8{padding:2rem}.px-1{padding-left:.25rem;padding-right:.25rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-2\.5{padding-left:.625rem;padding-right:.625rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-3\.5{padding-left:.875rem;padding-right:.875rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.px-7{padding-left:1.75rem;padding-right:1.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-10{padding-top:2.5rem;padding-bottom:2.5rem}.py-12{padding-top:3rem;padding-bottom:3rem}.py-14{padding-top:3.5rem;padding-bottom:3.5rem}.py-16{padding-top:4rem;padding-bottom:4rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-2\.5{padding-top:.625rem;padding-bottom:.625rem}.py-20{padding-top:5rem;padding-bottom:5rem}.py-24{padding-top:6rem;padding-bottom:6rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-3\.5{padding-top:.875rem;padding-bottom:.875rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.py-8{padding-top:2rem;padding-bottom:2rem}.pb-1\.5{padding-bottom:.375rem}.pb-10{padding-bottom:2.5rem}.pb-2{padding-bottom:.5rem}.pb-3{padding-bottom:.75rem}.pt-3{padding-top:.75rem}.pt-4{padding-top:1rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-5xl{font-size:3rem;line-height:1}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.text-\[12px\]{font-size:12px}.text-\[13px\]{font-size:13px}.text-\[15px\]{font-size:15px}.text-\[20px\]{font-size:20px}.text-\[9px\]{font-size:9px}.text-base{font-size:1rem;line-height:1.5rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-light{font-weight:300}.font-medium{font-weight:500}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.leading-\[1\.05\]{line-height:1.05}.leading-none{line-height:1}.leading-relaxed{line-height:1.625}.leading-snug{line-height:1.375}.leading-tight{line-height:1.25}.tracking-\[0\.12em\]{letter-spacing:.12em}.tracking-\[0\.25em\]{letter-spacing:.25em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-\[0\.35em\]{letter-spacing:.35em}.tracking-\[0\.3em\]{letter-spacing:.3em}.tracking-tight{letter-spacing:-.025em}.tracking-wide{letter-spacing:.025em}.tracking-wider{letter-spacing:.05em}.tracking-widest{letter-spacing:.1em}.text-\[\#43343a\]{--tw-text-opacity: 1;color:rgb(67 52 58 / var(--tw-text-opacity, 1))}.text-\[\#5a474d\]{--tw-text-opacity: 1;color:rgb(90 71 77 / var(--tw-text-opacity, 1))}.text-\[\#6b5258\]{--tw-text-opacity: 1;color:rgb(107 82 88 / var(--tw-text-opacity, 1))}.text-\[\#8a7077\]{--tw-text-opacity: 1;color:rgb(138 112 119 / var(--tw-text-opacity, 1))}.text-\[\#a08a90\]{--tw-text-opacity: 1;color:rgb(160 138 144 / var(--tw-text-opacity, 1))}.text-\[\#e6edf6\]{--tw-text-opacity: 1;color:rgb(230 237 246 / var(--tw-text-opacity, 1))}.text-accent{--tw-text-opacity: 1;color:rgb(61 220 151 / var(--tw-text-opacity, 1))}.text-amber-300{--tw-text-opacity: 1;color:rgb(252 211 77 / var(--tw-text-opacity, 1))}.text-amber-400{--tw-text-opacity: 1;color:rgb(251 191 36 / var(--tw-text-opacity, 1))}.text-amber-600{--tw-text-opacity: 1;color:rgb(217 119 6 / var(--tw-text-opacity, 1))}.text-bloom{--tw-text-opacity: 1;color:rgb(208 90 130 / var(--tw-text-opacity, 1))}.text-bloom\/30{color:#d05a824d}.text-bloom\/40{color:#d05a8266}.text-bloom2{--tw-text-opacity: 1;color:rgb(153 52 86 / var(--tw-text-opacity, 1))}.text-blush-200{--tw-text-opacity: 1;color:rgb(246 205 218 / var(--tw-text-opacity, 1))}.text-blush-200\/40{color:#f6cdda66}.text-blush-300{--tw-text-opacity: 1;color:rgb(238 167 190 / var(--tw-text-opacity, 1))}.text-blush-400{--tw-text-opacity: 1;color:rgb(224 122 156 / var(--tw-text-opacity, 1))}.text-blush-500{--tw-text-opacity: 1;color:rgb(208 90 130 / var(--tw-text-opacity, 1))}.text-blush-600{--tw-text-opacity: 1;color:rgb(184 67 107 / var(--tw-text-opacity, 1))}.text-blush-700{--tw-text-opacity: 1;color:rgb(153 52 86 / var(--tw-text-opacity, 1))}.text-blush-900{--tw-text-opacity: 1;color:rgb(107 42 65 / var(--tw-text-opacity, 1))}.text-brand{--tw-text-opacity: 1;color:rgb(0 160 200 / var(--tw-text-opacity, 1))}.text-cream\/50{color:#fdfaf580}.text-cream\/60{color:#fdfaf599}.text-cream\/70{color:#fdfaf5b3}.text-cream\/75{color:#fdfaf5bf}.text-cream\/85{color:#fdfaf5d9}.text-cream\/90{color:#fdfaf5e6}.text-cream\/95{color:#fdfaf5f2}.text-emerald-400{--tw-text-opacity: 1;color:rgb(52 211 153 / var(--tw-text-opacity, 1))}.text-gold{--tw-text-opacity: 1;color:rgb(196 163 90 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-gray-700{--tw-text-opacity: 1;color:rgb(55 65 81 / var(--tw-text-opacity, 1))}.text-ink{--tw-text-opacity: 1;color:rgb(11 15 23 / var(--tw-text-opacity, 1))}.text-leaf{--tw-text-opacity: 1;color:rgb(78 110 67 / var(--tw-text-opacity, 1))}.text-rose-400{--tw-text-opacity: 1;color:rgb(251 113 133 / var(--tw-text-opacity, 1))}.text-sage-200{--tw-text-opacity: 1;color:rgb(205 221 198 / var(--tw-text-opacity, 1))}.text-sage-300{--tw-text-opacity: 1;color:rgb(168 195 158 / var(--tw-text-opacity, 1))}.text-sage-300\/40{color:#a8c39e66}.text-sage-600{--tw-text-opacity: 1;color:rgb(78 110 67 / var(--tw-text-opacity, 1))}.text-sage-700{--tw-text-opacity: 1;color:rgb(64 88 55 / var(--tw-text-opacity, 1))}.text-sage-800{--tw-text-opacity: 1;color:rgb(54 72 47 / var(--tw-text-opacity, 1))}.text-sky-400{--tw-text-opacity: 1;color:rgb(56 189 248 / var(--tw-text-opacity, 1))}.text-slate-200{--tw-text-opacity: 1;color:rgb(226 232 240 / var(--tw-text-opacity, 1))}.text-slate-300{--tw-text-opacity: 1;color:rgb(203 213 225 / var(--tw-text-opacity, 1))}.text-slate-400{--tw-text-opacity: 1;color:rgb(148 163 184 / var(--tw-text-opacity, 1))}.text-slate-500{--tw-text-opacity: 1;color:rgb(100 116 139 / var(--tw-text-opacity, 1))}.text-slate-600{--tw-text-opacity: 1;color:rgb(71 85 105 / var(--tw-text-opacity, 1))}.text-violet-300{--tw-text-opacity: 1;color:rgb(196 181 253 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-white\/20{color:#fff3}.text-white\/70{color:#ffffffb3}.text-white\/85{color:#ffffffd9}.line-through{text-decoration-line:line-through}.underline-offset-2{text-underline-offset:2px}.accent-bloom{accent-color:#d05a82}.opacity-0{opacity:0}.opacity-30{opacity:.3}.opacity-50{opacity:.5}.shadow{--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / .1), 0 1px 2px -1px rgb(0 0 0 / .1);--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-bloom{--tw-shadow: 0 20px 60px -18px rgba(153,52,86,.3);--tw-shadow-colored: 0 20px 60px -18px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-petal{--tw-shadow: 0 10px 40px -12px rgba(208,90,130,.25);--tw-shadow-colored: 0 10px 40px -12px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 2px 0 rgb(0 0 0 / .05);--tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-soft{--tw-shadow: 0 8px 30px -10px rgba(110,80,90,.18);--tw-shadow-colored: 0 8px 30px -10px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.shadow-bloom{--tw-shadow-color: #d05a82;--tw-shadow: var(--tw-shadow-colored)}.shadow-petal{--tw-shadow-color: #fbe8ef;--tw-shadow: var(--tw-shadow-colored)}.outline-none{outline:2px solid transparent;outline-offset:2px}.drop-shadow-sm{--tw-drop-shadow: drop-shadow(0 1px 1px rgb(0 0 0 / .05));filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.backdrop-blur{--tw-backdrop-blur: blur(8px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.backdrop-blur-md{--tw-backdrop-blur: blur(12px);-webkit-backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia);backdrop-filter:var(--tw-backdrop-blur) var(--tw-backdrop-brightness) var(--tw-backdrop-contrast) var(--tw-backdrop-grayscale) var(--tw-backdrop-hue-rotate) var(--tw-backdrop-invert) var(--tw-backdrop-opacity) var(--tw-backdrop-saturate) var(--tw-backdrop-sepia)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-shadow{transition-property:box-shadow;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-500{transition-duration:.5s}:root{--blush: #d05a82;--blush-deep: #993456;--sage: #4e6e43;--cream: #fdfaf5;--gold: #c4a35a}body{margin:0;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;color:#43343a;background:var(--cream);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}*{box-sizing:border-box}.admin-shell{color-scheme:dark;background:#0b0f17;color:#e6edf6;font-family:Inter,-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.font-display{font-family:Cormorant Garamond,Georgia,serif}.font-serif{font-family:Playfair Display,Georgia,serif}html{scroll-behavior:smooth}::-moz-selection{background:#d05a822e}::selection{background:#d05a822e}.botanical-divider{display:flex;align-items:center;justify-content:center;gap:.75rem;color:#a8c39e}.botanical-divider:before,.botanical-divider:after{content:"";height:1px;flex:1;max-width:7rem;background:linear-gradient(to var(--dir, right),transparent,rgba(168,195,158,.7))}.botanical-divider:before{--dir: left}.petal-layer{position:absolute;top:0;right:0;bottom:0;left:0;overflow:hidden;pointer-events:none;z-index:0}.petal{position:absolute;top:-8vh;border-radius:50% 0 50% 50%;background:radial-gradient(circle at 30% 30%,#eea7bef2,#d05a828c);opacity:0;will-change:transform,opacity;animation:petalfall linear infinite}.petal.sage{background:radial-gradient(circle at 30% 30%,#a8c39ee6,#648a5680)}.petal.cream{background:radial-gradient(circle at 30% 30%,#fdf6eef2,#c4a35a66)}.zoom-frame{overflow:hidden}.zoom-frame img{transition:transform .9s cubic-bezier(.22,1,.36,1)}@media (prefers-reduced-motion: reduce){*,*:before,*:after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important;scroll-behavior:auto!important}.petal-layer{display:none}}.thin-scroll::-webkit-scrollbar{height:6px}.thin-scroll::-webkit-scrollbar-thumb{background:#d05a8240;border-radius:999px}.placeholder\:text-blush-300::-moz-placeholder{--tw-text-opacity: 1;color:rgb(238 167 190 / var(--tw-text-opacity, 1))}.placeholder\:text-blush-300::placeholder{--tw-text-opacity: 1;color:rgb(238 167 190 / var(--tw-text-opacity, 1))}.hover\:border-bloom:hover{--tw-border-opacity: 1;border-color:rgb(208 90 130 / var(--tw-border-opacity, 1))}.hover\:border-blush-300:hover{--tw-border-opacity: 1;border-color:rgb(238 167 190 / var(--tw-border-opacity, 1))}.hover\:bg-bloom2:hover{--tw-bg-opacity: 1;background-color:rgb(153 52 86 / var(--tw-bg-opacity, 1))}.hover\:bg-blush-100:hover{--tw-bg-opacity: 1;background-color:rgb(251 232 239 / var(--tw-bg-opacity, 1))}.hover\:bg-blush-50:hover{--tw-bg-opacity: 1;background-color:rgb(253 244 247 / var(--tw-bg-opacity, 1))}.hover\:bg-blush-600:hover{--tw-bg-opacity: 1;background-color:rgb(184 67 107 / var(--tw-bg-opacity, 1))}.hover\:bg-brand\/90:hover{background-color:#00a0c8e6}.hover\:bg-card\/60:hover{background-color:#1a223499}.hover\:bg-cream:hover{--tw-bg-opacity: 1;background-color:rgb(253 250 245 / var(--tw-bg-opacity, 1))}.hover\:bg-panel\/50:hover{background-color:#13192780}.hover\:bg-petal:hover{--tw-bg-opacity: 1;background-color:rgb(251 232 239 / var(--tw-bg-opacity, 1))}.hover\:bg-white\/10:hover{background-color:#ffffff1a}.hover\:text-bloom:hover,.hover\:text-blush-500:hover{--tw-text-opacity: 1;color:rgb(208 90 130 / var(--tw-text-opacity, 1))}.hover\:text-blush-600:hover{--tw-text-opacity: 1;color:rgb(184 67 107 / var(--tw-text-opacity, 1))}.hover\:text-blush-700:hover{--tw-text-opacity: 1;color:rgb(153 52 86 / var(--tw-text-opacity, 1))}.hover\:text-brand:hover{--tw-text-opacity: 1;color:rgb(0 160 200 / var(--tw-text-opacity, 1))}.hover\:text-cream\/80:hover{color:#fdfaf5cc}.hover\:text-white:hover{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.hover\:underline:hover{text-decoration-line:underline}.hover\:shadow-bloom:hover{--tw-shadow: 0 20px 60px -18px rgba(153,52,86,.3);--tw-shadow-colored: 0 20px 60px -18px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow);--tw-shadow-color: #d05a82;--tw-shadow: var(--tw-shadow-colored)}.focus\:border-bloom:focus{--tw-border-opacity: 1;border-color:rgb(208 90 130 / var(--tw-border-opacity, 1))}.focus\:border-blush-300:focus{--tw-border-opacity: 1;border-color:rgb(238 167 190 / var(--tw-border-opacity, 1))}.focus\:border-blush-400:focus{--tw-border-opacity: 1;border-color:rgb(224 122 156 / var(--tw-border-opacity, 1))}.focus\:border-brand:focus{--tw-border-opacity: 1;border-color:rgb(0 160 200 / var(--tw-border-opacity, 1))}.disabled\:opacity-60:disabled{opacity:.6}.group:hover .group-hover\:scale-110{--tw-scale-x: 1.1;--tw-scale-y: 1.1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.group:hover .group-hover\:gap-2{gap:.5rem}.group:hover .group-hover\:opacity-100{opacity:1}@media (min-width: 640px){.sm\:flex{display:flex}.sm\:flex-row{flex-direction:row}.sm\:justify-between{justify-content:space-between}.sm\:gap-3{gap:.75rem}}@media (min-width: 768px){.md\:col-span-1{grid-column:span 1 / span 1}.md\:col-span-2{grid-column:span 2 / span 2}.md\:block{display:block}.md\:flex{display:flex}.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.md\:grid-cols-\[200px_1fr\]{grid-template-columns:200px 1fr}.md\:flex-row{flex-direction:row}.md\:p-10{padding:2.5rem}.md\:p-12{padding:3rem}.md\:text-4xl{font-size:2.25rem;line-height:2.5rem}.md\:text-6xl{font-size:3.75rem;line-height:1}}@media (min-width: 1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}} diff --git a/backend/src/main/resources/static/assets/index-DGrOz8iv.js b/backend/src/main/resources/static/assets/index-DGrOz8iv.js new file mode 100644 index 0000000..b5c61a8 --- /dev/null +++ b/backend/src/main/resources/static/assets/index-DGrOz8iv.js @@ -0,0 +1,459 @@ +var qj=e=>{throw TypeError(e)};var Tv=(e,t,n)=>t.has(e)||qj("Cannot "+n);var M=(e,t,n)=>(Tv(e,t,"read from private field"),n?n.call(e):t.get(e)),ce=(e,t,n)=>t.has(e)?qj("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(e):t.set(e,n),Z=(e,t,n,r)=>(Tv(e,t,"write to private field"),r?r.call(e,n):t.set(e,n),n),Ae=(e,t,n)=>(Tv(e,t,"access private method"),n);var zd=(e,t,n,r)=>({set _(a){Z(e,t,a,n)},get _(){return M(e,t,r)}});(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))r(a);new MutationObserver(a=>{for(const i of a)if(i.type==="childList")for(const s of i.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&r(s)}).observe(document,{childList:!0,subtree:!0});function n(a){const i={};return a.integrity&&(i.integrity=a.integrity),a.referrerPolicy&&(i.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?i.credentials="include":a.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(a){if(a.ep)return;a.ep=!0;const i=n(a);fetch(a.href,i)}})();var Bd=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function Be(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var $C={exports:{}},Zm={};/** + * @license React + * react-jsx-runtime.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var LL=Symbol.for("react.transitional.element"),zL=Symbol.for("react.fragment");function kC(e,t,n){var r=null;if(n!==void 0&&(r=""+n),t.key!==void 0&&(r=""+t.key),"key"in t){n={};for(var a in t)a!=="key"&&(n[a]=t[a])}else n=t;return t=n.ref,{$$typeof:LL,type:e,key:r,ref:t!==void 0?t:null,props:n}}Zm.Fragment=zL;Zm.jsx=kC;Zm.jsxs=kC;$C.exports=Zm;var c=$C.exports,LC={exports:{}},ge={};/** + * @license React + * react.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var j1=Symbol.for("react.transitional.element"),BL=Symbol.for("react.portal"),IL=Symbol.for("react.fragment"),UL=Symbol.for("react.strict_mode"),qL=Symbol.for("react.profiler"),VL=Symbol.for("react.consumer"),HL=Symbol.for("react.context"),FL=Symbol.for("react.forward_ref"),KL=Symbol.for("react.suspense"),GL=Symbol.for("react.memo"),zC=Symbol.for("react.lazy"),YL=Symbol.for("react.activity"),Vj=Symbol.iterator;function XL(e){return e===null||typeof e!="object"?null:(e=Vj&&e[Vj]||e["@@iterator"],typeof e=="function"?e:null)}var BC={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},IC=Object.assign,UC={};function du(e,t,n){this.props=e,this.context=t,this.refs=UC,this.updater=n||BC}du.prototype.isReactComponent={};du.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};du.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function qC(){}qC.prototype=du.prototype;function A1(e,t,n){this.props=e,this.context=t,this.refs=UC,this.updater=n||BC}var O1=A1.prototype=new qC;O1.constructor=A1;IC(O1,du.prototype);O1.isPureReactComponent=!0;var Hj=Array.isArray;function fb(){}var nt={H:null,A:null,T:null,S:null},VC=Object.prototype.hasOwnProperty;function E1(e,t,n){var r=n.ref;return{$$typeof:j1,type:e,key:t,ref:r!==void 0?r:null,props:n}}function WL(e,t){return E1(e.type,t,e.props)}function T1(e){return typeof e=="object"&&e!==null&&e.$$typeof===j1}function QL(e){var t={"=":"=0",":":"=2"};return"$"+e.replace(/[=:]/g,function(n){return t[n]})}var Fj=/\/+/g;function Nv(e,t){return typeof e=="object"&&e!==null&&e.key!=null?QL(""+e.key):t.toString(36)}function ZL(e){switch(e.status){case"fulfilled":return e.value;case"rejected":throw e.reason;default:switch(typeof e.status=="string"?e.then(fb,fb):(e.status="pending",e.then(function(t){e.status==="pending"&&(e.status="fulfilled",e.value=t)},function(t){e.status==="pending"&&(e.status="rejected",e.reason=t)})),e.status){case"fulfilled":return e.value;case"rejected":throw e.reason}}throw e}function _o(e,t,n,r,a){var i=typeof e;(i==="undefined"||i==="boolean")&&(e=null);var s=!1;if(e===null)s=!0;else switch(i){case"bigint":case"string":case"number":s=!0;break;case"object":switch(e.$$typeof){case j1:case BL:s=!0;break;case zC:return s=e._init,_o(s(e._payload),t,n,r,a)}}if(s)return a=a(e),s=r===""?"."+Nv(e,0):r,Hj(a)?(n="",s!=null&&(n=s.replace(Fj,"$&/")+"/"),_o(a,t,n,"",function(u){return u})):a!=null&&(T1(a)&&(a=WL(a,n+(a.key==null||e&&e.key===a.key?"":(""+a.key).replace(Fj,"$&/")+"/")+s)),t.push(a)),1;s=0;var o=r===""?".":r+":";if(Hj(e))for(var l=0;l>>1,H=R[V];if(0>>1;Va(J,B))tea(ue,J)?(R[V]=ue,R[te]=B,V=te):(R[V]=J,R[F]=B,V=F);else if(tea(ue,B))R[V]=ue,R[te]=B,V=te;else break e}}return $}function a(R,$){var B=R.sortIndex-$.sortIndex;return B!==0?B:R.id-$.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var s=Date,o=s.now();e.unstable_now=function(){return s.now()-o}}var l=[],u=[],f=1,d=null,h=3,p=!1,v=!1,y=!1,g=!1,m=typeof setTimeout=="function"?setTimeout:null,b=typeof clearTimeout=="function"?clearTimeout:null,x=typeof setImmediate<"u"?setImmediate:null;function w(R){for(var $=n(u);$!==null;){if($.callback===null)r(u);else if($.startTime<=R)r(u),$.sortIndex=$.expirationTime,t(l,$);else break;$=n(u)}}function S(R){if(y=!1,w(R),!v)if(n(l)!==null)v=!0,j||(j=!0,C());else{var $=n(u);$!==null&&k(S,$.startTime-R)}}var j=!1,O=-1,E=5,T=-1;function _(){return g?!0:!(e.unstable_now()-TR&&_());){var V=d.callback;if(typeof V=="function"){d.callback=null,h=d.priorityLevel;var H=V(d.expirationTime<=R);if(R=e.unstable_now(),typeof H=="function"){d.callback=H,w(R),$=!0;break t}d===n(l)&&r(l),w(R)}else r(l);d=n(l)}if(d!==null)$=!0;else{var Q=n(u);Q!==null&&k(S,Q.startTime-R),$=!1}}break e}finally{d=null,h=B,p=!1}$=void 0}}finally{$?C():j=!1}}}var C;if(typeof x=="function")C=function(){x(P)};else if(typeof MessageChannel<"u"){var z=new MessageChannel,L=z.port2;z.port1.onmessage=P,C=function(){L.postMessage(null)}}else C=function(){m(P,0)};function k(R,$){O=m(function(){R(e.unstable_now())},$)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(R){R.callback=null},e.unstable_forceFrameRate=function(R){0>R||125V?(R.sortIndex=B,t(u,R),n(l)===null&&R===n(u)&&(y?(b(O),O=-1):y=!0,k(S,B-V))):(R.sortIndex=H,t(l,R),v||p||(v=!0,j||(j=!0,C()))),R},e.unstable_shouldYield=_,e.unstable_wrapCallback=function(R){var $=h;return function(){var B=h;h=$;try{return R.apply(this,arguments)}finally{h=B}}}})(KC);FC.exports=KC;var t6=FC.exports,GC={exports:{}},vn={};/** + * @license React + * react-dom.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var n6=A;function YC(e){var t="https://react.dev/errors/"+e;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(XC)}catch(e){console.error(e)}}XC(),GC.exports=vn;var i6=GC.exports;/** + * @license React + * react-dom-client.production.js + * + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Lt=t6,WC=A,s6=i6;function U(e){var t="https://react.dev/errors/"+e;if(1Ro||(e.current=vb[Ro],vb[Ro]=null,Ro--)}function We(e,t){Ro++,vb[Ro]=e.current,e.current=t}var Jr=oa(null),Fc=oa(null),Ci=oa(null),rp=oa(null);function ap(e,t){switch(We(Ci,t),We(Fc,e),We(Jr,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?e2(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=e2(t),e=wR(t,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}Ft(Jr),We(Jr,e)}function Al(){Ft(Jr),Ft(Fc),Ft(Ci)}function gb(e){e.memoizedState!==null&&We(rp,e);var t=Jr.current,n=wR(t,e.type);t!==n&&(We(Fc,e),We(Jr,n))}function ip(e){Fc.current===e&&(Ft(Jr),Ft(Fc)),rp.current===e&&(Ft(rp),nf._currentValue=ks)}var _v,Xj;function ls(e){if(_v===void 0)try{throw Error()}catch(n){var t=n.stack.trim().match(/\n( *(at )?)/);_v=t&&t[1]||"",Xj=-1)":-1a||l[r]!==u[a]){var f=` +`+l[r].replace(" at new "," at ");return e.displayName&&f.includes("")&&(f=f.replace("",e.displayName)),f}while(1<=r&&0<=a);break}}}finally{Cv=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:"")?ls(n):""}function f6(e,t){switch(e.tag){case 26:case 27:case 5:return ls(e.type);case 16:return ls("Lazy");case 13:return e.child!==t&&t!==null?ls("Suspense Fallback"):ls("Suspense");case 19:return ls("SuspenseList");case 0:case 15:return Pv(e.type,!1);case 11:return Pv(e.type.render,!1);case 1:return Pv(e.type,!0);case 31:return ls("Activity");default:return""}}function Wj(e){try{var t="",n=null;do t+=f6(e,n),n=e,e=e.return;while(e);return t}catch(r){return` +Error generating stack: `+r.message+` +`+r.stack}}var bb=Object.prototype.hasOwnProperty,C1=Lt.unstable_scheduleCallback,Mv=Lt.unstable_cancelCallback,d6=Lt.unstable_shouldYield,h6=Lt.unstable_requestPaint,Vn=Lt.unstable_now,p6=Lt.unstable_getCurrentPriorityLevel,rP=Lt.unstable_ImmediatePriority,aP=Lt.unstable_UserBlockingPriority,sp=Lt.unstable_NormalPriority,m6=Lt.unstable_LowPriority,iP=Lt.unstable_IdlePriority,y6=Lt.log,v6=Lt.unstable_setDisableYieldValue,id=null,Hn=null;function ji(e){if(typeof y6=="function"&&v6(e),Hn&&typeof Hn.setStrictMode=="function")try{Hn.setStrictMode(id,e)}catch{}}var Fn=Math.clz32?Math.clz32:x6,g6=Math.log,b6=Math.LN2;function x6(e){return e>>>=0,e===0?32:31-(g6(e)/b6|0)|0}var qd=256,Vd=262144,Hd=4194304;function us(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function ty(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var a=0,i=e.suspendedLanes,s=e.pingedLanes;e=e.warmLanes;var o=r&134217727;return o!==0?(r=o&~i,r!==0?a=us(r):(s&=o,s!==0?a=us(s):n||(n=o&~e,n!==0&&(a=us(n))))):(o=r&~i,o!==0?a=us(o):s!==0?a=us(s):n||(n=r&~e,n!==0&&(a=us(n)))),a===0?0:t!==0&&t!==a&&!(t&i)&&(i=a&-a,n=t&-t,i>=n||i===32&&(n&4194048)!==0)?t:a}function sd(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function S6(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function sP(){var e=Hd;return Hd<<=1,!(Hd&62914560)&&(Hd=4194304),e}function Rv(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function od(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function w6(e,t,n,r,a,i){var s=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var o=e.entanglements,l=e.expirationTimes,u=e.hiddenUpdates;for(n=s&~n;0"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var N6=/[\n"\\]/g;function ur(e){return e.replace(N6,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function wb(e,t,n,r,a,i,s,o){e.name="",s!=null&&typeof s!="function"&&typeof s!="symbol"&&typeof s!="boolean"?e.type=s:e.removeAttribute("type"),t!=null?s==="number"?(t===0&&e.value===""||e.value!=t)&&(e.value=""+sr(t)):e.value!==""+sr(t)&&(e.value=""+sr(t)):s!=="submit"&&s!=="reset"||e.removeAttribute("value"),t!=null?jb(e,s,sr(t)):n!=null?jb(e,s,sr(n)):r!=null&&e.removeAttribute("value"),a==null&&i!=null&&(e.defaultChecked=!!i),a!=null&&(e.checked=a&&typeof a!="function"&&typeof a!="symbol"),o!=null&&typeof o!="function"&&typeof o!="symbol"&&typeof o!="boolean"?e.name=""+sr(o):e.removeAttribute("name")}function mP(e,t,n,r,a,i,s,o){if(i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"&&(e.type=i),t!=null||n!=null){if(!(i!=="submit"&&i!=="reset"||t!=null)){Sb(e);return}n=n!=null?""+sr(n):"",t=t!=null?""+sr(t):n,o||t===e.value||(e.value=t),e.defaultValue=t}r=r??a,r=typeof r!="function"&&typeof r!="symbol"&&!!r,e.checked=o?e.checked:!!r,e.defaultChecked=!!r,s!=null&&typeof s!="function"&&typeof s!="symbol"&&typeof s!="boolean"&&(e.name=s),Sb(e)}function jb(e,t,n){t==="number"&&op(e.ownerDocument)===e||e.defaultValue===""+n||(e.defaultValue=""+n)}function tl(e,t,n,r){if(e=e.options,t){t={};for(var a=0;a"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Ob=!1;if(Ba)try{var Uu={};Object.defineProperty(Uu,"passive",{get:function(){Ob=!0}}),window.addEventListener("test",Uu,Uu),window.removeEventListener("test",Uu,Uu)}catch{Ob=!1}var Ai=null,k1=null,Mh=null;function xP(){if(Mh)return Mh;var e,t=k1,n=t.length,r,a="value"in Ai?Ai.value:Ai.textContent,i=a.length;for(e=0;e=bc),oA=" ",lA=!1;function wP(e,t){switch(e){case"keyup":return nz.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function jP(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var ko=!1;function az(e,t){switch(e){case"compositionend":return jP(t);case"keypress":return t.which!==32?null:(lA=!0,oA);case"textInput":return e=t.data,e===oA&&lA?null:e;default:return null}}function iz(e,t){if(ko)return e==="compositionend"||!z1&&wP(e,t)?(e=xP(),Mh=k1=Ai=null,ko=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=hA(n)}}function TP(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?TP(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function NP(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=op(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=op(e.document)}return t}function B1(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}var hz=Ba&&"documentMode"in document&&11>=document.documentMode,Lo=null,Eb=null,Sc=null,Tb=!1;function mA(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Tb||Lo==null||Lo!==op(r)||(r=Lo,"selectionStart"in r&&B1(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Sc&&Yc(Sc,r)||(Sc=r,r=Op(Eb,"onSelect"),0>=s,a-=s,Kr=1<<32-Fn(t)+a|n<E?(T=O,O=null):T=O.sibling;var _=h(m,O,x[E],w);if(_===null){O===null&&(O=T);break}e&&O&&_.alternate===null&&t(m,O),b=i(_,b,E),j===null?S=_:j.sibling=_,j=_,O=T}if(E===x.length)return n(m,O),Ne&&xa(m,E),S;if(O===null){for(;EE?(T=O,O=null):T=O.sibling;var P=h(m,O,_.value,w);if(P===null){O===null&&(O=T);break}e&&O&&P.alternate===null&&t(m,O),b=i(P,b,E),j===null?S=P:j.sibling=P,j=P,O=T}if(_.done)return n(m,O),Ne&&xa(m,E),S;if(O===null){for(;!_.done;E++,_=x.next())_=d(m,_.value,w),_!==null&&(b=i(_,b,E),j===null?S=_:j.sibling=_,j=_);return Ne&&xa(m,E),S}for(O=r(O);!_.done;E++,_=x.next())_=p(O,m,E,_.value,w),_!==null&&(e&&_.alternate!==null&&O.delete(_.key===null?E:_.key),b=i(_,b,E),j===null?S=_:j.sibling=_,j=_);return e&&O.forEach(function(C){return t(m,C)}),Ne&&xa(m,E),S}function g(m,b,x,w){if(typeof x=="object"&&x!==null&&x.type===Mo&&x.key===null&&(x=x.props.children),typeof x=="object"&&x!==null){switch(x.$$typeof){case Ud:e:{for(var S=x.key;b!==null;){if(b.key===S){if(S=x.type,S===Mo){if(b.tag===7){n(m,b.sibling),w=a(b,x.props.children),w.return=m,m=w;break e}}else if(b.elementType===S||typeof S=="object"&&S!==null&&S.$$typeof===oi&&cs(S)===b.type){n(m,b.sibling),w=a(b,x.props),Vu(w,x),w.return=m,m=w;break e}n(m,b);break}else t(m,b);b=b.sibling}x.type===Mo?(w=Ls(x.props.children,m.mode,w,x.key),w.return=m,m=w):(w=Dh(x.type,x.key,x.props,null,m.mode,w),Vu(w,x),w.return=m,m=w)}return s(m);case lc:e:{for(S=x.key;b!==null;){if(b.key===S)if(b.tag===4&&b.stateNode.containerInfo===x.containerInfo&&b.stateNode.implementation===x.implementation){n(m,b.sibling),w=a(b,x.children||[]),w.return=m,m=w;break e}else{n(m,b);break}else t(m,b);b=b.sibling}w=qv(x,m.mode,w),w.return=m,m=w}return s(m);case oi:return x=cs(x),g(m,b,x,w)}if(uc(x))return v(m,b,x,w);if(Iu(x)){if(S=Iu(x),typeof S!="function")throw Error(U(150));return x=S.call(x),y(m,b,x,w)}if(typeof x.then=="function")return g(m,b,Yd(x),w);if(x.$$typeof===Aa)return g(m,b,Gd(m,x),w);Xd(m,x)}return typeof x=="string"&&x!==""||typeof x=="number"||typeof x=="bigint"?(x=""+x,b!==null&&b.tag===6?(n(m,b.sibling),w=a(b,x),w.return=m,m=w):(n(m,b),w=Uv(x,m.mode,w),w.return=m,m=w),s(m)):n(m,b)}return function(m,b,x,w){try{Qc=0;var S=g(m,b,x,w);return al=null,S}catch(O){if(O===yu||O===oy)throw O;var j=In(29,O,null,m.mode);return j.lanes=w,j.return=m,j}finally{}}}var Ys=VP(!0),HP=VP(!1),li=!1;function Y1(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Db(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Mi(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Ri(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Pe&2){var a=r.pending;return a===null?t.next=t:(t.next=a.next,a.next=t),r.pending=t,t=up(e),$P(e,null,n),t}return sy(e,r,t,n),up(e)}function jc(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194048)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,lP(e,n)}}function Hv(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var a=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var s={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};i===null?a=i=s:i=i.next=s,n=n.next}while(n!==null);i===null?a=i=t:i=i.next=t}else a=i=t;n={baseState:r.baseState,firstBaseUpdate:a,lastBaseUpdate:i,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var $b=!1;function Ac(){if($b){var e=rl;if(e!==null)throw e}}function Oc(e,t,n,r){$b=!1;var a=e.updateQueue;li=!1;var i=a.firstBaseUpdate,s=a.lastBaseUpdate,o=a.shared.pending;if(o!==null){a.shared.pending=null;var l=o,u=l.next;l.next=null,s===null?i=u:s.next=u,s=l;var f=e.alternate;f!==null&&(f=f.updateQueue,o=f.lastBaseUpdate,o!==s&&(o===null?f.firstBaseUpdate=u:o.next=u,f.lastBaseUpdate=l))}if(i!==null){var d=a.baseState;s=0,f=u=l=null,o=i;do{var h=o.lane&-536870913,p=h!==o.lane;if(p?(Ee&h)===h:(r&h)===h){h!==0&&h===Tl&&($b=!0),f!==null&&(f=f.next={lane:0,tag:o.tag,payload:o.payload,callback:null,next:null});e:{var v=e,y=o;h=t;var g=n;switch(y.tag){case 1:if(v=y.payload,typeof v=="function"){d=v.call(g,d,h);break e}d=v;break e;case 3:v.flags=v.flags&-65537|128;case 0:if(v=y.payload,h=typeof v=="function"?v.call(g,d,h):v,h==null)break e;d=rt({},d,h);break e;case 2:li=!0}}h=o.callback,h!==null&&(e.flags|=64,p&&(e.flags|=8192),p=a.callbacks,p===null?a.callbacks=[h]:p.push(h))}else p={lane:h,tag:o.tag,payload:o.payload,callback:o.callback,next:null},f===null?(u=f=p,l=d):f=f.next=p,s|=h;if(o=o.next,o===null){if(o=a.shared.pending,o===null)break;p=o,o=p.next,p.next=null,a.lastBaseUpdate=p,a.shared.pending=null}}while(!0);f===null&&(l=d),a.baseState=l,a.firstBaseUpdate=u,a.lastBaseUpdate=f,i===null&&(a.shared.lanes=0),Ki|=s,e.lanes=s,e.memoizedState=d}}function FP(e,t){if(typeof e!="function")throw Error(U(191,e));e.call(t)}function KP(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ei?i:8;var s=pe.T,o={};pe.T=o,oS(e,!1,t,n);try{var l=a(),u=pe.S;if(u!==null&&u(o,l),l!==null&&typeof l=="object"&&typeof l.then=="function"){var f=wz(l,r);Ec(e,t,f,Kn(e))}else Ec(e,t,r,Kn(e))}catch(d){Ec(e,t,{then:function(){},status:"rejected",reason:d},Kn())}finally{Re.p=i,s!==null&&o.types!==null&&(s.types=o.types),pe.T=s}}function Nz(){}function Ib(e,t,n,r){if(e.tag!==5)throw Error(U(476));var a=vM(e).queue;yM(e,a,t,ks,n===null?Nz:function(){return gM(e),n(r)})}function vM(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ks,baseState:ks,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ua,lastRenderedState:ks},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Ua,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function gM(e){var t=vM(e);t.next===null&&(t=e.alternate.memoizedState),Ec(e,t.next.queue,{},Kn())}function sS(){return en(nf)}function bM(){return wt().memoizedState}function xM(){return wt().memoizedState}function _z(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=Kn();e=Mi(n);var r=Ri(t,e,n);r!==null&&(Tn(r,t,n),jc(r,t,n)),t={cache:F1()},e.payload=t;return}t=t.return}}function Cz(e,t,n){var r=Kn();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},fy(e)?wM(t,n):(n=U1(e,t,n,r),n!==null&&(Tn(n,e,r),jM(n,t,r)))}function SM(e,t,n){var r=Kn();Ec(e,t,n,r)}function Ec(e,t,n,r){var a={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(fy(e))wM(t,a);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var s=t.lastRenderedState,o=i(s,n);if(a.hasEagerState=!0,a.eagerState=o,Xn(o,s))return sy(e,t,a,0),Ge===null&&iy(),!1}catch{}finally{}if(n=U1(e,t,a,r),n!==null)return Tn(n,e,r),jM(n,t,r),!0}return!1}function oS(e,t,n,r){if(r={lane:2,revertLane:yS(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},fy(e)){if(t)throw Error(U(479))}else t=U1(e,n,r,2),t!==null&&Tn(t,e,2)}function fy(e){var t=e.alternate;return e===be||t!==null&&t===be}function wM(e,t){il=mp=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function jM(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,lP(e,n)}}var Jc={readContext:en,use:uy,useCallback:pt,useContext:pt,useEffect:pt,useImperativeHandle:pt,useLayoutEffect:pt,useInsertionEffect:pt,useMemo:pt,useReducer:pt,useRef:pt,useState:pt,useDebugValue:pt,useDeferredValue:pt,useTransition:pt,useSyncExternalStore:pt,useId:pt,useHostTransitionStatus:pt,useFormState:pt,useActionState:pt,useOptimistic:pt,useMemoCache:pt,useCacheRefresh:pt};Jc.useEffectEvent=pt;var AM={readContext:en,use:uy,useCallback:function(e,t){return cn().memoizedState=[e,t===void 0?null:t],e},useContext:en,useEffect:CA,useImperativeHandle:function(e,t,n){n=n!=null?n.concat([e]):null,Lh(4194308,4,fM.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Lh(4194308,4,e,t)},useInsertionEffect:function(e,t){Lh(4,2,e,t)},useMemo:function(e,t){var n=cn();t=t===void 0?null:t;var r=e();if(Xs){ji(!0);try{e()}finally{ji(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=cn();if(n!==void 0){var a=n(t);if(Xs){ji(!0);try{n(t)}finally{ji(!1)}}}else a=t;return r.memoizedState=r.baseState=a,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:a},r.queue=e,e=e.dispatch=Cz.bind(null,be,e),[r.memoizedState,e]},useRef:function(e){var t=cn();return e={current:e},t.memoizedState=e},useState:function(e){e=zb(e);var t=e.queue,n=SM.bind(null,be,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:aS,useDeferredValue:function(e,t){var n=cn();return iS(n,e,t)},useTransition:function(){var e=zb(!1);return e=yM.bind(null,be,e.queue,!0,!1),cn().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=be,a=cn();if(Ne){if(n===void 0)throw Error(U(407));n=n()}else{if(n=t(),Ge===null)throw Error(U(349));Ee&127||QP(r,t,n)}a.memoizedState=n;var i={value:n,getSnapshot:t};return a.queue=i,CA(JP.bind(null,r,i,e),[e]),r.flags|=2048,_l(9,{destroy:void 0},ZP.bind(null,r,i,n,t),null),n},useId:function(){var e=cn(),t=Ge.identifierPrefix;if(Ne){var n=Gr,r=Kr;n=(r&~(1<<32-Fn(r)-1)).toString(32)+n,t="_"+t+"R_"+n,n=yp++,0<\/script>",i=i.removeChild(i.firstChild);break;case"select":i=typeof r.is=="string"?s.createElement("select",{is:r.is}):s.createElement("select"),r.multiple?i.multiple=!0:r.size&&(i.size=r.size);break;default:i=typeof r.is=="string"?s.createElement(a,{is:r.is}):s.createElement(a)}}i[Qt]=t,i[_n]=r;e:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)i.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break e;for(;s.sibling===null;){if(s.return===null||s.return===t)break e;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=i;e:switch(tn(i,a,r),a){case"button":case"input":case"select":case"textarea":r=!!r.autoFocus;break e;case"img":r=!0;break e;default:r=!1}r&&ha(t)}}return Ze(t),Zv(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&ha(t);else{if(typeof r!="string"&&t.stateNode===null)throw Error(U(166));if(e=Ci.current,So(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Zt,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[Qt]=t,e=!!(e.nodeValue===n||r!==null&&r.suppressHydrationWarning===!0||SR(e.nodeValue,n)),e||Hi(t,!0)}else e=Ep(e).createTextNode(r),e[Qt]=t,t.stateNode=e}return Ze(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=So(t),n!==null){if(e===null){if(!r)throw Error(U(318));if(e=t.memoizedState,e=e!==null?e.dehydrated:null,!e)throw Error(U(557));e[Qt]=t}else Ks(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ze(t),e=!1}else n=Vv(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(Bn(t),t):(Bn(t),null);if(t.flags&128)throw Error(U(558))}return Ze(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=So(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(U(318));if(a=t.memoizedState,a=a!==null?a.dehydrated:null,!a)throw Error(U(317));a[Qt]=t}else Ks(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Ze(t),a=!1}else a=Vv(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(Bn(t),t):(Bn(t),null)}return Bn(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),i=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(i=r.memoizedState.cachePool.pool),i!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),Wd(t,t.updateQueue),Ze(t),null);case 4:return Al(),e===null&&vS(t.stateNode.containerInfo),Ze(t),null;case 10:return Ma(t.type),Ze(t),null;case 19:if(Ft(xt),r=t.memoizedState,r===null)return Ze(t),null;if(a=(t.flags&128)!==0,i=r.rendering,i===null)if(a)Hu(r,!1);else{if(gt!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(i=pp(e),i!==null){for(t.flags|=128,Hu(r,!1),e=i.updateQueue,t.updateQueue=e,Wd(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)kP(n,e),n=n.sibling;return We(xt,xt.current&1|2),Ne&&xa(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Vn()>xp&&(t.flags|=128,a=!0,Hu(r,!1),t.lanes=4194304)}else{if(!a)if(e=pp(i),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,Wd(t,e),Hu(r,!0),r.tail===null&&r.tailMode==="hidden"&&!i.alternate&&!Ne)return Ze(t),null}else 2*Vn()-r.renderingStartTime>xp&&n!==536870912&&(t.flags|=128,a=!0,Hu(r,!1),t.lanes=4194304);r.isBackwards?(i.sibling=t.child,t.child=i):(e=r.last,e!==null?e.sibling=i:t.child=i,r.last=i)}return r.tail!==null?(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Vn(),e.sibling=null,n=xt.current,We(xt,a?n&1|2:n&1),Ne&&xa(t,r.treeForkCount),e):(Ze(t),null);case 22:case 23:return Bn(t),X1(),r=t.memoizedState!==null,e!==null?e.memoizedState!==null!==r&&(t.flags|=8192):r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(Ze(t),t.subtreeFlags&6&&(t.flags|=8192)):Ze(t),n=t.updateQueue,n!==null&&Wd(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&Ft(zs),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Ma(Tt),Ze(t),null;case 25:return null;case 30:return null}throw Error(U(156,t.tag))}function $z(e,t){switch(H1(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Ma(Tt),Al(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return ip(t),null;case 31:if(t.memoizedState!==null){if(Bn(t),t.alternate===null)throw Error(U(340));Ks()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(Bn(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(U(340));Ks()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return Ft(xt),null;case 4:return Al(),null;case 10:return Ma(t.type),null;case 22:case 23:return Bn(t),X1(),e!==null&&Ft(zs),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Ma(Tt),null;case 25:return null;default:return null}}function kM(e,t){switch(H1(t),t.tag){case 3:Ma(Tt),Al();break;case 26:case 27:case 5:ip(t);break;case 4:Al();break;case 31:t.memoizedState!==null&&Bn(t);break;case 13:Bn(t);break;case 19:Ft(xt);break;case 10:Ma(t.type);break;case 22:case 23:Bn(t),X1(),e!==null&&Ft(zs);break;case 24:Ma(Tt)}}function dd(e,t){try{var n=t.updateQueue,r=n!==null?n.lastEffect:null;if(r!==null){var a=r.next;n=a;do{if((n.tag&e)===e){r=void 0;var i=n.create,s=n.inst;r=i(),s.destroy=r}n=n.next}while(n!==a)}}catch(o){Ue(t,t.return,o)}}function Fi(e,t,n){try{var r=t.updateQueue,a=r!==null?r.lastEffect:null;if(a!==null){var i=a.next;r=i;do{if((r.tag&e)===e){var s=r.inst,o=s.destroy;if(o!==void 0){s.destroy=void 0,a=t;var l=n,u=o;try{u()}catch(f){Ue(a,l,f)}}}r=r.next}while(r!==i)}}catch(f){Ue(t,t.return,f)}}function LM(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{KP(t,n)}catch(r){Ue(e,e.return,r)}}}function zM(e,t,n){n.props=Ws(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(r){Ue(e,t,r)}}function Tc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n=="function"?e.refCleanup=n(r):n.current=r}}catch(a){Ue(e,t,a)}}function Yr(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r=="function")try{r()}catch(a){Ue(e,t,a)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n=="function")try{n(null)}catch(a){Ue(e,t,a)}else n.current=null}function BM(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{e:switch(t){case"button":case"input":case"select":case"textarea":n.autoFocus&&r.focus();break e;case"img":n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(a){Ue(e,e.return,a)}}function Jv(e,t,n){try{var r=e.stateNode;rB(r,e.type,n,t),r[_n]=t}catch(a){Ue(e,e.return,a)}}function IM(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Xi(e.type)||e.tag===4}function eg(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||IM(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Xi(e.type)||e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Fb(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName==="HTML"?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName==="HTML"?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Oa));else if(r!==4&&(r===27&&Xi(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Fb(e,t,n),e=e.sibling;e!==null;)Fb(e,t,n),e=e.sibling}function bp(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Xi(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(bp(e,t,n),e=e.sibling;e!==null;)bp(e,t,n),e=e.sibling}function UM(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,a=t.attributes;a.length;)t.removeAttributeNode(a[0]);tn(t,r,n),t[Qt]=e,t[_n]=n}catch(i){Ue(e,e.return,i)}}var ja=!1,Et=!1,tg=!1,VA=typeof WeakSet=="function"?WeakSet:Set,qt=null;function kz(e,t){if(e=e.containerInfo,Zb=Cp,e=NP(e),B1(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var s=0,o=-1,l=-1,u=0,f=0,d=e,h=null;t:for(;;){for(var p;d!==n||a!==0&&d.nodeType!==3||(o=s+a),d!==i||r!==0&&d.nodeType!==3||(l=s+r),d.nodeType===3&&(s+=d.nodeValue.length),(p=d.firstChild)!==null;)h=d,d=p;for(;;){if(d===e)break t;if(h===n&&++u===a&&(o=s),h===i&&++f===r&&(l=s),(p=d.nextSibling)!==null)break;d=h,h=d.parentNode}d=p}n=o===-1||l===-1?null:{start:o,end:l}}else n=null}n=n||{start:0,end:0}}else n=null;for(Jb={focusedElem:e,selectionRange:n},Cp=!1,qt=t;qt!==null;)if(t=qt,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,qt=e;else for(;qt!==null;){switch(t=qt,i=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e!==null?e.events:null,e!==null))for(n=0;n title"))),tn(i,r,n),i[Qt]=e,Vt(i),r=i;break e;case"link":var s=u2("link","href",a).get(r+(n.href||""));if(s){for(var o=0;og&&(s=g,g=y,y=s);var m=pA(o,y),b=pA(o,g);if(m&&b&&(p.rangeCount!==1||p.anchorNode!==m.node||p.anchorOffset!==m.offset||p.focusNode!==b.node||p.focusOffset!==b.offset)){var x=d.createRange();x.setStart(m.node,m.offset),p.removeAllRanges(),y>g?(p.addRange(x),p.extend(b.node,b.offset)):(x.setEnd(b.node,b.offset),p.addRange(x))}}}}for(d=[],p=o;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof o.focus=="function"&&o.focus(),o=0;on?32:n,pe.T=null,n=Yb,Yb=null;var i=$i,s=Ra;if(Dt=0,Pl=$i=null,Ra=0,Pe&6)throw Error(U(331));var o=Pe;if(Pe|=4,ZM(i.current),XM(i,i.current,s,n),Pe=o,hd(0,!1),Hn&&typeof Hn.onPostCommitFiberRoot=="function")try{Hn.onPostCommitFiberRoot(id,i)}catch{}return!0}finally{Re.p=a,pe.T=r,hR(e,t)}}function GA(e,t,n){t=cr(n,t),t=qb(e.stateNode,t,2),e=Ri(e,t,2),e!==null&&(od(e,2),la(e))}function Ue(e,t,n){if(e.tag===3)GA(e,e,n);else for(;t!==null;){if(t.tag===3){GA(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof r.componentDidCatch=="function"&&(Di===null||!Di.has(r))){e=cr(n,e),n=_M(2),r=Ri(t,n,2),r!==null&&(CM(n,r,t,e),od(r,2),la(r));break}}t=t.return}}function rg(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Bz;var a=new Set;r.set(t,a)}else a=r.get(t),a===void 0&&(a=new Set,r.set(t,a));a.has(n)||(hS=!0,a.add(n),e=Hz.bind(null,e,t,n),t.then(e,e))}function Hz(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Ge===e&&(Ee&n)===n&&(gt===4||gt===3&&(Ee&62914560)===Ee&&300>Vn()-dy?!(Pe&2)&&Ml(e,0):pS|=n,Cl===Ee&&(Cl=0)),la(e)}function mR(e,t){t===0&&(t=sP()),e=co(e,t),e!==null&&(od(e,t),la(e))}function Fz(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),mR(e,n)}function Kz(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(U(314))}r!==null&&r.delete(t),mR(e,n)}function Gz(e,t){return C1(e,t)}var jp=null,Po=null,Wb=!1,Ap=!1,ag=!1,Ti=0;function la(e){e!==Po&&e.next===null&&(Po===null?jp=Po=e:Po=Po.next=e),Ap=!0,Wb||(Wb=!0,Xz())}function hd(e,t){if(!ag&&Ap){ag=!0;do for(var n=!1,r=jp;r!==null;){if(e!==0){var a=r.pendingLanes;if(a===0)var i=0;else{var s=r.suspendedLanes,o=r.pingedLanes;i=(1<<31-Fn(42|e)+1)-1,i&=a&~(s&~o),i=i&201326741?i&201326741|1:i?i|2:0}i!==0&&(n=!0,YA(r,i))}else i=Ee,i=ty(r,r===Ge?i:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(i&3)||sd(r,i)||(n=!0,YA(r,i));r=r.next}while(n);ag=!1}}function Yz(){yR()}function yR(){Ap=Wb=!1;var e=0;Ti!==0&&iB()&&(e=Ti);for(var t=Vn(),n=null,r=jp;r!==null;){var a=r.next,i=vR(r,t);i===0?(r.next=null,n===null?jp=a:n.next=a,a===null&&(Po=n)):(n=r,(e!==0||i&3)&&(Ap=!0)),r=a}Dt!==0&&Dt!==5||hd(e),Ti!==0&&(Ti=0)}function vR(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,a=e.expirationTimes,i=e.pendingLanes&-62914561;0o)break;var f=l.transferSize,d=l.initiatorType;f&&JA(d)&&(l=l.responseEnd,s+=f*(l"u"?null:document;function ER(e,t,n){var r=gu;if(r&&typeof t=="string"&&t){var a=ur(t);a='link[rel="'+e+'"][href="'+a+'"]',typeof n=="string"&&(a+='[crossorigin="'+n+'"]'),s2.has(a)||(s2.add(a),e={rel:e,crossOrigin:n,href:t},r.querySelector(a)===null&&(t=r.createElement("link"),tn(t,"link",e),Vt(t),r.head.appendChild(t)))}}function pB(e){Xa.D(e),ER("dns-prefetch",e,null)}function mB(e,t){Xa.C(e,t),ER("preconnect",e,t)}function yB(e,t,n){Xa.L(e,t,n);var r=gu;if(r&&e&&t){var a='link[rel="preload"][as="'+ur(t)+'"]';t==="image"&&n&&n.imageSrcSet?(a+='[imagesrcset="'+ur(n.imageSrcSet)+'"]',typeof n.imageSizes=="string"&&(a+='[imagesizes="'+ur(n.imageSizes)+'"]')):a+='[href="'+ur(e)+'"]';var i=a;switch(t){case"style":i=Rl(e);break;case"script":i=bu(e)}yr.has(i)||(e=rt({rel:"preload",href:t==="image"&&n&&n.imageSrcSet?void 0:e,as:t},n),yr.set(i,e),r.querySelector(a)!==null||t==="style"&&r.querySelector(pd(i))||t==="script"&&r.querySelector(md(i))||(t=r.createElement("link"),tn(t,"link",e),Vt(t),r.head.appendChild(t)))}}function vB(e,t){Xa.m(e,t);var n=gu;if(n&&e){var r=t&&typeof t.as=="string"?t.as:"script",a='link[rel="modulepreload"][as="'+ur(r)+'"][href="'+ur(e)+'"]',i=a;switch(r){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":i=bu(e)}if(!yr.has(i)&&(e=rt({rel:"modulepreload",href:e},t),yr.set(i,e),n.querySelector(a)===null)){switch(r){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(n.querySelector(md(i)))return}r=n.createElement("link"),tn(r,"link",e),Vt(r),n.head.appendChild(r)}}}function gB(e,t,n){Xa.S(e,t,n);var r=gu;if(r&&e){var a=el(r).hoistableStyles,i=Rl(e);t=t||"default";var s=a.get(i);if(!s){var o={loading:0,preload:null};if(s=r.querySelector(pd(i)))o.loading=5;else{e=rt({rel:"stylesheet",href:e,"data-precedence":t},n),(n=yr.get(i))&&gS(e,n);var l=s=r.createElement("link");Vt(l),tn(l,"link",e),l._p=new Promise(function(u,f){l.onload=u,l.onerror=f}),l.addEventListener("load",function(){o.loading|=1}),l.addEventListener("error",function(){o.loading|=2}),o.loading|=4,Uh(s,t,r)}s={type:"stylesheet",instance:s,count:1,state:o},a.set(i,s)}}}function bB(e,t){Xa.X(e,t);var n=gu;if(n&&e){var r=el(n).hoistableScripts,a=bu(e),i=r.get(a);i||(i=n.querySelector(md(a)),i||(e=rt({src:e,async:!0},t),(t=yr.get(a))&&bS(e,t),i=n.createElement("script"),Vt(i),tn(i,"link",e),n.head.appendChild(i)),i={type:"script",instance:i,count:1,state:null},r.set(a,i))}}function xB(e,t){Xa.M(e,t);var n=gu;if(n&&e){var r=el(n).hoistableScripts,a=bu(e),i=r.get(a);i||(i=n.querySelector(md(a)),i||(e=rt({src:e,async:!0,type:"module"},t),(t=yr.get(a))&&bS(e,t),i=n.createElement("script"),Vt(i),tn(i,"link",e),n.head.appendChild(i)),i={type:"script",instance:i,count:1,state:null},r.set(a,i))}}function o2(e,t,n,r){var a=(a=Ci.current)?Tp(a):null;if(!a)throw Error(U(446));switch(e){case"meta":case"title":return null;case"style":return typeof n.precedence=="string"&&typeof n.href=="string"?(t=Rl(n.href),n=el(a).hoistableStyles,r=n.get(t),r||(r={type:"style",instance:null,count:0,state:null},n.set(t,r)),r):{type:"void",instance:null,count:0,state:null};case"link":if(n.rel==="stylesheet"&&typeof n.href=="string"&&typeof n.precedence=="string"){e=Rl(n.href);var i=el(a).hoistableStyles,s=i.get(e);if(s||(a=a.ownerDocument||a,s={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},i.set(e,s),(i=a.querySelector(pd(e)))&&!i._p&&(s.instance=i,s.state.loading=5),yr.has(e)||(n={rel:"preload",as:"style",href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},yr.set(e,n),i||SB(a,e,n,s.state))),t&&r===null)throw Error(U(528,""));return s}if(t&&r!==null)throw Error(U(529,""));return null;case"script":return t=n.async,n=n.src,typeof n=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=bu(n),n=el(a).hoistableScripts,r=n.get(t),r||(r={type:"script",instance:null,count:0,state:null},n.set(t,r)),r):{type:"void",instance:null,count:0,state:null};default:throw Error(U(444,e))}}function Rl(e){return'href="'+ur(e)+'"'}function pd(e){return'link[rel="stylesheet"]['+e+"]"}function TR(e){return rt({},e,{"data-precedence":e.precedence,precedence:null})}function SB(e,t,n,r){e.querySelector('link[rel="preload"][as="style"]['+t+"]")?r.loading=1:(t=e.createElement("link"),r.preload=t,t.addEventListener("load",function(){return r.loading|=1}),t.addEventListener("error",function(){return r.loading|=2}),tn(t,"link",n),Vt(t),e.head.appendChild(t))}function bu(e){return'[src="'+ur(e)+'"]'}function md(e){return"script[async]"+e}function l2(e,t,n){if(t.count++,t.instance===null)switch(t.type){case"style":var r=e.querySelector('style[data-href~="'+ur(n.href)+'"]');if(r)return t.instance=r,Vt(r),r;var a=rt({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement("style"),Vt(r),tn(r,"style",a),Uh(r,n.precedence,e),t.instance=r;case"stylesheet":a=Rl(n.href);var i=e.querySelector(pd(a));if(i)return t.state.loading|=4,t.instance=i,Vt(i),i;r=TR(n),(a=yr.get(a))&&gS(r,a),i=(e.ownerDocument||e).createElement("link"),Vt(i);var s=i;return s._p=new Promise(function(o,l){s.onload=o,s.onerror=l}),tn(i,"link",r),t.state.loading|=4,Uh(i,n.precedence,e),t.instance=i;case"script":return i=bu(n.src),(a=e.querySelector(md(i)))?(t.instance=a,Vt(a),a):(r=n,(a=yr.get(i))&&(r=rt({},n),bS(r,a)),e=e.ownerDocument||e,a=e.createElement("script"),Vt(a),tn(a,"link",r),e.head.appendChild(a),t.instance=a);case"void":return null;default:throw Error(U(443,t.type))}else t.type==="stylesheet"&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Uh(r,n.precedence,e));return t.instance}function Uh(e,t,n){for(var r=n.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),a=r.length?r[r.length-1]:null,i=a,s=0;s title"):null)}function wB(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return e=t.disabled,typeof t.precedence=="string"&&e==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function NR(e){return!(e.type==="stylesheet"&&!(e.state.loading&3))}function jB(e,t,n,r){if(n.type==="stylesheet"&&(typeof r.media!="string"||matchMedia(r.media).matches!==!1)&&!(n.state.loading&4)){if(n.instance===null){var a=Rl(r.href),i=t.querySelector(pd(a));if(i){t=i._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(e.count++,e=Np.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=i,Vt(i);return}i=t.ownerDocument||t,r=TR(r),(a=yr.get(a))&&gS(r,a),i=i.createElement("link"),Vt(i);var s=i;s._p=new Promise(function(o,l){s.onload=o,s.onerror=l}),tn(i,"link",r),n.instance=i}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Np.bind(e),t.addEventListener("load",n),t.addEventListener("error",n))}}var cg=0;function AB(e,t){return e.stylesheets&&e.count===0&&Vh(e,e.stylesheets),0cg?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(a)}}:null}function Np(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Vh(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var _p=null;function Vh(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,_p=new Map,t.forEach(OB,e),_p=null,Np.call(e))}function OB(e,t){if(!(t.state.loading&4)){var n=_p.get(e);if(n)var r=n.get(null);else{n=new Map,_p.set(e,n);for(var a=e.querySelectorAll("link[data-precedence],style[data-precedence]"),i=0;i"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(kR)}catch(e){console.error(e)}}kR(),HC.exports=Jm;var RB=HC.exports;const DB=Be(RB);var yd=class{constructor(){this.listeners=new Set,this.subscribe=this.subscribe.bind(this)}subscribe(e){return this.listeners.add(e),this.onSubscribe(),()=>{this.listeners.delete(e),this.onUnsubscribe()}}hasListeners(){return this.listeners.size>0}onSubscribe(){}onUnsubscribe(){}},Ts,mi,fl,OC,$B=(OC=class extends yd{constructor(){super();ce(this,Ts);ce(this,mi);ce(this,fl);Z(this,fl,t=>{if(typeof window<"u"&&window.addEventListener){const n=()=>t();return window.addEventListener("visibilitychange",n,!1),()=>{window.removeEventListener("visibilitychange",n)}}})}onSubscribe(){M(this,mi)||this.setEventListener(M(this,fl))}onUnsubscribe(){var t;this.hasListeners()||((t=M(this,mi))==null||t.call(this),Z(this,mi,void 0))}setEventListener(t){var n;Z(this,fl,t),(n=M(this,mi))==null||n.call(this),Z(this,mi,t(r=>{typeof r=="boolean"?this.setFocused(r):this.onFocus()}))}setFocused(t){M(this,Ts)!==t&&(Z(this,Ts,t),this.onFocus())}onFocus(){const t=this.isFocused();this.listeners.forEach(n=>{n(t)})}isFocused(){var t;return typeof M(this,Ts)=="boolean"?M(this,Ts):((t=globalThis.document)==null?void 0:t.visibilityState)!=="hidden"}},Ts=new WeakMap,mi=new WeakMap,fl=new WeakMap,OC),AS=new $B,kB={setTimeout:(e,t)=>setTimeout(e,t),clearTimeout:e=>clearTimeout(e),setInterval:(e,t)=>setInterval(e,t),clearInterval:e=>clearInterval(e)},yi,w1,EC,LB=(EC=class{constructor(){ce(this,yi,kB);ce(this,w1,!1)}setTimeoutProvider(e){Z(this,yi,e)}setTimeout(e,t){return M(this,yi).setTimeout(e,t)}clearTimeout(e){M(this,yi).clearTimeout(e)}setInterval(e,t){return M(this,yi).setInterval(e,t)}clearInterval(e){M(this,yi).clearInterval(e)}},yi=new WeakMap,w1=new WeakMap,EC),bs=new LB;function zB(e){setTimeout(e,0)}var BB=typeof window>"u"||"Deno"in globalThis;function On(){}function IB(e,t){return typeof e=="function"?e(t):e}function o0(e){return typeof e=="number"&&e>=0&&e!==1/0}function LR(e,t){return Math.max(e+(t||0)-Date.now(),0)}function Bi(e,t){return typeof e=="function"?e(t):e}function Ln(e,t){return typeof e=="function"?e(t):e}function v2(e,t){const{type:n="all",exact:r,fetchStatus:a,predicate:i,queryKey:s,stale:o}=e;if(s){if(r){if(t.queryHash!==OS(s,t.options))return!1}else if(!of(t.queryKey,s))return!1}if(n!=="all"){const l=t.isActive();if(n==="active"&&!l||n==="inactive"&&l)return!1}return!(typeof o=="boolean"&&t.isStale()!==o||a&&a!==t.state.fetchStatus||i&&!i(t))}function g2(e,t){const{exact:n,status:r,predicate:a,mutationKey:i}=e;if(i){if(!t.options.mutationKey)return!1;if(n){if(sf(t.options.mutationKey)!==sf(i))return!1}else if(!of(t.options.mutationKey,i))return!1}return!(r&&t.state.status!==r||a&&!a(t))}function OS(e,t){return((t==null?void 0:t.queryKeyHashFn)||sf)(e)}function sf(e){return JSON.stringify(e,(t,n)=>u0(n)?Object.keys(n).sort().reduce((r,a)=>(r[a]=n[a],r),{}):n)}function of(e,t){return e===t?!0:typeof e!=typeof t?!1:e&&t&&typeof e=="object"&&typeof t=="object"?Object.keys(t).every(n=>of(e[n],t[n])):!1}var UB=Object.prototype.hasOwnProperty;function zR(e,t,n=0){if(e===t)return e;if(n>500)return t;const r=b2(e)&&b2(t);if(!r&&!(u0(e)&&u0(t)))return t;const i=(r?e:Object.keys(e)).length,s=r?t:Object.keys(t),o=s.length,l=r?new Array(o):{};let u=0;for(let f=0;f{bs.setTimeout(t,e)})}function c0(e,t,n){return typeof n.structuralSharing=="function"?n.structuralSharing(e,t):n.structuralSharing!==!1?zR(e,t):t}function VB(e,t,n=0){const r=[...e,t];return n&&r.length>n?r.slice(1):r}function HB(e,t,n=0){const r=[t,...e];return n&&r.length>n?r.slice(0,-1):r}var ES=Symbol();function BR(e,t){return!e.queryFn&&(t!=null&&t.initialPromise)?()=>t.initialPromise:!e.queryFn||e.queryFn===ES?()=>Promise.reject(new Error(`Missing queryFn: '${e.queryHash}'`)):e.queryFn}function IR(e,t){return typeof e=="function"?e(...t):!!e}function FB(e,t,n){let r=!1,a;return Object.defineProperty(e,"signal",{enumerable:!0,get:()=>(a??(a=t()),r||(r=!0,a.aborted?n():a.addEventListener("abort",n,{once:!0})),a)}),e}var lf=(()=>{let e=()=>BB;return{isServer(){return e()},setIsServer(t){e=t}}})();function f0(){let e,t;const n=new Promise((a,i)=>{e=a,t=i});n.status="pending",n.catch(()=>{});function r(a){Object.assign(n,a),delete n.resolve,delete n.reject}return n.resolve=a=>{r({status:"fulfilled",value:a}),e(a)},n.reject=a=>{r({status:"rejected",reason:a}),t(a)},n}var KB=zB;function GB(){let e=[],t=0,n=o=>{o()},r=o=>{o()},a=KB;const i=o=>{t?e.push(o):a(()=>{n(o)})},s=()=>{const o=e;e=[],o.length&&a(()=>{r(()=>{o.forEach(l=>{n(l)})})})};return{batch:o=>{let l;t++;try{l=o()}finally{t--,t||s()}return l},batchCalls:o=>(...l)=>{i(()=>{o(...l)})},schedule:i,setNotifyFunction:o=>{n=o},setBatchNotifyFunction:o=>{r=o},setScheduler:o=>{a=o}}}var Wt=GB(),dl,vi,hl,TC,YB=(TC=class extends yd{constructor(){super();ce(this,dl,!0);ce(this,vi);ce(this,hl);Z(this,hl,t=>{if(typeof window<"u"&&window.addEventListener){const n=()=>t(!0),r=()=>t(!1);return window.addEventListener("online",n,!1),window.addEventListener("offline",r,!1),()=>{window.removeEventListener("online",n),window.removeEventListener("offline",r)}}})}onSubscribe(){M(this,vi)||this.setEventListener(M(this,hl))}onUnsubscribe(){var t;this.hasListeners()||((t=M(this,vi))==null||t.call(this),Z(this,vi,void 0))}setEventListener(t){var n;Z(this,hl,t),(n=M(this,vi))==null||n.call(this),Z(this,vi,t(this.setOnline.bind(this)))}setOnline(t){M(this,dl)!==t&&(Z(this,dl,t),this.listeners.forEach(r=>{r(t)}))}isOnline(){return M(this,dl)}},dl=new WeakMap,vi=new WeakMap,hl=new WeakMap,TC),Mp=new YB;function XB(e){return Math.min(1e3*2**e,3e4)}function UR(e){return(e??"online")==="online"?Mp.isOnline():!0}var d0=class extends Error{constructor(e){super("CancelledError"),this.revert=e==null?void 0:e.revert,this.silent=e==null?void 0:e.silent}};function qR(e){let t=!1,n=0,r;const a=f0(),i=()=>a.status!=="pending",s=y=>{var g;if(!i()){const m=new d0(y);h(m),(g=e.onCancel)==null||g.call(e,m)}},o=()=>{t=!0},l=()=>{t=!1},u=()=>AS.isFocused()&&(e.networkMode==="always"||Mp.isOnline())&&e.canRun(),f=()=>UR(e.networkMode)&&e.canRun(),d=y=>{i()||(r==null||r(),a.resolve(y))},h=y=>{i()||(r==null||r(),a.reject(y))},p=()=>new Promise(y=>{var g;r=m=>{(i()||u())&&y(m)},(g=e.onPause)==null||g.call(e)}).then(()=>{var y;r=void 0,i()||(y=e.onContinue)==null||y.call(e)}),v=()=>{if(i())return;let y;const g=n===0?e.initialPromise:void 0;try{y=g??e.fn()}catch(m){y=Promise.reject(m)}Promise.resolve(y).then(d).catch(m=>{var j;if(i())return;const b=e.retry??(lf.isServer()?0:3),x=e.retryDelay??XB,w=typeof x=="function"?x(n,m):x,S=b===!0||typeof b=="number"&&nu()?void 0:p()).then(()=>{t?h(m):v()})})};return{promise:a,status:()=>a.status,cancel:s,continue:()=>(r==null||r(),a),cancelRetry:o,continueRetry:l,canStart:f,start:()=>(f()?v():p().then(v),a)}}var Ns,NC,VR=(NC=class{constructor(){ce(this,Ns)}destroy(){this.clearGcTimeout()}scheduleGc(){this.clearGcTimeout(),o0(this.gcTime)&&Z(this,Ns,bs.setTimeout(()=>{this.optionalRemove()},this.gcTime))}updateGcTime(e){this.gcTime=Math.max(this.gcTime||0,e??(lf.isServer()?1/0:5*60*1e3))}clearGcTimeout(){M(this,Ns)!==void 0&&(bs.clearTimeout(M(this,Ns)),Z(this,Ns,void 0))}},Ns=new WeakMap,NC);function WB(e){return{onFetch:(t,n)=>{var f,d,h,p,v;const r=t.options,a=(h=(d=(f=t.fetchOptions)==null?void 0:f.meta)==null?void 0:d.fetchMore)==null?void 0:h.direction,i=((p=t.state.data)==null?void 0:p.pages)||[],s=((v=t.state.data)==null?void 0:v.pageParams)||[];let o={pages:[],pageParams:[]},l=0;const u=async()=>{let y=!1;const g=x=>{FB(x,()=>t.signal,()=>y=!0)},m=BR(t.options,t.fetchOptions),b=async(x,w,S)=>{if(y)return Promise.reject(t.signal.reason);if(w==null&&x.pages.length)return Promise.resolve(x);const O=(()=>{const P={client:t.client,queryKey:t.queryKey,pageParam:w,direction:S?"backward":"forward",meta:t.options.meta};return g(P),P})(),E=await m(O),{maxPages:T}=t.options,_=S?HB:VB;return{pages:_(x.pages,E,T),pageParams:_(x.pageParams,w,T)}};if(a&&i.length){const x=a==="backward",w=x?QB:S2,S={pages:i,pageParams:s},j=w(r,S);o=await b(S,j,x)}else{const x=e??i.length;do{const w=l===0?s[0]??r.initialPageParam:S2(r,o);if(l>0&&w==null)break;o=await b(o,w),l++}while(l{var y,g;return(g=(y=t.options).persister)==null?void 0:g.call(y,u,{client:t.client,queryKey:t.queryKey,meta:t.options.meta,signal:t.signal},n)}:t.fetchFn=u}}}function S2(e,{pages:t,pageParams:n}){const r=t.length-1;return t.length>0?e.getNextPageParam(t[r],t,n[r],n):void 0}function QB(e,{pages:t,pageParams:n}){var r;return t.length>0?(r=e.getPreviousPageParam)==null?void 0:r.call(e,t[0],t,n[0],n):void 0}var pl,_s,ml,ar,Cs,Bt,Jf,Ps,kn,HR,ga,_C,ZB=(_C=class extends VR{constructor(t){super();ce(this,kn);ce(this,pl);ce(this,_s);ce(this,ml);ce(this,ar);ce(this,Cs);ce(this,Bt);ce(this,Jf);ce(this,Ps);Z(this,Ps,!1),Z(this,Jf,t.defaultOptions),this.setOptions(t.options),this.observers=[],Z(this,Cs,t.client),Z(this,ar,M(this,Cs).getQueryCache()),this.queryKey=t.queryKey,this.queryHash=t.queryHash,Z(this,_s,j2(this.options)),this.state=t.state??M(this,_s),this.scheduleGc()}get meta(){return this.options.meta}get queryType(){return M(this,pl)}get promise(){var t;return(t=M(this,Bt))==null?void 0:t.promise}setOptions(t){if(this.options={...M(this,Jf),...t},t!=null&&t._type&&Z(this,pl,t._type),this.updateGcTime(this.options.gcTime),this.state&&this.state.data===void 0){const n=j2(this.options);n.data!==void 0&&(this.setState(w2(n.data,n.dataUpdatedAt)),Z(this,_s,n))}}optionalRemove(){!this.observers.length&&this.state.fetchStatus==="idle"&&M(this,ar).remove(this)}setData(t,n){const r=c0(this.state.data,t,this.options);return Ae(this,kn,ga).call(this,{data:r,type:"success",dataUpdatedAt:n==null?void 0:n.updatedAt,manual:n==null?void 0:n.manual}),r}setState(t){Ae(this,kn,ga).call(this,{type:"setState",state:t})}cancel(t){var r,a;const n=(r=M(this,Bt))==null?void 0:r.promise;return(a=M(this,Bt))==null||a.cancel(t),n?n.then(On).catch(On):Promise.resolve()}destroy(){super.destroy(),this.cancel({silent:!0})}get resetState(){return M(this,_s)}reset(){this.destroy(),this.setState(this.resetState)}isActive(){return this.observers.some(t=>Ln(t.options.enabled,this)!==!1)}isDisabled(){return this.getObserversCount()>0?!this.isActive():this.options.queryFn===ES||!this.isFetched()}isFetched(){return this.state.dataUpdateCount+this.state.errorUpdateCount>0}isStatic(){return this.getObserversCount()>0?this.observers.some(t=>Bi(t.options.staleTime,this)==="static"):!1}isStale(){return this.getObserversCount()>0?this.observers.some(t=>t.getCurrentResult().isStale):this.state.data===void 0||this.state.isInvalidated}isStaleByTime(t=0){return this.state.data===void 0?!0:t==="static"?!1:this.state.isInvalidated?!0:!LR(this.state.dataUpdatedAt,t)}onFocus(){var n;const t=this.observers.find(r=>r.shouldFetchOnWindowFocus());t==null||t.refetch({cancelRefetch:!1}),(n=M(this,Bt))==null||n.continue()}onOnline(){var n;const t=this.observers.find(r=>r.shouldFetchOnReconnect());t==null||t.refetch({cancelRefetch:!1}),(n=M(this,Bt))==null||n.continue()}addObserver(t){this.observers.includes(t)||(this.observers.push(t),this.clearGcTimeout(),M(this,ar).notify({type:"observerAdded",query:this,observer:t}))}removeObserver(t){this.observers.includes(t)&&(this.observers=this.observers.filter(n=>n!==t),this.observers.length||(M(this,Bt)&&(M(this,Ps)||Ae(this,kn,HR).call(this)?M(this,Bt).cancel({revert:!0}):M(this,Bt).cancelRetry()),this.scheduleGc()),M(this,ar).notify({type:"observerRemoved",query:this,observer:t}))}getObserversCount(){return this.observers.length}invalidate(){this.state.isInvalidated||Ae(this,kn,ga).call(this,{type:"invalidate"})}async fetch(t,n){var u,f,d,h,p,v,y,g,m,b,x;if(this.state.fetchStatus!=="idle"&&((u=M(this,Bt))==null?void 0:u.status())!=="rejected"){if(this.state.data!==void 0&&(n!=null&&n.cancelRefetch))this.cancel({silent:!0});else if(M(this,Bt))return M(this,Bt).continueRetry(),M(this,Bt).promise}if(t&&this.setOptions(t),!this.options.queryFn){const w=this.observers.find(S=>S.options.queryFn);w&&this.setOptions(w.options)}const r=new AbortController,a=w=>{Object.defineProperty(w,"signal",{enumerable:!0,get:()=>(Z(this,Ps,!0),r.signal)})},i=()=>{const w=BR(this.options,n),j=(()=>{const O={client:M(this,Cs),queryKey:this.queryKey,meta:this.meta};return a(O),O})();return Z(this,Ps,!1),this.options.persister?this.options.persister(w,j,this):w(j)},o=(()=>{const w={fetchOptions:n,options:this.options,queryKey:this.queryKey,client:M(this,Cs),state:this.state,fetchFn:i};return a(w),w})(),l=M(this,pl)==="infinite"?WB(this.options.pages):this.options.behavior;l==null||l.onFetch(o,this),Z(this,ml,this.state),(this.state.fetchStatus==="idle"||this.state.fetchMeta!==((f=o.fetchOptions)==null?void 0:f.meta))&&Ae(this,kn,ga).call(this,{type:"fetch",meta:(d=o.fetchOptions)==null?void 0:d.meta}),Z(this,Bt,qR({initialPromise:n==null?void 0:n.initialPromise,fn:o.fetchFn,onCancel:w=>{w instanceof d0&&w.revert&&this.setState({...M(this,ml),fetchStatus:"idle"}),r.abort()},onFail:(w,S)=>{Ae(this,kn,ga).call(this,{type:"failed",failureCount:w,error:S})},onPause:()=>{Ae(this,kn,ga).call(this,{type:"pause"})},onContinue:()=>{Ae(this,kn,ga).call(this,{type:"continue"})},retry:o.options.retry,retryDelay:o.options.retryDelay,networkMode:o.options.networkMode,canRun:()=>!0}));try{const w=await M(this,Bt).start();if(w===void 0)throw new Error(`${this.queryHash} data is undefined`);return this.setData(w),(p=(h=M(this,ar).config).onSuccess)==null||p.call(h,w,this),(y=(v=M(this,ar).config).onSettled)==null||y.call(v,w,this.state.error,this),w}catch(w){if(w instanceof d0){if(w.silent)return M(this,Bt).promise;if(w.revert){if(this.state.data===void 0)throw w;return this.state.data}}throw Ae(this,kn,ga).call(this,{type:"error",error:w}),(m=(g=M(this,ar).config).onError)==null||m.call(g,w,this),(x=(b=M(this,ar).config).onSettled)==null||x.call(b,this.state.data,w,this),w}finally{this.scheduleGc()}}},pl=new WeakMap,_s=new WeakMap,ml=new WeakMap,ar=new WeakMap,Cs=new WeakMap,Bt=new WeakMap,Jf=new WeakMap,Ps=new WeakMap,kn=new WeakSet,HR=function(){return this.state.fetchStatus==="paused"&&this.state.status==="pending"},ga=function(t){const n=r=>{switch(t.type){case"failed":return{...r,fetchFailureCount:t.failureCount,fetchFailureReason:t.error};case"pause":return{...r,fetchStatus:"paused"};case"continue":return{...r,fetchStatus:"fetching"};case"fetch":return{...r,...FR(r.data,this.options),fetchMeta:t.meta??null};case"success":const a={...r,...w2(t.data,t.dataUpdatedAt),dataUpdateCount:r.dataUpdateCount+1,...!t.manual&&{fetchStatus:"idle",fetchFailureCount:0,fetchFailureReason:null}};return Z(this,ml,t.manual?a:void 0),a;case"error":const i=t.error;return{...r,error:i,errorUpdateCount:r.errorUpdateCount+1,errorUpdatedAt:Date.now(),fetchFailureCount:r.fetchFailureCount+1,fetchFailureReason:i,fetchStatus:"idle",status:"error",isInvalidated:!0};case"invalidate":return{...r,isInvalidated:!0};case"setState":return{...r,...t.state}}};this.state=n(this.state),Wt.batch(()=>{this.observers.forEach(r=>{r.onQueryUpdate()}),M(this,ar).notify({query:this,type:"updated",action:t})})},_C);function FR(e,t){return{fetchFailureCount:0,fetchFailureReason:null,fetchStatus:UR(t.networkMode)?"fetching":"paused",...e===void 0&&{error:null,status:"pending"}}}function w2(e,t){return{data:e,dataUpdatedAt:t??Date.now(),error:null,isInvalidated:!1,status:"success"}}function j2(e){const t=typeof e.initialData=="function"?e.initialData():e.initialData,n=t!==void 0,r=n?typeof e.initialDataUpdatedAt=="function"?e.initialDataUpdatedAt():e.initialDataUpdatedAt:0;return{data:t,dataUpdateCount:0,dataUpdatedAt:n?r??Date.now():0,error:null,errorUpdateCount:0,errorUpdatedAt:0,fetchFailureCount:0,fetchFailureReason:null,fetchMeta:null,isInvalidated:!1,status:n?"success":"pending",fetchStatus:"idle"}}var wn,Te,ed,un,Ms,yl,Sa,gi,td,vl,gl,Rs,Ds,bi,bl,ze,hc,h0,p0,m0,y0,v0,g0,b0,KR,CC,JB=(CC=class extends yd{constructor(t,n){super();ce(this,ze);ce(this,wn);ce(this,Te);ce(this,ed);ce(this,un);ce(this,Ms);ce(this,yl);ce(this,Sa);ce(this,gi);ce(this,td);ce(this,vl);ce(this,gl);ce(this,Rs);ce(this,Ds);ce(this,bi);ce(this,bl,new Set);this.options=n,Z(this,wn,t),Z(this,gi,null),Z(this,Sa,f0()),this.bindMethods(),this.setOptions(n)}bindMethods(){this.refetch=this.refetch.bind(this)}onSubscribe(){this.listeners.size===1&&(M(this,Te).addObserver(this),A2(M(this,Te),this.options)?Ae(this,ze,hc).call(this):this.updateResult(),Ae(this,ze,y0).call(this))}onUnsubscribe(){this.hasListeners()||this.destroy()}shouldFetchOnReconnect(){return x0(M(this,Te),this.options,this.options.refetchOnReconnect)}shouldFetchOnWindowFocus(){return x0(M(this,Te),this.options,this.options.refetchOnWindowFocus)}destroy(){this.listeners=new Set,Ae(this,ze,v0).call(this),Ae(this,ze,g0).call(this),M(this,Te).removeObserver(this)}setOptions(t){const n=this.options,r=M(this,Te);if(this.options=M(this,wn).defaultQueryOptions(t),this.options.enabled!==void 0&&typeof this.options.enabled!="boolean"&&typeof this.options.enabled!="function"&&typeof Ln(this.options.enabled,M(this,Te))!="boolean")throw new Error("Expected enabled to be a boolean or a callback that returns a boolean");Ae(this,ze,b0).call(this),M(this,Te).setOptions(this.options),n._defaulted&&!l0(this.options,n)&&M(this,wn).getQueryCache().notify({type:"observerOptionsUpdated",query:M(this,Te),observer:this});const a=this.hasListeners();a&&O2(M(this,Te),r,this.options,n)&&Ae(this,ze,hc).call(this),this.updateResult(),a&&(M(this,Te)!==r||Ln(this.options.enabled,M(this,Te))!==Ln(n.enabled,M(this,Te))||Bi(this.options.staleTime,M(this,Te))!==Bi(n.staleTime,M(this,Te)))&&Ae(this,ze,h0).call(this);const i=Ae(this,ze,p0).call(this);a&&(M(this,Te)!==r||Ln(this.options.enabled,M(this,Te))!==Ln(n.enabled,M(this,Te))||i!==M(this,bi))&&Ae(this,ze,m0).call(this,i)}getOptimisticResult(t){const n=M(this,wn).getQueryCache().build(M(this,wn),t),r=this.createResult(n,t);return t8(this,r)&&(Z(this,un,r),Z(this,yl,this.options),Z(this,Ms,M(this,Te).state)),r}getCurrentResult(){return M(this,un)}trackResult(t,n){return new Proxy(t,{get:(r,a)=>(this.trackProp(a),n==null||n(a),a==="promise"&&(this.trackProp("data"),!this.options.experimental_prefetchInRender&&M(this,Sa).status==="pending"&&M(this,Sa).reject(new Error("experimental_prefetchInRender feature flag is not enabled"))),Reflect.get(r,a))})}trackProp(t){M(this,bl).add(t)}getCurrentQuery(){return M(this,Te)}refetch({...t}={}){return this.fetch({...t})}fetchOptimistic(t){const n=M(this,wn).defaultQueryOptions(t),r=M(this,wn).getQueryCache().build(M(this,wn),n);return r.fetch().then(()=>this.createResult(r,n))}fetch(t){return Ae(this,ze,hc).call(this,{...t,cancelRefetch:t.cancelRefetch??!0}).then(()=>(this.updateResult(),M(this,un)))}createResult(t,n){var T;const r=M(this,Te),a=this.options,i=M(this,un),s=M(this,Ms),o=M(this,yl),u=t!==r?t.state:M(this,ed),{state:f}=t;let d={...f},h=!1,p;if(n._optimisticResults){const _=this.hasListeners(),P=!_&&A2(t,n),C=_&&O2(t,r,n,a);(P||C)&&(d={...d,...FR(f.data,t.options)}),n._optimisticResults==="isRestoring"&&(d.fetchStatus="idle")}let{error:v,errorUpdatedAt:y,status:g}=d;p=d.data;let m=!1;if(n.placeholderData!==void 0&&p===void 0&&g==="pending"){let _;i!=null&&i.isPlaceholderData&&n.placeholderData===(o==null?void 0:o.placeholderData)?(_=i.data,m=!0):_=typeof n.placeholderData=="function"?n.placeholderData((T=M(this,gl))==null?void 0:T.state.data,M(this,gl)):n.placeholderData,_!==void 0&&(g="success",p=c0(i==null?void 0:i.data,_,n),h=!0)}if(n.select&&p!==void 0&&!m)if(i&&p===(s==null?void 0:s.data)&&n.select===M(this,td))p=M(this,vl);else try{Z(this,td,n.select),p=n.select(p),p=c0(i==null?void 0:i.data,p,n),Z(this,vl,p),Z(this,gi,null)}catch(_){Z(this,gi,_)}M(this,gi)&&(v=M(this,gi),p=M(this,vl),y=Date.now(),g="error");const b=d.fetchStatus==="fetching",x=g==="pending",w=g==="error",S=x&&b,j=p!==void 0,E={status:g,fetchStatus:d.fetchStatus,isPending:x,isSuccess:g==="success",isError:w,isInitialLoading:S,isLoading:S,data:p,dataUpdatedAt:d.dataUpdatedAt,error:v,errorUpdatedAt:y,failureCount:d.fetchFailureCount,failureReason:d.fetchFailureReason,errorUpdateCount:d.errorUpdateCount,isFetched:t.isFetched(),isFetchedAfterMount:d.dataUpdateCount>u.dataUpdateCount||d.errorUpdateCount>u.errorUpdateCount,isFetching:b,isRefetching:b&&!x,isLoadingError:w&&!j,isPaused:d.fetchStatus==="paused",isPlaceholderData:h,isRefetchError:w&&j,isStale:TS(t,n),refetch:this.refetch,promise:M(this,Sa),isEnabled:Ln(n.enabled,t)!==!1};if(this.options.experimental_prefetchInRender){const _=E.data!==void 0,P=E.status==="error"&&!_,C=k=>{P?k.reject(E.error):_&&k.resolve(E.data)},z=()=>{const k=Z(this,Sa,E.promise=f0());C(k)},L=M(this,Sa);switch(L.status){case"pending":t.queryHash===r.queryHash&&C(L);break;case"fulfilled":(P||E.data!==L.value)&&z();break;case"rejected":(!P||E.error!==L.reason)&&z();break}}return E}updateResult(){const t=M(this,un),n=this.createResult(M(this,Te),this.options);if(Z(this,Ms,M(this,Te).state),Z(this,yl,this.options),M(this,Ms).data!==void 0&&Z(this,gl,M(this,Te)),l0(n,t))return;Z(this,un,n);const r=()=>{if(!t)return!0;const{notifyOnChangeProps:a}=this.options,i=typeof a=="function"?a():a;if(i==="all"||!i&&!M(this,bl).size)return!0;const s=new Set(i??M(this,bl));return this.options.throwOnError&&s.add("error"),Object.keys(M(this,un)).some(o=>{const l=o;return M(this,un)[l]!==t[l]&&s.has(l)})};Ae(this,ze,KR).call(this,{listeners:r()})}onQueryUpdate(){this.updateResult(),this.hasListeners()&&Ae(this,ze,y0).call(this)}},wn=new WeakMap,Te=new WeakMap,ed=new WeakMap,un=new WeakMap,Ms=new WeakMap,yl=new WeakMap,Sa=new WeakMap,gi=new WeakMap,td=new WeakMap,vl=new WeakMap,gl=new WeakMap,Rs=new WeakMap,Ds=new WeakMap,bi=new WeakMap,bl=new WeakMap,ze=new WeakSet,hc=function(t){Ae(this,ze,b0).call(this);let n=M(this,Te).fetch(this.options,t);return t!=null&&t.throwOnError||(n=n.catch(On)),n},h0=function(){Ae(this,ze,v0).call(this);const t=Bi(this.options.staleTime,M(this,Te));if(lf.isServer()||M(this,un).isStale||!o0(t))return;const r=LR(M(this,un).dataUpdatedAt,t)+1;Z(this,Rs,bs.setTimeout(()=>{M(this,un).isStale||this.updateResult()},r))},p0=function(){return(typeof this.options.refetchInterval=="function"?this.options.refetchInterval(M(this,Te)):this.options.refetchInterval)??!1},m0=function(t){Ae(this,ze,g0).call(this),Z(this,bi,t),!(lf.isServer()||Ln(this.options.enabled,M(this,Te))===!1||!o0(M(this,bi))||M(this,bi)===0)&&Z(this,Ds,bs.setInterval(()=>{(this.options.refetchIntervalInBackground||AS.isFocused())&&Ae(this,ze,hc).call(this)},M(this,bi)))},y0=function(){Ae(this,ze,h0).call(this),Ae(this,ze,m0).call(this,Ae(this,ze,p0).call(this))},v0=function(){M(this,Rs)!==void 0&&(bs.clearTimeout(M(this,Rs)),Z(this,Rs,void 0))},g0=function(){M(this,Ds)!==void 0&&(bs.clearInterval(M(this,Ds)),Z(this,Ds,void 0))},b0=function(){const t=M(this,wn).getQueryCache().build(M(this,wn),this.options);if(t===M(this,Te))return;const n=M(this,Te);Z(this,Te,t),Z(this,ed,t.state),this.hasListeners()&&(n==null||n.removeObserver(this),t.addObserver(this))},KR=function(t){Wt.batch(()=>{t.listeners&&this.listeners.forEach(n=>{n(M(this,un))}),M(this,wn).getQueryCache().notify({query:M(this,Te),type:"observerResultsUpdated"})})},CC);function e8(e,t){return Ln(t.enabled,e)!==!1&&e.state.data===void 0&&!(e.state.status==="error"&&Ln(t.retryOnMount,e)===!1)}function A2(e,t){return e8(e,t)||e.state.data!==void 0&&x0(e,t,t.refetchOnMount)}function x0(e,t,n){if(Ln(t.enabled,e)!==!1&&Bi(t.staleTime,e)!=="static"){const r=typeof n=="function"?n(e):n;return r==="always"||r!==!1&&TS(e,t)}return!1}function O2(e,t,n,r){return(e!==t||Ln(r.enabled,e)===!1)&&(!n.suspense||e.state.status!=="error")&&TS(e,n)}function TS(e,t){return Ln(t.enabled,e)!==!1&&e.isStaleByTime(Bi(t.staleTime,e))}function t8(e,t){return!l0(e.getCurrentResult(),t)}var nd,qr,rn,$s,Vr,ri,PC,n8=(PC=class extends VR{constructor(t){super();ce(this,Vr);ce(this,nd);ce(this,qr);ce(this,rn);ce(this,$s);Z(this,nd,t.client),this.mutationId=t.mutationId,Z(this,rn,t.mutationCache),Z(this,qr,[]),this.state=t.state||r8(),this.setOptions(t.options),this.scheduleGc()}setOptions(t){this.options=t,this.updateGcTime(this.options.gcTime)}get meta(){return this.options.meta}addObserver(t){M(this,qr).includes(t)||(M(this,qr).push(t),this.clearGcTimeout(),M(this,rn).notify({type:"observerAdded",mutation:this,observer:t}))}removeObserver(t){Z(this,qr,M(this,qr).filter(n=>n!==t)),this.scheduleGc(),M(this,rn).notify({type:"observerRemoved",mutation:this,observer:t})}optionalRemove(){M(this,qr).length||(this.state.status==="pending"?this.scheduleGc():M(this,rn).remove(this))}continue(){var t;return((t=M(this,$s))==null?void 0:t.continue())??this.execute(this.state.variables)}async execute(t){var s,o,l,u,f,d,h,p,v,y,g,m,b,x,w,S,j,O;const n=()=>{Ae(this,Vr,ri).call(this,{type:"continue"})},r={client:M(this,nd),meta:this.options.meta,mutationKey:this.options.mutationKey};Z(this,$s,qR({fn:()=>this.options.mutationFn?this.options.mutationFn(t,r):Promise.reject(new Error("No mutationFn found")),onFail:(E,T)=>{Ae(this,Vr,ri).call(this,{type:"failed",failureCount:E,error:T})},onPause:()=>{Ae(this,Vr,ri).call(this,{type:"pause"})},onContinue:n,retry:this.options.retry??0,retryDelay:this.options.retryDelay,networkMode:this.options.networkMode,canRun:()=>M(this,rn).canRun(this)}));const a=this.state.status==="pending",i=!M(this,$s).canStart();try{if(a)n();else{Ae(this,Vr,ri).call(this,{type:"pending",variables:t,isPaused:i}),M(this,rn).config.onMutate&&await M(this,rn).config.onMutate(t,this,r);const T=await((o=(s=this.options).onMutate)==null?void 0:o.call(s,t,r));T!==this.state.context&&Ae(this,Vr,ri).call(this,{type:"pending",context:T,variables:t,isPaused:i})}const E=await M(this,$s).start();return await((u=(l=M(this,rn).config).onSuccess)==null?void 0:u.call(l,E,t,this.state.context,this,r)),await((d=(f=this.options).onSuccess)==null?void 0:d.call(f,E,t,this.state.context,r)),await((p=(h=M(this,rn).config).onSettled)==null?void 0:p.call(h,E,null,this.state.variables,this.state.context,this,r)),await((y=(v=this.options).onSettled)==null?void 0:y.call(v,E,null,t,this.state.context,r)),Ae(this,Vr,ri).call(this,{type:"success",data:E}),E}catch(E){try{await((m=(g=M(this,rn).config).onError)==null?void 0:m.call(g,E,t,this.state.context,this,r))}catch(T){Promise.reject(T)}try{await((x=(b=this.options).onError)==null?void 0:x.call(b,E,t,this.state.context,r))}catch(T){Promise.reject(T)}try{await((S=(w=M(this,rn).config).onSettled)==null?void 0:S.call(w,void 0,E,this.state.variables,this.state.context,this,r))}catch(T){Promise.reject(T)}try{await((O=(j=this.options).onSettled)==null?void 0:O.call(j,void 0,E,t,this.state.context,r))}catch(T){Promise.reject(T)}throw Ae(this,Vr,ri).call(this,{type:"error",error:E}),E}finally{M(this,rn).runNext(this)}}},nd=new WeakMap,qr=new WeakMap,rn=new WeakMap,$s=new WeakMap,Vr=new WeakSet,ri=function(t){const n=r=>{switch(t.type){case"failed":return{...r,failureCount:t.failureCount,failureReason:t.error};case"pause":return{...r,isPaused:!0};case"continue":return{...r,isPaused:!1};case"pending":return{...r,context:t.context,data:void 0,failureCount:0,failureReason:null,error:null,isPaused:t.isPaused,status:"pending",variables:t.variables,submittedAt:Date.now()};case"success":return{...r,data:t.data,failureCount:0,failureReason:null,error:null,status:"success",isPaused:!1};case"error":return{...r,data:void 0,error:t.error,failureCount:r.failureCount+1,failureReason:t.error,isPaused:!1,status:"error"}}};this.state=n(this.state),Wt.batch(()=>{M(this,qr).forEach(r=>{r.onMutationUpdate(t)}),M(this,rn).notify({mutation:this,type:"updated",action:t})})},PC);function r8(){return{context:void 0,data:void 0,error:null,failureCount:0,failureReason:null,isPaused:!1,status:"idle",variables:void 0,submittedAt:0}}var wa,Tr,rd,MC,a8=(MC=class extends yd{constructor(t={}){super();ce(this,wa);ce(this,Tr);ce(this,rd);this.config=t,Z(this,wa,new Set),Z(this,Tr,new Map),Z(this,rd,0)}build(t,n,r){const a=new n8({client:t,mutationCache:this,mutationId:++zd(this,rd)._,options:t.defaultMutationOptions(n),state:r});return this.add(a),a}add(t){M(this,wa).add(t);const n=nh(t);if(typeof n=="string"){const r=M(this,Tr).get(n);r?r.push(t):M(this,Tr).set(n,[t])}this.notify({type:"added",mutation:t})}remove(t){if(M(this,wa).delete(t)){const n=nh(t);if(typeof n=="string"){const r=M(this,Tr).get(n);if(r)if(r.length>1){const a=r.indexOf(t);a!==-1&&r.splice(a,1)}else r[0]===t&&M(this,Tr).delete(n)}}this.notify({type:"removed",mutation:t})}canRun(t){const n=nh(t);if(typeof n=="string"){const r=M(this,Tr).get(n),a=r==null?void 0:r.find(i=>i.state.status==="pending");return!a||a===t}else return!0}runNext(t){var r;const n=nh(t);if(typeof n=="string"){const a=(r=M(this,Tr).get(n))==null?void 0:r.find(i=>i!==t&&i.state.isPaused);return(a==null?void 0:a.continue())??Promise.resolve()}else return Promise.resolve()}clear(){Wt.batch(()=>{M(this,wa).forEach(t=>{this.notify({type:"removed",mutation:t})}),M(this,wa).clear(),M(this,Tr).clear()})}getAll(){return Array.from(M(this,wa))}find(t){const n={exact:!0,...t};return this.getAll().find(r=>g2(n,r))}findAll(t={}){return this.getAll().filter(n=>g2(t,n))}notify(t){Wt.batch(()=>{this.listeners.forEach(n=>{n(t)})})}resumePausedMutations(){const t=this.getAll().filter(n=>n.state.isPaused);return Wt.batch(()=>Promise.all(t.map(n=>n.continue().catch(On))))}},wa=new WeakMap,Tr=new WeakMap,rd=new WeakMap,MC);function nh(e){var t;return(t=e.options.scope)==null?void 0:t.id}var Hr,RC,i8=(RC=class extends yd{constructor(t={}){super();ce(this,Hr);this.config=t,Z(this,Hr,new Map)}build(t,n,r){const a=n.queryKey,i=n.queryHash??OS(a,n);let s=this.get(i);return s||(s=new ZB({client:t,queryKey:a,queryHash:i,options:t.defaultQueryOptions(n),state:r,defaultOptions:t.getQueryDefaults(a)}),this.add(s)),s}add(t){M(this,Hr).has(t.queryHash)||(M(this,Hr).set(t.queryHash,t),this.notify({type:"added",query:t}))}remove(t){const n=M(this,Hr).get(t.queryHash);n&&(t.destroy(),n===t&&M(this,Hr).delete(t.queryHash),this.notify({type:"removed",query:t}))}clear(){Wt.batch(()=>{this.getAll().forEach(t=>{this.remove(t)})})}get(t){return M(this,Hr).get(t)}getAll(){return[...M(this,Hr).values()]}find(t){const n={exact:!0,...t};return this.getAll().find(r=>v2(n,r))}findAll(t={}){const n=this.getAll();return Object.keys(t).length>0?n.filter(r=>v2(t,r)):n}notify(t){Wt.batch(()=>{this.listeners.forEach(n=>{n(t)})})}onFocus(){Wt.batch(()=>{this.getAll().forEach(t=>{t.onFocus()})})}onOnline(){Wt.batch(()=>{this.getAll().forEach(t=>{t.onOnline()})})}},Hr=new WeakMap,RC),mt,xi,Si,xl,Sl,wi,wl,jl,DC,s8=(DC=class{constructor(e={}){ce(this,mt);ce(this,xi);ce(this,Si);ce(this,xl);ce(this,Sl);ce(this,wi);ce(this,wl);ce(this,jl);Z(this,mt,e.queryCache||new i8),Z(this,xi,e.mutationCache||new a8),Z(this,Si,e.defaultOptions||{}),Z(this,xl,new Map),Z(this,Sl,new Map),Z(this,wi,0)}mount(){zd(this,wi)._++,M(this,wi)===1&&(Z(this,wl,AS.subscribe(async e=>{e&&(await this.resumePausedMutations(),M(this,mt).onFocus())})),Z(this,jl,Mp.subscribe(async e=>{e&&(await this.resumePausedMutations(),M(this,mt).onOnline())})))}unmount(){var e,t;zd(this,wi)._--,M(this,wi)===0&&((e=M(this,wl))==null||e.call(this),Z(this,wl,void 0),(t=M(this,jl))==null||t.call(this),Z(this,jl,void 0))}isFetching(e){return M(this,mt).findAll({...e,fetchStatus:"fetching"}).length}isMutating(e){return M(this,xi).findAll({...e,status:"pending"}).length}getQueryData(e){var n;const t=this.defaultQueryOptions({queryKey:e});return(n=M(this,mt).get(t.queryHash))==null?void 0:n.state.data}ensureQueryData(e){const t=this.defaultQueryOptions(e),n=M(this,mt).build(this,t),r=n.state.data;return r===void 0?this.fetchQuery(e):(e.revalidateIfStale&&n.isStaleByTime(Bi(t.staleTime,n))&&this.prefetchQuery(t),Promise.resolve(r))}getQueriesData(e){return M(this,mt).findAll(e).map(({queryKey:t,state:n})=>{const r=n.data;return[t,r]})}setQueryData(e,t,n){const r=this.defaultQueryOptions({queryKey:e}),a=M(this,mt).get(r.queryHash),i=a==null?void 0:a.state.data,s=IB(t,i);if(s!==void 0)return M(this,mt).build(this,r).setData(s,{...n,manual:!0})}setQueriesData(e,t,n){return Wt.batch(()=>M(this,mt).findAll(e).map(({queryKey:r})=>[r,this.setQueryData(r,t,n)]))}getQueryState(e){var n;const t=this.defaultQueryOptions({queryKey:e});return(n=M(this,mt).get(t.queryHash))==null?void 0:n.state}removeQueries(e){const t=M(this,mt);Wt.batch(()=>{t.findAll(e).forEach(n=>{t.remove(n)})})}resetQueries(e,t){const n=M(this,mt);return Wt.batch(()=>(n.findAll(e).forEach(r=>{r.reset()}),this.refetchQueries({type:"active",...e},t)))}cancelQueries(e,t={}){const n={revert:!0,...t},r=Wt.batch(()=>M(this,mt).findAll(e).map(a=>a.cancel(n)));return Promise.all(r).then(On).catch(On)}invalidateQueries(e,t={}){return Wt.batch(()=>(M(this,mt).findAll(e).forEach(n=>{n.invalidate()}),(e==null?void 0:e.refetchType)==="none"?Promise.resolve():this.refetchQueries({...e,type:(e==null?void 0:e.refetchType)??(e==null?void 0:e.type)??"active"},t)))}refetchQueries(e,t={}){const n={...t,cancelRefetch:t.cancelRefetch??!0},r=Wt.batch(()=>M(this,mt).findAll(e).filter(a=>!a.isDisabled()&&!a.isStatic()).map(a=>{let i=a.fetch(void 0,n);return n.throwOnError||(i=i.catch(On)),a.state.fetchStatus==="paused"?Promise.resolve():i}));return Promise.all(r).then(On)}fetchQuery(e){const t=this.defaultQueryOptions(e);t.retry===void 0&&(t.retry=!1);const n=M(this,mt).build(this,t);return n.isStaleByTime(Bi(t.staleTime,n))?n.fetch(t):Promise.resolve(n.state.data)}prefetchQuery(e){return this.fetchQuery(e).then(On).catch(On)}fetchInfiniteQuery(e){return e._type="infinite",this.fetchQuery(e)}prefetchInfiniteQuery(e){return this.fetchInfiniteQuery(e).then(On).catch(On)}ensureInfiniteQueryData(e){return e._type="infinite",this.ensureQueryData(e)}resumePausedMutations(){return Mp.isOnline()?M(this,xi).resumePausedMutations():Promise.resolve()}getQueryCache(){return M(this,mt)}getMutationCache(){return M(this,xi)}getDefaultOptions(){return M(this,Si)}setDefaultOptions(e){Z(this,Si,e)}setQueryDefaults(e,t){M(this,xl).set(sf(e),{queryKey:e,defaultOptions:t})}getQueryDefaults(e){const t=[...M(this,xl).values()],n={};return t.forEach(r=>{of(e,r.queryKey)&&Object.assign(n,r.defaultOptions)}),n}setMutationDefaults(e,t){M(this,Sl).set(sf(e),{mutationKey:e,defaultOptions:t})}getMutationDefaults(e){const t=[...M(this,Sl).values()],n={};return t.forEach(r=>{of(e,r.mutationKey)&&Object.assign(n,r.defaultOptions)}),n}defaultQueryOptions(e){if(e._defaulted)return e;const t={...M(this,Si).queries,...this.getQueryDefaults(e.queryKey),...e,_defaulted:!0};return t.queryHash||(t.queryHash=OS(t.queryKey,t)),t.refetchOnReconnect===void 0&&(t.refetchOnReconnect=t.networkMode!=="always"),t.throwOnError===void 0&&(t.throwOnError=!!t.suspense),!t.networkMode&&t.persister&&(t.networkMode="offlineFirst"),t.queryFn===ES&&(t.enabled=!1),t}defaultMutationOptions(e){return e!=null&&e._defaulted?e:{...M(this,Si).mutations,...(e==null?void 0:e.mutationKey)&&this.getMutationDefaults(e.mutationKey),...e,_defaulted:!0}}clear(){M(this,mt).clear(),M(this,xi).clear()}},mt=new WeakMap,xi=new WeakMap,Si=new WeakMap,xl=new WeakMap,Sl=new WeakMap,wi=new WeakMap,wl=new WeakMap,jl=new WeakMap,DC),GR=A.createContext(void 0),nn=e=>{const t=A.useContext(GR);if(!t)throw new Error("No QueryClient set, use QueryClientProvider to set one");return t},o8=({client:e,children:t})=>(A.useEffect(()=>(e.mount(),()=>{e.unmount()}),[e]),c.jsx(GR.Provider,{value:e,children:t})),YR=A.createContext(!1),l8=()=>A.useContext(YR);YR.Provider;function u8(){let e=!1;return{clearReset:()=>{e=!1},reset:()=>{e=!0},isReset:()=>e}}var c8=A.createContext(u8()),f8=()=>A.useContext(c8),d8=(e,t,n)=>{const r=n!=null&&n.state.error&&typeof e.throwOnError=="function"?IR(e.throwOnError,[n.state.error,n]):e.throwOnError;(e.suspense||e.experimental_prefetchInRender||r)&&(t.isReset()||(e.retryOnMount=!1))},h8=e=>{A.useEffect(()=>{e.clearReset()},[e])},p8=({result:e,errorResetBoundary:t,throwOnError:n,query:r,suspense:a})=>e.isError&&!t.isReset()&&!e.isFetching&&r&&(a&&e.data===void 0||IR(n,[e.error,r])),m8=e=>{if(e.suspense){const n=a=>a==="static"?a:Math.max(a??1e3,1e3),r=e.staleTime;e.staleTime=typeof r=="function"?(...a)=>n(r(...a)):n(r),typeof e.gcTime=="number"&&(e.gcTime=Math.max(e.gcTime,1e3))}},y8=(e,t)=>e.isLoading&&e.isFetching&&!t,v8=(e,t)=>(e==null?void 0:e.suspense)&&t.isPending,E2=(e,t,n)=>t.fetchOptimistic(e).catch(()=>{n.clearReset()});function g8(e,t,n){var p,v,y,g;const r=l8(),a=f8(),i=nn(),s=i.defaultQueryOptions(e);(v=(p=i.getDefaultOptions().queries)==null?void 0:p._experimental_beforeQuery)==null||v.call(p,s);const o=i.getQueryCache().get(s.queryHash),l=e.subscribed!==!1;s._optimisticResults=r?"isRestoring":l?"optimistic":void 0,m8(s),d8(s,a,o),h8(a);const u=!i.getQueryCache().get(s.queryHash),[f]=A.useState(()=>new t(i,s)),d=f.getOptimisticResult(s),h=!r&&l;if(A.useSyncExternalStore(A.useCallback(m=>{const b=h?f.subscribe(Wt.batchCalls(m)):On;return f.updateResult(),b},[f,h]),()=>f.getCurrentResult(),()=>f.getCurrentResult()),A.useEffect(()=>{f.setOptions(s)},[s,f]),v8(s,d))throw E2(s,f,a);if(p8({result:d,errorResetBoundary:a,throwOnError:s.throwOnError,query:o,suspense:s.suspense}))throw d.error;if((g=(y=i.getDefaultOptions().queries)==null?void 0:y._experimental_afterQuery)==null||g.call(y,s,d),s.experimental_prefetchInRender&&!lf.isServer()&&y8(d,r)){const m=u?E2(s,f,a):o==null?void 0:o.promise;m==null||m.catch(On).finally(()=>{f.updateResult()})}return s.notifyOnChangeProps?d:f.trackResult(d)}function oe(e,t){return g8(e,JB)}/** + * react-router v7.17.0 + * + * Copyright (c) Remix Software Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE.md file in the root directory of this source tree. + * + * @license MIT + */var T2="popstate";function N2(e){return typeof e=="object"&&e!=null&&"pathname"in e&&"search"in e&&"hash"in e&&"state"in e&&"key"in e}function b8(e={}){function t(r,a){var u;let i=(u=a.state)==null?void 0:u.masked,{pathname:s,search:o,hash:l}=i||r.location;return S0("",{pathname:s,search:o,hash:l},a.state&&a.state.usr||null,a.state&&a.state.key||"default",i?{pathname:r.location.pathname,search:r.location.search,hash:r.location.hash}:void 0)}function n(r,a){return typeof a=="string"?a:uf(a)}return S8(t,n,null,e)}function ot(e,t){if(e===!1||e===null||typeof e>"u")throw new Error(t)}function vr(e,t){if(!e){typeof console<"u"&&console.warn(t);try{throw new Error(t)}catch{}}}function x8(){return Math.random().toString(36).substring(2,10)}function _2(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function S0(e,t,n=null,r,a){return{pathname:typeof e=="string"?e:e.pathname,search:"",hash:"",...typeof t=="string"?xu(t):t,state:n,key:t&&t.key||r||x8(),mask:a}}function uf({pathname:e="/",search:t="",hash:n=""}){return t&&t!=="?"&&(e+=t.charAt(0)==="?"?t:"?"+t),n&&n!=="#"&&(e+=n.charAt(0)==="#"?n:"#"+n),e}function xu(e){let t={};if(e){let n=e.indexOf("#");n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf("?");r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function S8(e,t,n,r={}){let{window:a=document.defaultView,v5Compat:i=!1}=r,s=a.history,o="POP",l=null,u=f();u==null&&(u=0,s.replaceState({...s.state,idx:u},""));function f(){return(s.state||{idx:null}).idx}function d(){o="POP";let g=f(),m=g==null?null:g-u;u=g,l&&l({action:o,location:y.location,delta:m})}function h(g,m){o="PUSH";let b=N2(g)?g:S0(y.location,g,m);u=f()+1;let x=_2(b,u),w=y.createHref(b.mask||b);try{s.pushState(x,"",w)}catch(S){if(S instanceof DOMException&&S.name==="DataCloneError")throw S;a.location.assign(w)}i&&l&&l({action:o,location:y.location,delta:1})}function p(g,m){o="REPLACE";let b=N2(g)?g:S0(y.location,g,m);u=f();let x=_2(b,u),w=y.createHref(b.mask||b);s.replaceState(x,"",w),i&&l&&l({action:o,location:y.location,delta:0})}function v(g){return w8(a,g)}let y={get action(){return o},get location(){return e(a,s)},listen(g){if(l)throw new Error("A history only accepts one active listener");return a.addEventListener(T2,d),l=g,()=>{a.removeEventListener(T2,d),l=null}},createHref(g){return t(a,g)},createURL:v,encodeLocation(g){let m=v(g);return{pathname:m.pathname,search:m.search,hash:m.hash}},push:h,replace:p,go(g){return s.go(g)}};return y}function w8(e,t,n=!1){let r="http://localhost";e&&(r=e.location.origin!=="null"?e.location.origin:e.location.href),ot(r,"No window.location.(origin|href) available to create URL");let a=typeof t=="string"?t:uf(t);return a=a.replace(/ $/,"%20"),!n&&a.startsWith("//")&&(a=r+a),new URL(a,r)}function XR(e,t,n="/"){return j8(e,t,n,!1)}function j8(e,t,n,r,a){let i=typeof t=="string"?xu(t):t,s=Ha(i.pathname||"/",n);if(s==null)return null;let o=A8(e),l=null,u=k8(s);for(let f=0;l==null&&f{let f={relativePath:u===void 0?s.path||"":u,caseSensitive:s.caseSensitive===!0,childrenIndex:o,route:s};if(f.relativePath.startsWith("/")){if(!f.relativePath.startsWith(r)&&l)return;ot(f.relativePath.startsWith(r),`Absolute route path "${f.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),f.relativePath=f.relativePath.slice(r.length)}let d=Mr([r,f.relativePath]),h=n.concat(f);s.children&&s.children.length>0&&(ot(s.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${d}".`),WR(s.children,t,h,d,l)),!(s.path==null&&!s.index)&&t.push({path:d,score:M8(d,s.index),routesMeta:h})};return e.forEach((s,o)=>{var l;if(s.path===""||!((l=s.path)!=null&&l.includes("?")))i(s,o);else for(let u of QR(s.path))i(s,o,!0,u)}),t}function QR(e){let t=e.split("/");if(t.length===0)return[];let[n,...r]=t,a=n.endsWith("?"),i=n.replace(/\?$/,"");if(r.length===0)return a?[i,""]:[i];let s=QR(r.join("/")),o=[];return o.push(...s.map(l=>l===""?i:[i,l].join("/"))),a&&o.push(...s),o.map(l=>e.startsWith("/")&&l===""?"/":l)}function O8(e){e.sort((t,n)=>t.score!==n.score?n.score-t.score:R8(t.routesMeta.map(r=>r.childrenIndex),n.routesMeta.map(r=>r.childrenIndex)))}var E8=/^:[\w-]+$/,T8=3,N8=2,_8=1,C8=10,P8=-2,C2=e=>e==="*";function M8(e,t){let n=e.split("/"),r=n.length;return n.some(C2)&&(r+=P8),t&&(r+=N8),n.filter(a=>!C2(a)).reduce((a,i)=>a+(E8.test(i)?T8:i===""?_8:C8),r)}function R8(e,t){return e.length===t.length&&e.slice(0,-1).every((r,a)=>r===t[a])?e[e.length-1]-t[t.length-1]:0}function D8(e,t,n=!1){let{routesMeta:r}=e,a={},i="/",s=[];for(let o=0;o{if(f==="*"){let v=o[h]||"";s=i.slice(0,i.length-v.length).replace(/(.)\/+$/,"$1")}const p=o[h];return d&&!p?u[f]=void 0:u[f]=(p||"").replace(/%2F/g,"/"),u},{}),pathname:i,pathnameBase:s,pattern:e}}function $8(e,t=!1,n=!0){vr(e==="*"||!e.endsWith("*")||e.endsWith("/*"),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,"/*")}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,"/*")}".`);let r=[],a="^"+e.replace(/\/*\*?$/,"").replace(/^\/*/,"/").replace(/[\\.*+^${}|()[\]]/g,"\\$&").replace(/\/:([\w-]+)(\?)?/g,(s,o,l,u,f)=>{if(r.push({paramName:o,isOptional:l!=null}),l){let d=f.charAt(u+s.length);return d&&d!=="/"?"/([^\\/]*)":"(?:/([^\\/]*))?"}return"/([^\\/]+)"}).replace(/\/([\w-]+)\?(\/|$)/g,"(/$1)?$2");return e.endsWith("*")?(r.push({paramName:"*"}),a+=e==="*"||e==="/*"?"(.*)$":"(?:\\/(.+)|\\/*)$"):n?a+="\\/*$":e!==""&&e!=="/"&&(a+="(?:(?=\\/|$))"),[new RegExp(a,t?void 0:"i"),r]}function k8(e){try{return e.split("/").map(t=>decodeURIComponent(t).replace(/\//g,"%2F")).join("/")}catch(t){return vr(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function Ha(e,t){if(t==="/")return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith("/")?t.length-1:t.length,r=e.charAt(n);return r&&r!=="/"?null:e.slice(n)||"/"}var L8=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function z8(e,t="/"){let{pathname:n,search:r="",hash:a=""}=typeof e=="string"?xu(e):e,i;return n?(n=ZR(n),n.startsWith("/")?i=P2(n.substring(1),"/"):i=P2(n,t)):i=t,{pathname:i,search:U8(r),hash:q8(a)}}function P2(e,t){let n=Dp(t).split("/");return e.split("/").forEach(a=>{a===".."?n.length>1&&n.pop():a!=="."&&n.push(a)}),n.length>1?n.join("/"):"/"}function fg(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function B8(e){return e.filter((t,n)=>n===0||t.route.path&&t.route.path.length>0)}function NS(e){let t=B8(e);return t.map((n,r)=>r===t.length-1?n.pathname:n.pathnameBase)}function vy(e,t,n,r=!1){let a;typeof e=="string"?a=xu(e):(a={...e},ot(!a.pathname||!a.pathname.includes("?"),fg("?","pathname","search",a)),ot(!a.pathname||!a.pathname.includes("#"),fg("#","pathname","hash",a)),ot(!a.search||!a.search.includes("#"),fg("#","search","hash",a)));let i=e===""||a.pathname==="",s=i?"/":a.pathname,o;if(s==null)o=n;else{let d=t.length-1;if(!r&&s.startsWith("..")){let h=s.split("/");for(;h[0]==="..";)h.shift(),d-=1;a.pathname=h.join("/")}o=d>=0?t[d]:"/"}let l=z8(a,o),u=s&&s!=="/"&&s.endsWith("/"),f=(i||s===".")&&n.endsWith("/");return!l.pathname.endsWith("/")&&(u||f)&&(l.pathname+="/"),l}var ZR=e=>e.replace(/\/\/+/g,"/"),Mr=e=>ZR(e.join("/")),Dp=e=>e.replace(/\/+$/,""),I8=e=>Dp(e).replace(/^\/*/,"/"),U8=e=>!e||e==="?"?"":e.startsWith("?")?e:"?"+e,q8=e=>!e||e==="#"?"":e.startsWith("#")?e:"#"+e,V8=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||"",this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function H8(e){return e!=null&&typeof e.status=="number"&&typeof e.statusText=="string"&&typeof e.internal=="boolean"&&"data"in e}function F8(e){let t=e.map(n=>n.route.path).filter(Boolean);return Mr(t)||"/"}var JR=typeof window<"u"&&typeof window.document<"u"&&typeof window.document.createElement<"u";function eD(e,t){let n=e;if(typeof n!="string"||!L8.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,a=!1;if(JR)try{let i=new URL(window.location.href),s=n.startsWith("//")?new URL(i.protocol+n):new URL(n),o=Ha(s.pathname,t);s.origin===i.origin&&o!=null?n=o+s.search+s.hash:a=!0}catch{vr(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:a,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join("\0");var tD=["POST","PUT","PATCH","DELETE"];new Set(tD);var K8=["GET",...tD];new Set(K8);var Su=A.createContext(null);Su.displayName="DataRouter";var gy=A.createContext(null);gy.displayName="DataRouterState";var nD=A.createContext(!1);function G8(){return A.useContext(nD)}var rD=A.createContext({isTransitioning:!1});rD.displayName="ViewTransition";var Y8=A.createContext(new Map);Y8.displayName="Fetchers";var X8=A.createContext(null);X8.displayName="Await";var Zn=A.createContext(null);Zn.displayName="Navigation";var vd=A.createContext(null);vd.displayName="Location";var xr=A.createContext({outlet:null,matches:[],isDataRoute:!1});xr.displayName="Route";var _S=A.createContext(null);_S.displayName="RouteError";var aD="REACT_ROUTER_ERROR",W8="REDIRECT",Q8="ROUTE_ERROR_RESPONSE";function Z8(e){if(e.startsWith(`${aD}:${W8}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string"&&typeof t.location=="string"&&typeof t.reloadDocument=="boolean"&&typeof t.replace=="boolean")return t}catch{}}function J8(e){if(e.startsWith(`${aD}:${Q8}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t=="object"&&t&&typeof t.status=="number"&&typeof t.statusText=="string")return new V8(t.status,t.statusText,t.data)}catch{}}function eI(e,{relative:t}={}){ot(wu(),"useHref() may be used only in the context of a component.");let{basename:n,navigator:r}=A.useContext(Zn),{hash:a,pathname:i,search:s}=gd(e,{relative:t}),o=i;return n!=="/"&&(o=i==="/"?n:Mr([n,i])),r.createHref({pathname:o,search:s,hash:a})}function wu(){return A.useContext(vd)!=null}function Sr(){return ot(wu(),"useLocation() may be used only in the context of a component."),A.useContext(vd).location}var iD="You should call navigate() in a React.useEffect(), not when your component is first rendered.";function sD(e){A.useContext(Zn).static||A.useLayoutEffect(e)}function Kt(){let{isDataRoute:e}=A.useContext(xr);return e?mI():tI()}function tI(){ot(wu(),"useNavigate() may be used only in the context of a component.");let e=A.useContext(Su),{basename:t,navigator:n}=A.useContext(Zn),{matches:r}=A.useContext(xr),{pathname:a}=Sr(),i=JSON.stringify(NS(r)),s=A.useRef(!1);return sD(()=>{s.current=!0}),A.useCallback((l,u={})=>{if(vr(s.current,iD),!s.current)return;if(typeof l=="number"){n.go(l);return}let f=vy(l,JSON.parse(i),a,u.relative==="path");e==null&&t!=="/"&&(f.pathname=f.pathname==="/"?t:Mr([t,f.pathname])),(u.replace?n.replace:n.push)(f,u.state,u)},[t,n,i,a,e])}var nI=A.createContext(null);function rI(e){let t=A.useContext(xr).outlet;return A.useMemo(()=>t&&A.createElement(nI.Provider,{value:e},t),[t,e])}function oD(){let{matches:e}=A.useContext(xr),t=e[e.length-1];return(t==null?void 0:t.params)??{}}function gd(e,{relative:t}={}){let{matches:n}=A.useContext(xr),{pathname:r}=Sr(),a=JSON.stringify(NS(n));return A.useMemo(()=>vy(e,JSON.parse(a),r,t==="path"),[e,a,r,t])}function aI(e,t){return lD(e,t)}function lD(e,t,n){var g;ot(wu(),"useRoutes() may be used only in the context of a component.");let{navigator:r}=A.useContext(Zn),{matches:a}=A.useContext(xr),i=a[a.length-1],s=i?i.params:{},o=i?i.pathname:"/",l=i?i.pathnameBase:"/",u=i&&i.route;{let m=u&&u.path||"";cD(o,!u||m.endsWith("*")||m.endsWith("*?"),`You rendered descendant (or called \`useRoutes()\`) at "${o}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. + +Please change the parent to .`)}let f=Sr(),d;if(t){let m=typeof t=="string"?xu(t):t;ot(l==="/"||((g=m.pathname)==null?void 0:g.startsWith(l)),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${l}" but pathname "${m.pathname}" was given in the \`location\` prop.`),d=m}else d=f;let h=d.pathname||"/",p=h;if(l!=="/"){let m=l.replace(/^\//,"").split("/");p="/"+h.replace(/^\//,"").split("/").slice(m.length).join("/")}let v=n&&n.state.matches.length?n.state.matches.map(m=>Object.assign(m,{route:n.manifest[m.route.id]||m.route})):XR(e,{pathname:p});vr(u||v!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),vr(v==null||v[v.length-1].route.element!==void 0||v[v.length-1].route.Component!==void 0||v[v.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let y=uI(v&&v.map(m=>Object.assign({},m,{params:Object.assign({},s,m.params),pathname:Mr([l,r.encodeLocation?r.encodeLocation(m.pathname.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:m.pathname]),pathnameBase:m.pathnameBase==="/"?l:Mr([l,r.encodeLocation?r.encodeLocation(m.pathnameBase.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:m.pathnameBase])})),a,n);return t&&y?A.createElement(vd.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",mask:void 0,...d},navigationType:"POP"}},y):y}function iI(){let e=pI(),t=H8(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r="rgba(200,200,200, 0.5)",a={padding:"0.5rem",backgroundColor:r},i={padding:"2px 4px",backgroundColor:r},s=null;return console.error("Error handled by React Router default ErrorBoundary:",e),s=A.createElement(A.Fragment,null,A.createElement("p",null,"💿 Hey developer 👋"),A.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",A.createElement("code",{style:i},"ErrorBoundary")," or"," ",A.createElement("code",{style:i},"errorElement")," prop on your route.")),A.createElement(A.Fragment,null,A.createElement("h2",null,"Unexpected Application Error!"),A.createElement("h3",{style:{fontStyle:"italic"}},t),n?A.createElement("pre",{style:a},n):null,s)}var sI=A.createElement(iI,null),uD=class extends A.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!=="idle"&&e.revalidation==="idle"?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error!==void 0?e.error:t.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error("React Router caught the following error during render",e)}render(){let e=this.state.error;if(this.context&&typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){const n=J8(e.digest);n&&(e=n)}let t=e!==void 0?A.createElement(xr.Provider,{value:this.props.routeContext},A.createElement(_S.Provider,{value:e,children:this.props.component})):this.props.children;return this.context?A.createElement(oI,{error:e},t):t}};uD.contextType=nD;var dg=new WeakMap;function oI({children:e,error:t}){let{basename:n}=A.useContext(Zn);if(typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){let r=Z8(t.digest);if(r){let a=dg.get(t);if(a)throw a;let i=eD(r.location,n);if(JR&&!dg.get(t))if(i.isExternal||r.reloadDocument)window.location.href=i.absoluteURL||i.to;else{const s=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:r.replace}));throw dg.set(t,s),s}return A.createElement("meta",{httpEquiv:"refresh",content:`0;url=${i.absoluteURL||i.to}`})}}return e}function lI({routeContext:e,match:t,children:n}){let r=A.useContext(Su);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),A.createElement(xr.Provider,{value:e},n)}function uI(e,t=[],n){let r=n==null?void 0:n.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let a=e,i=r==null?void 0:r.errors;if(i!=null){let f=a.findIndex(d=>d.route.id&&(i==null?void 0:i[d.route.id])!==void 0);ot(f>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(i).join(",")}`),a=a.slice(0,Math.min(a.length,f+1))}let s=!1,o=-1;if(n&&r){s=r.renderFallback;for(let f=0;f=0?a=a.slice(0,o+1):a=[a[0]];break}}}}let l=n==null?void 0:n.onError,u=r&&l?(f,d)=>{var h,p;l(f,{location:r.location,params:((p=(h=r.matches)==null?void 0:h[0])==null?void 0:p.params)??{},pattern:F8(r.matches),errorInfo:d})}:void 0;return a.reduceRight((f,d,h)=>{let p,v=!1,y=null,g=null;r&&(p=i&&d.route.id?i[d.route.id]:void 0,y=d.route.errorElement||sI,s&&(o<0&&h===0?(cD("route-fallback",!1,"No `HydrateFallback` element provided to render during initial hydration"),v=!0,g=null):o===h&&(v=!0,g=d.route.hydrateFallbackElement||null)));let m=t.concat(a.slice(0,h+1)),b=()=>{let x;return p?x=y:v?x=g:d.route.Component?x=A.createElement(d.route.Component,null):d.route.element?x=d.route.element:x=f,A.createElement(lI,{match:d,routeContext:{outlet:f,matches:m,isDataRoute:r!=null},children:x})};return r&&(d.route.ErrorBoundary||d.route.errorElement||h===0)?A.createElement(uD,{location:r.location,revalidation:r.revalidation,component:y,error:p,children:b(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:u}):b()},null)}function CS(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function cI(e){let t=A.useContext(Su);return ot(t,CS(e)),t}function fI(e){let t=A.useContext(gy);return ot(t,CS(e)),t}function dI(e){let t=A.useContext(xr);return ot(t,CS(e)),t}function PS(e){let t=dI(e),n=t.matches[t.matches.length-1];return ot(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function hI(){return PS("useRouteId")}function pI(){var r;let e=A.useContext(_S),t=fI("useRouteError"),n=PS("useRouteError");return e!==void 0?e:(r=t.errors)==null?void 0:r[n]}function mI(){let{router:e}=cI("useNavigate"),t=PS("useNavigate"),n=A.useRef(!1);return sD(()=>{n.current=!0}),A.useCallback(async(a,i={})=>{vr(n.current,iD),n.current&&(typeof a=="number"?await e.navigate(a):await e.navigate(a,{fromRouteId:t,...i}))},[e,t])}var M2={};function cD(e,t,n){!t&&!M2[e]&&(M2[e]=!0,vr(!1,n))}A.memo(yI);function yI({routes:e,manifest:t,future:n,state:r,isStatic:a,onError:i}){return lD(e,void 0,{manifest:t,state:r,isStatic:a,onError:i})}function $p({to:e,replace:t,state:n,relative:r}){ot(wu()," may be used only in the context of a component.");let{static:a}=A.useContext(Zn);vr(!a," must not be used on the initial render in a . This is a no-op, but you should modify your code so the is only ever rendered in response to some user interaction or state change.");let{matches:i}=A.useContext(xr),{pathname:s}=Sr(),o=Kt(),l=vy(e,NS(i),s,r==="path"),u=JSON.stringify(l);return A.useEffect(()=>{o(JSON.parse(u),{replace:t,state:n,relative:r})},[o,u,r,t,n]),null}function fD(e){return rI(e.context)}function xe(e){ot(!1,"A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .")}function vI({basename:e="/",children:t=null,location:n,navigationType:r="POP",navigator:a,static:i=!1,useTransitions:s}){ot(!wu(),"You cannot render a inside another . You should never have more than one in your app.");let o=e.replace(/^\/*/,"/"),l=A.useMemo(()=>({basename:o,navigator:a,static:i,useTransitions:s,future:{}}),[o,a,i,s]);typeof n=="string"&&(n=xu(n));let{pathname:u="/",search:f="",hash:d="",state:h=null,key:p="default",mask:v}=n,y=A.useMemo(()=>{let g=Ha(u,o);return g==null?null:{location:{pathname:g,search:f,hash:d,state:h,key:p,mask:v},navigationType:r}},[o,u,f,d,h,p,r,v]);return vr(y!=null,` is not able to match the URL "${u}${f}${d}" because it does not start with the basename, so the won't render anything.`),y==null?null:A.createElement(Zn.Provider,{value:l},A.createElement(vd.Provider,{children:t,value:y}))}function gI({children:e,location:t}){return aI(w0(e),t)}function w0(e,t=[]){let n=[];return A.Children.forEach(e,(r,a)=>{if(!A.isValidElement(r))return;let i=[...t,a];if(r.type===A.Fragment){n.push.apply(n,w0(r.props.children,i));return}ot(r.type===xe,`[${typeof r.type=="string"?r.type:r.type.name}] is not a component. All component children of must be a or `),ot(!r.props.index||!r.props.children,"An index route cannot have child routes.");let s={id:r.props.id||i.join("-"),caseSensitive:r.props.caseSensitive,element:r.props.element,Component:r.props.Component,index:r.props.index,path:r.props.path,middleware:r.props.middleware,loader:r.props.loader,action:r.props.action,hydrateFallbackElement:r.props.hydrateFallbackElement,HydrateFallback:r.props.HydrateFallback,errorElement:r.props.errorElement,ErrorBoundary:r.props.ErrorBoundary,hasErrorBoundary:r.props.hasErrorBoundary===!0||r.props.ErrorBoundary!=null||r.props.errorElement!=null,shouldRevalidate:r.props.shouldRevalidate,handle:r.props.handle,lazy:r.props.lazy};r.props.children&&(s.children=w0(r.props.children,i)),n.push(s)}),n}var Fh="get",Kh="application/x-www-form-urlencoded";function by(e){return typeof HTMLElement<"u"&&e instanceof HTMLElement}function bI(e){return by(e)&&e.tagName.toLowerCase()==="button"}function xI(e){return by(e)&&e.tagName.toLowerCase()==="form"}function SI(e){return by(e)&&e.tagName.toLowerCase()==="input"}function wI(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function jI(e,t){return e.button===0&&(!t||t==="_self")&&!wI(e)}function j0(e=""){return new URLSearchParams(typeof e=="string"||Array.isArray(e)||e instanceof URLSearchParams?e:Object.keys(e).reduce((t,n)=>{let r=e[n];return t.concat(Array.isArray(r)?r.map(a=>[n,a]):[[n,r]])},[]))}function AI(e,t){let n=j0(e);return t&&t.forEach((r,a)=>{n.has(a)||t.getAll(a).forEach(i=>{n.append(a,i)})}),n}var rh=null;function OI(){if(rh===null)try{new FormData(document.createElement("form"),0),rh=!1}catch{rh=!0}return rh}var EI=new Set(["application/x-www-form-urlencoded","multipart/form-data","text/plain"]);function hg(e){return e!=null&&!EI.has(e)?(vr(!1,`"${e}" is not a valid \`encType\` for \`

\`/\`\` and will default to "${Kh}"`),null):e}function TI(e,t){let n,r,a,i,s;if(xI(e)){let o=e.getAttribute("action");r=o?Ha(o,t):null,n=e.getAttribute("method")||Fh,a=hg(e.getAttribute("enctype"))||Kh,i=new FormData(e)}else if(bI(e)||SI(e)&&(e.type==="submit"||e.type==="image")){let o=e.form;if(o==null)throw new Error('Cannot submit a + +

매장/대표 관리자용 모바일앱입니다. QR을 스캔하면 설치 페이지로 이동합니다. 앱 업로드·버전 관리는 GUARDiA Manager에서 일원화됩니다(읽기전용).

+ + {loading &&
불러오는 중…
} + {err &&
{err}
} + + {!loading && !err && info && !info.has_version && ( +
+ + 아직 등록된 앱 버전이 없습니다.
GUARDiA Manager에서 APK를 업로드하면 여기에 QR이 표시됩니다. +
+ )} + + {!loading && !err && info?.has_version && ( +
+
+ {info.qr_url ? 관리자앱 QR : } +
+
+
{info.app_name || 'GUARDiA Mall Admin'}v{info.version}
+
{info.platform} {info.file_size_mb ? `· ${info.file_size_mb}MB` : ''}{info.download_count != null && ` · 다운로드 ${info.download_count}회`}
+ {info.release_notes && ( +
변경점
{info.release_notes}
+ )} +
+ {info.landing_url && 설치 페이지} + {info.download_url && APK 다운로드} + +
+
+
+ )} + + ) +} diff --git a/frontend/src/admin/AdminLayout.tsx b/frontend/src/admin/AdminLayout.tsx new file mode 100644 index 0000000..1c102c8 --- /dev/null +++ b/frontend/src/admin/AdminLayout.tsx @@ -0,0 +1,93 @@ +import { useEffect, useState } from 'react' +import { Outlet, Navigate, NavLink, useNavigate } from 'react-router-dom' +import { + LayoutDashboard, Store, Flower2, Boxes, ShoppingBag, Users, Crown, Megaphone, + Repeat, CalendarClock, BarChart3, UserCog, ScrollText, Settings, LogOut, UserCircle, ArrowLeftRight, Smartphone, +} from 'lucide-react' +import { getMe } from '../api/client' + +const links = [ + { to: '/admin/dashboard', label: '대시보드', icon: LayoutDashboard, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/stores', label: '매장 관리', icon: Store, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/products', label: '상품 관리', icon: Flower2, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/inventory', label: '매장별 재고', icon: Boxes, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/orders', label: '주문 (라이브)', icon: ShoppingBag, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/transfers', label: '재고 이양', icon: ArrowLeftRight, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/members', label: '회원 (CRM)', icon: Users, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/loyalty', label: '등급·포인트', icon: Crown, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/events', label: '이벤트·캠페인', icon: Megaphone, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/subscriptions', label: '구독', icon: Repeat, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/schedule', label: '스케줄·서지', icon: CalendarClock, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/analytics', label: '매출 분석 (BI)', icon: BarChart3, roles: ['ADMIN', 'MANAGER'] }, +] +const adminLinks = [ + { to: '/admin/users', label: '사용자/권한', icon: UserCog, roles: ['ADMIN'] }, + { to: '/admin/audit', label: '감사 로그', icon: ScrollText, roles: ['ADMIN', 'MANAGER'] }, + { to: '/admin/settings', label: '게이트웨이 설정', icon: Settings, roles: ['ADMIN'] }, + { to: '/admin/app', label: '관리자앱 설치', icon: Smartphone, roles: ['ADMIN', 'MANAGER'] }, +] +const linkClass = ({ isActive }: { isActive: boolean }) => + `flex items-center gap-3 px-5 py-2.5 text-sm transition-colors ${isActive ? 'bg-card text-brand border-r-2 border-brand' : 'text-slate-300 hover:bg-card/60'}` + +export default function AdminLayout() { + const token = localStorage.getItem('mall_token') + const [role, setRole] = useState(() => localStorage.getItem('mall_role') || '') + const [who, setWho] = useState(() => localStorage.getItem('mall_admin_user') || '') + const nav = useNavigate() + + useEffect(() => { + document.documentElement.classList.add('admin-shell') + return () => document.documentElement.classList.remove('admin-shell') + }, []) + + useEffect(() => { + if (!token) return + getMe().then((me: any) => { + if (me?.role) { localStorage.setItem('mall_role', me.role); setRole(me.role) } + if (me?.username) { localStorage.setItem('mall_admin_user', me.username); setWho(me.username) } + }).catch(() => {}) + }, [token]) + + if (!token) return + // USER 역할은 관리자 영역 접근 차단 + if (role && role === 'USER') return + + const visible = links.filter(l => !role || l.roles.includes(role)) + const visibleAdmin = adminLinks.filter(l => l.roles.includes(role)) + + const logout = () => { + localStorage.removeItem('mall_token'); localStorage.removeItem('mall_role'); localStorage.removeItem('mall_admin_user') + nav('/admin/login') + } + + return ( +
+ +
+
+
미국 다지점 꽃집 옴니채널 e-커머스 · AI 수요예측 · BI
+
+ {who || 'admin'} {role} + +
+
+
+ +
+
+
+ ) +} diff --git a/frontend/src/admin/AdminLogin.tsx b/frontend/src/admin/AdminLogin.tsx new file mode 100644 index 0000000..371b453 --- /dev/null +++ b/frontend/src/admin/AdminLogin.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { Flower2 } from 'lucide-react' +import { login, getMe } from '../api/client' + +export default function AdminLogin() { + const [username, setUsername] = useState('admin') + const [password, setPassword] = useState('') + const [err, setErr] = useState('') + const nav = useNavigate() + + useEffect(() => { + document.documentElement.classList.add('admin-shell') + return () => document.documentElement.classList.remove('admin-shell') + }, []) + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); setErr('') + try { + const res = await login(username, password) + const token = res.data?.data?.token + if (!token) throw new Error('no token') + localStorage.setItem('mall_token', token) + const me: any = await getMe().catch(() => null) + if (me?.role) localStorage.setItem('mall_role', me.role) + if (me?.username) localStorage.setItem('mall_admin_user', me.username) + if (me?.role === 'USER') { setErr('관리자 권한이 없습니다.'); localStorage.removeItem('mall_token'); return } + nav('/admin/dashboard') + } catch { + setErr('로그인 실패 — 아이디/비밀번호를 확인하세요.') + } + } + + return ( +
+ +
+ + GUARDiA Mall 콘솔 +
+

매장·대표 관리자 로그인

+ + setUsername(e.target.value)} + className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" /> + + setPassword(e.target.value)} + className="w-full mb-4 px-3 py-2 rounded-lg bg-card border border-edge text-sm focus:border-brand outline-none" /> + {err &&

{err}

} + +

고객 쇼핑몰은 여기

+ +
+ ) +} diff --git a/frontend/src/admin/Analytics.tsx b/frontend/src/admin/Analytics.tsx new file mode 100644 index 0000000..767af54 --- /dev/null +++ b/frontend/src/admin/Analytics.tsx @@ -0,0 +1,43 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { BarChart3 } from 'lucide-react' +import { getStoreSales, getSalesTrend, getTopProducts, getLoyaltyByTier } from '../api/client' +import Chart from '../components/Chart' +import { money } from '../store/shop' + +export default function Analytics() { + const [days, setDays] = useState(14) + const { data: storeSales } = useQuery({ queryKey: ['an-store', days], queryFn: () => getStoreSales(days) }) + const { data: trend } = useQuery({ queryKey: ['an-trend', days], queryFn: () => getSalesTrend(days) }) + const { data: top } = useQuery({ queryKey: ['an-top'], queryFn: () => getTopProducts(10) }) + const { data: byTier } = useQuery({ queryKey: ['an-tier'], queryFn: () => getLoyaltyByTier(days) }) + + const storeRows = (storeSales || []).map((s: any) => ({ name: s.storeCode || s.storeName, 매출: s.sales, 주문: s.orderCount })) + const trendRows = (trend || []).map((t: any) => ({ name: (t.date || '').slice(5), 매출: t.sales })) + const topRows = (top || []).map((p: any) => ({ name: p.name, 판매: p.salesCount })) + const tierRows = (byTier || []).map((t: any) => ({ name: t.tier, 매출: t.sales, 고객: t.customers })) + const totalSales = (storeSales || []).reduce((s: number, x: any) => s + (x.sales || 0), 0) + + return ( +
+
+

매출 분석 (BI)

+ +
+ +
+
기간 총매출
+
{money(totalSales)}
+
+ +
+

매출 추이

+

매장별 매출

+

등급별 매출 (BI)

+

베스트셀러 Top 10

+
+
+ ) +} diff --git a/frontend/src/admin/AuditLog.tsx b/frontend/src/admin/AuditLog.tsx new file mode 100644 index 0000000..183ebe3 --- /dev/null +++ b/frontend/src/admin/AuditLog.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { ScrollText, Search } from 'lucide-react' +import { getAuditLogs } from '../api/client' + +export default function AuditLog() { + const [action, setAction] = useState('') + const [actor, setActor] = useState('') + const { data: logs } = useQuery({ queryKey: ['audit', action, actor], queryFn: () => getAuditLogs(action, actor, 200) }) + + return ( +
+
+

감사 로그

+
+
setActor(e.target.value)} placeholder="행위자" className="bg-transparent text-sm outline-none w-28" />
+ setAction(e.target.value)} placeholder="액션 필터" className="bg-card border border-edge rounded-lg px-3 py-2 text-sm w-32" /> +
+
+
+ + + + + + {(logs || []).map((l: any, i: number) => ( + + + + + + + + ))} + {!(logs || []).length && } + +
시각행위자액션대상상세
{(l.createdAt || l.timestamp || '').slice(0, 19).replace('T', ' ')}{l.actor || l.username || '-'}{l.action}{l.target || l.resource || '-'}{l.detail || l.details || ''}
감사 로그가 없습니다.
+
+
+ ) +} diff --git a/frontend/src/admin/Dashboard.tsx b/frontend/src/admin/Dashboard.tsx new file mode 100644 index 0000000..76d0f72 --- /dev/null +++ b/frontend/src/admin/Dashboard.tsx @@ -0,0 +1,69 @@ +import { useQuery } from '@tanstack/react-query' +import { DollarSign, ShoppingBag, TrendingUp, Star } from 'lucide-react' +import { getAnalyticsDashboard, getStoreSales, getSalesTrend, getTopProducts } from '../api/client' +import Chart from '../components/Chart' +import { money } from '../store/shop' + +function Kpi({ icon: Icon, label, value, sub }: { icon: any; label: string; value: string; sub?: string }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ) +} + +export default function Dashboard() { + const { data: dash } = useQuery({ queryKey: ['adash'], queryFn: () => getAnalyticsDashboard(1, 500) }) + const { data: storeSales } = useQuery({ queryKey: ['store-sales'], queryFn: () => getStoreSales(7) }) + const { data: trend } = useQuery({ queryKey: ['trend'], queryFn: () => getSalesTrend(14) }) + const { data: top } = useQuery({ queryKey: ['top'], queryFn: () => getTopProducts(8) }) + + const today = dash?.today || {} + const storeRows = (storeSales || []).map((s: any) => ({ name: s.storeCode || s.storeName, 매출: s.sales, 주문: s.orderCount })) + const trendRows = (trend || []).map((t: any) => ({ name: (t.date || '').slice(5), 매출: t.sales, 주문: t.orderCount })) + const topRows = (top || []).map((p: any) => ({ name: p.name, 판매량: p.salesCount })) + + return ( +
+

대시보드

+
+ + + + +
+ +
+
+

매장별 매출 (최근 7일)

+ +
+
+

매출 추이 (14일)

+ +
+
+ +
+
+

베스트셀러

+ +
+
+

매장 매출 랭킹

+ + + + {(dash?.storeRanking || []).map((s: any, i: number) => ( + + ))} + {!(dash?.storeRanking || []).length && } + +
매장매출주문
{s.storeName || s.storeCode}{money(s.sales)}{s.orderCount}
데이터 없음
+
+
+
+ ) +} diff --git a/frontend/src/admin/Events.tsx b/frontend/src/admin/Events.tsx new file mode 100644 index 0000000..05237e3 --- /dev/null +++ b/frontend/src/admin/Events.tsx @@ -0,0 +1,104 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Megaphone, Sparkles, Plus, BarChart3 } from 'lucide-react' +import { getAdminEvents, createEvent, publishEvent, endEvent, deleteEvent, getEventPerformance, aiEventCopy } from '../api/client' +import StatusBadge from '../components/StatusBadge' +import { money } from '../store/shop' + +const TYPES = ['DISCOUNT', 'POINT_BONUS', 'GIFT', 'TIER_ONLY', 'SEASON'] + +export default function Events() { + const qc = useQueryClient() + const [status, setStatus] = useState('') + const [open, setOpen] = useState(false) + const [perf, setPerf] = useState(null) + const [form, setForm] = useState({ code: '', title: '', eventType: 'DISCOUNT', description: '', startDate: '', endDate: '', targetTiers: '', bonusPointRate: 0 }) + const [aiCopyMsg, setAiCopyMsg] = useState('') + + const { data: events } = useQuery({ queryKey: ['admin-events', status], queryFn: () => getAdminEvents(status, '', false) }) + + const refresh = () => qc.invalidateQueries({ queryKey: ['admin-events'] }) + const create = async () => { await createEvent({ ...form, status: 'DRAFT', banners: [] }).catch(() => {}); setOpen(false); refresh() } + const publish = async (id: number) => { await publishEvent(id).catch(() => {}); refresh() } + const end = async (id: number) => { await endEvent(id).catch(() => {}); refresh() } + const del = async (id: number) => { await deleteEvent(id).catch(() => {}); refresh() } + const showPerf = async (id: number) => { const r = await getEventPerformance(id).catch(() => null); setPerf(r) } + const genCopy = async () => { + const r = await aiEventCopy(form.eventType, form.title || '봄 시즌', 'friendly').catch(() => null) + if (r) { setForm({ ...form, title: r.headline || form.title, description: r.subtext || form.description }); setAiCopyMsg(`AI 카피 생성 (${r.source})`) } + } + + return ( +
+
+

이벤트 · 캠페인

+
+ + +
+
+ + {open && ( +
+
+ setForm({ ...form, code: e.target.value })} placeholder="코드" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm" /> + setForm({ ...form, title: e.target.value })} placeholder="제목" className="bg-panel border border-edge rounded-lg px-3 py-2 text-sm col-span-2" /> +
+