diff --git a/.claude/skills/GUIDELINES.md b/.claude/skills/GUIDELINES.md index a8fc16b..4208e48 100644 --- a/.claude/skills/GUIDELINES.md +++ b/.claude/skills/GUIDELINES.md @@ -57,6 +57,12 @@ A **Skill** is a scoped, reusable workflow that encodes: - Many detailed examples needed - Separate reference material helps token efficiency +### Korean Translation Requirement (MUST) + +When you create or update a skill: +- ALSO create/update a Korean translation at `.claude/skills//md-ko-ver/SKILL.md`. +- Ensure the file is saved as UTF-8 (한글 인코딩 깨짐 방지). + --- ## Required Sections diff --git a/.claude/skills/flyway/SKILL.md b/.claude/skills/flyway/SKILL.md new file mode 100644 index 0000000..5d6963c --- /dev/null +++ b/.claude/skills/flyway/SKILL.md @@ -0,0 +1,245 @@ +--- +name: flyway +description: > + Use this skill when creating or modifying database schema migrations or + resolving Flyway validation/repair issues. It enforces immutable migrations, + ddl-auto=validate usage, safe backfills, and profile-scoped auto-repair rules. +--- + +# Flyway Migrations + +Use Flyway for all schema changes and migration recovery. This skill is scoped +to database schema workflow only. + +## When to Activate + +Use this skill when: +- Creating or editing a SQL migration under `src/main/resources/db/migration` +- Adding or changing JPA entity fields that require schema updates +- Handling Flyway errors (checksum mismatch, failed validation, failed migrate) +- Planning a baseline (initial schema) migration for clean environments +- Changing DDL-related settings (e.g., `ddl-auto`) + +## Out of Scope (DO NOT use this skill) + +- Business logic changes unrelated to schema +- Data correction scripts not tied to schema evolution +- Performance tuning, query optimization, or index analysis only +- Test data cleanup (see `test-code` skill) + +--- + +# Core Rules/Principles + +## 🚨 CRITICAL: NEVER edit an applied migration + +**Rule:** Once a migration version is applied to any DB, do NOT modify or delete +its SQL file. Always add a new migration. + +**Why this matters:** +- Editing breaks Flyway checksum validation. +- Inconsistent environments lead to irreversible schema drift. + +## MUST: Use Flyway for all schema changes + +All schema changes MUST be represented as Flyway migrations in +`src/main/resources/db/migration`. + +**Why this matters:** `ddl-auto` is set to `validate`, so Hibernate will not +modify schemas. Without Flyway migrations, the app will fail to boot. + +## MUST: Keep `ddl-auto=validate` + +Never commit `ddl-auto=update/create`. Use `validate` across profiles. + +**Why this matters:** Automatic schema mutation bypasses versioned migrations +and makes reproducible environments impossible. + +## IMPORTANT: Auto-repair is dev/test only + +Auto-repair is enabled for `local`, `test`, and `test-dev` profiles only. +Never enable auto-repair in production. + +**Why this matters:** Repair can hide accidental migration edits. Production +must fail fast and require explicit review. + +## IMPORTANT: NOT NULL changes require safe backfill + +Use the pattern: add nullable column -> backfill -> set NOT NULL. + +**Why this matters:** Avoids startup failures and partial updates. + +--- + +# Workflow + +## Step 1: Determine Change Scope + +**DO:** +- Identify which entities/fields require schema changes. +- Search for existing migrations covering the same change. + +**DO NOT:** +- Modify an existing migration file. + +**Verify:** +- [ ] No existing migration already covers the change. + +**Output:** A clear list of schema changes. + +## Step 2: Create a New Migration + +**DO:** +- Create a new SQL file under `src/main/resources/db/migration`. +- Use naming: `VYYYYMMDD__short_description.sql`. + +**DO NOT:** +- Use vague names like `update.sql`. + +**Verify:** +- [ ] Filename is unique and ordered. +- [ ] SQL is idempotent when possible (e.g., `IF NOT EXISTS` for columns). + +**Output:** New migration file path. + +## Step 3: Handle Backfills Safely + +**DO:** +- For NOT NULL: add nullable column -> backfill -> set NOT NULL. +- Use deterministic backfills (no random values). + +**DO NOT:** +- Skip backfill and apply NOT NULL immediately. + +**Verify:** +- [ ] Backfill covers all existing rows. +- [ ] Constraints applied after backfill. + +**Output:** Backfill SQL steps. + +## Step 4: Validate and Apply + +**DO:** +- Run `./gradlew test` or `./gradlew build`. +- If checksum mismatch: `flyway.repair()` (dev/test only) then re-run. + +**DO NOT:** +- Enable auto-repair in production. + +**Verify:** +- [ ] Build/test succeeds with `ddl-auto=validate`. + +**Output:** Test/build result and any repair action. + +--- + +# Output Contract + +Return exactly: +1. **Migration File:** path + filename +2. **Schema Change Summary:** what changed + backfill (if any) +3. **Safety Notes:** NOT NULL pattern / repair usage / profile scope +4. **Verification:** tests or build command run + +--- + +# Common Anti-Patterns (MUST AVOID) + +## ❌ Anti-Pattern 1: Editing an applied migration + +**DON'T:** +```sql +-- ❌ Editing an already-applied migration +ALTER TABLE trade_item ADD COLUMN net_payload_weight DECIMAL(19,2); +-- (later edited to add IF NOT EXISTS) +``` + +**✅ CORRECT:** +```sql +-- ✅ Add a new migration file instead +ALTER TABLE trade_item ADD COLUMN IF NOT EXISTS net_payload_weight DECIMAL(19,2); +``` + +**Consequences:** +- Flyway checksum mismatch and blocked boot. +- Divergent schemas across environments. + +## ❌ Anti-Pattern 2: Using `ddl-auto=update` + +**DON'T:** +```yaml +spring: + jpa: + hibernate: + ddl-auto: update # ❌ breaks migration discipline +``` + +**✅ CORRECT:** +```yaml +spring: + jpa: + hibernate: + ddl-auto: validate # ✅ schema managed by Flyway +``` + +**Consequences:** +- Untracked schema drift. +- Non-reproducible environments. + +## ❌ Anti-Pattern 3: Forcing NOT NULL without backfill + +**DON'T:** +```sql +ALTER TABLE trade_item ADD COLUMN net_payload_weight DECIMAL(19,2) NOT NULL; +``` + +**✅ CORRECT:** +```sql +ALTER TABLE trade_item ADD COLUMN net_payload_weight DECIMAL(19,2); +UPDATE trade_item SET net_payload_weight = weight - COALESCE(weight_loss, 0); +ALTER TABLE trade_item MODIFY COLUMN net_payload_weight DECIMAL(19,2) NOT NULL; +``` + +**Consequences:** +- Migration fails on existing data. +- Partial deployment rollbacks. + +--- + +# Summary Checklist + +## Migration File: +- [ ] New migration file created (no edits to applied files) +- [ ] Filename follows `VYYYYMMDD__short_description.sql` +- [ ] SQL is idempotent where possible + +## Data Safety: +- [ ] Backfill included for NOT NULL changes +- [ ] No destructive schema changes without explicit approval + +## Environment: +- [ ] `ddl-auto=validate` remains unchanged +- [ ] Auto-repair only in dev/test profiles + +**If ANY item is unchecked, schema drift or startup failures may occur.** + +--- + +# Output Expectations + +When this skill is active, you MUST: + +1. State the migration filename and path. +2. Explain the backfill/constraint sequence if NOT NULL is involved. +3. Call out any repair action and profile scope. + +**Before writing code:** +- Confirm whether a baseline migration exists for clean DBs. + +**When making changes:** +- Never modify existing migration files. +- Keep `ddl-auto=validate` in committed configs. + +**NEVER:** +- Enable auto-repair in production. +- Commit hardcoded DB credentials. diff --git a/.claude/skills/flyway/md-ko-ver/SKILL.md b/.claude/skills/flyway/md-ko-ver/SKILL.md new file mode 100644 index 0000000..36a619b --- /dev/null +++ b/.claude/skills/flyway/md-ko-ver/SKILL.md @@ -0,0 +1,245 @@ +--- +name: flyway +description: > + 데이터베이스 스키마 마이그레이션을 생성/수정하거나 Flyway 검증/리페어 문제를 + 해결할 때 사용한다. 변경 불가 마이그레이션, ddl-auto=validate 사용, + 안전한 백필, 프로필 범위의 자동 repair 규칙을 강제한다. +--- + +# Flyway 마이그레이션 + +모든 스키마 변경과 마이그레이션 복구는 Flyway로 수행한다. +이 스킬은 DB 스키마 워크플로 전용이다. + +## When to Activate + +다음 상황에서 이 스킬을 사용한다: +- `src/main/resources/db/migration` 아래 SQL 마이그레이션을 생성/수정할 때 +- JPA 엔티티 필드 변경으로 스키마 변경이 필요할 때 +- Flyway 오류 처리(체크섬 불일치, 검증 실패, migrate 실패) +- 클린 환경용 베이스라인(초기 스키마) 마이그레이션을 계획할 때 +- DDL 관련 설정 변경 시 (예: `ddl-auto`) + +## Out of Scope (DO NOT use this skill) + +- 스키마와 무관한 비즈니스 로직 변경 +- 스키마 진화와 무관한 데이터 정정 스크립트 +- 성능 튜닝, 쿼리 최적화, 인덱스 분석만 하는 작업 +- 테스트 데이터 정리 (see `test-code` skill) + +--- + +# Core Rules/Principles + +## 🚨 CRITICAL: 적용된 마이그레이션은 절대 수정하지 말 것 + +**Rule:** 어떤 DB에서든 한 번 적용된 버전의 SQL 파일은 수정/삭제 금지. +항상 새 마이그레이션을 추가한다. + +**Why this matters:** +- 수정 시 Flyway 체크섬 검증이 깨진다. +- 환경 간 스키마 불일치가 발생해 복구가 어려워진다. + +## MUST: 모든 스키마 변경은 Flyway로 관리 + +모든 스키마 변경은 `src/main/resources/db/migration`에 +Flyway 마이그레이션으로 추가해야 한다. + +**Why this matters:** `ddl-auto`가 `validate`라서 Hibernate가 스키마를 +변경하지 않는다. Flyway가 없으면 부팅이 실패한다. + +## MUST: `ddl-auto=validate` 유지 + +`ddl-auto=update/create`는 커밋 금지. 모든 프로필에서 `validate` 사용. + +**Why this matters:** 자동 스키마 변경은 버전 관리 밖에서 발생해 +재현 가능한 환경을 깨뜨린다. + +## IMPORTANT: auto-repair는 dev/test 전용 + +auto-repair는 `local`, `test`, `test-dev` 프로필에서만 허용한다. +운영에서는 절대 켜지 않는다. + +**Why this matters:** repair는 실수로 변경된 마이그레이션도 통과시키므로 +운영에서는 실패가 즉시 드러나야 한다. + +## IMPORTANT: NOT NULL 변경은 안전한 백필 필수 + +패턴: nullable 컬럼 추가 -> 백필 -> NOT NULL 적용. + +**Why this matters:** 기존 데이터로 인한 실패와 부분 적용을 방지한다. + +--- + +# Workflow + +## Step 1: 변경 범위 확정 + +**DO:** +- 어떤 엔티티/필드가 스키마 변경을 요구하는지 확인한다. +- 동일 변경을 포함하는 기존 마이그레이션이 있는지 검색한다. + +**DO NOT:** +- 기존 마이그레이션 파일을 수정하지 않는다. + +**Verify:** +- [ ] 기존 마이그레이션에 동일 변경이 없다. + +**Output:** 변경 사항 목록. + +## Step 2: 새 마이그레이션 생성 + +**DO:** +- `src/main/resources/db/migration`에 새 SQL 파일 생성. +- 네이밍 규칙: `VYYYYMMDD__short_description.sql`. + +**DO NOT:** +- `update.sql` 같은 모호한 이름 사용 금지. + +**Verify:** +- [ ] 파일명이 고유하고 순서가 맞다. +- [ ] 가능한 경우 idempotent SQL 사용 (예: `IF NOT EXISTS`). + +**Output:** 새 마이그레이션 파일 경로. + +## Step 3: 백필 안전 처리 + +**DO:** +- NOT NULL 변경은 nullable -> 백필 -> NOT NULL 순서로 적용. +- 결정적(deterministic) 백필만 사용. + +**DO NOT:** +- 백필 없이 NOT NULL을 바로 적용하지 않는다. + +**Verify:** +- [ ] 모든 기존 row가 백필 대상. +- [ ] 백필 이후 제약 조건 적용. + +**Output:** 백필 SQL 단계. + +## Step 4: 검증 및 적용 + +**DO:** +- `./gradlew test` 또는 `./gradlew build` 실행. +- 체크섬 불일치 시 `flyway.repair()`(dev/test 전용) 후 재실행. + +**DO NOT:** +- 운영에서 auto-repair 활성화 금지. + +**Verify:** +- [ ] `ddl-auto=validate` 상태에서 빌드/테스트 통과. + +**Output:** 검증 결과 및 repair 여부. + +--- + +# Output Contract + +반드시 다음을 반환: +1. **Migration File:** 경로 + 파일명 +2. **Schema Change Summary:** 변경 내용 + 백필(있으면) +3. **Safety Notes:** NOT NULL 패턴 / repair 사용 / 프로필 범위 +4. **Verification:** 실행한 테스트/빌드 명령 + +--- + +# Common Anti-Patterns (MUST AVOID) + +## ❌ Anti-Pattern 1: 적용된 마이그레이션 수정 + +**DON'T:** +```sql +-- ❌ 이미 적용된 마이그레이션을 수정 +ALTER TABLE trade_item ADD COLUMN net_payload_weight DECIMAL(19,2); +-- (이후 IF NOT EXISTS 추가로 수정) +``` + +**✅ CORRECT:** +```sql +-- ✅ 새 마이그레이션 파일을 추가 +ALTER TABLE trade_item ADD COLUMN IF NOT EXISTS net_payload_weight DECIMAL(19,2); +``` + +**Consequences:** +- Flyway 체크섬 불일치로 부팅 차단 +- 환경별 스키마 분기 + +## ❌ Anti-Pattern 2: `ddl-auto=update` 사용 + +**DON'T:** +```yaml +spring: + jpa: + hibernate: + ddl-auto: update # ❌ 마이그레이션 규율 붕괴 +``` + +**✅ CORRECT:** +```yaml +spring: + jpa: + hibernate: + ddl-auto: validate # ✅ Flyway가 스키마 관리 +``` + +**Consequences:** +- 추적 불가능한 스키마 변경 +- 재현 불가능한 환경 + +## ❌ Anti-Pattern 3: 백필 없는 NOT NULL 적용 + +**DON'T:** +```sql +ALTER TABLE trade_item ADD COLUMN net_payload_weight DECIMAL(19,2) NOT NULL; +``` + +**✅ CORRECT:** +```sql +ALTER TABLE trade_item ADD COLUMN net_payload_weight DECIMAL(19,2); +UPDATE trade_item SET net_payload_weight = weight - COALESCE(weight_loss, 0); +ALTER TABLE trade_item MODIFY COLUMN net_payload_weight DECIMAL(19,2) NOT NULL; +``` + +**Consequences:** +- 기존 데이터 때문에 마이그레이션 실패 +- 부분 배포 롤백 + +--- + +# Summary Checklist + +## Migration File: +- [ ] 새 마이그레이션 파일 생성 (기존 파일 수정 금지) +- [ ] 파일명 규칙 `VYYYYMMDD__short_description.sql` +- [ ] 가능한 경우 idempotent SQL 적용 + +## Data Safety: +- [ ] NOT NULL 변경 시 백필 포함 +- [ ] 승인 없는 파괴적 변경 없음 + +## Environment: +- [ ] `ddl-auto=validate` 유지 +- [ ] auto-repair는 dev/test 프로필에서만 + +**체크 누락 시 스키마 드리프트/부팅 실패 위험.** + +--- + +# Output Expectations + +이 스킬이 활성화되면 반드시: + +1. 마이그레이션 파일 경로/이름 명시 +2. NOT NULL 변경 시 백필/제약 순서 설명 +3. repair 수행 여부와 적용 프로필 범위 언급 + +**Before writing code:** +- 클린 DB용 베이스라인 마이그레이션 존재 여부 확인 + +**When making changes:** +- 기존 마이그레이션 파일 절대 수정 금지 +- 커밋 설정은 `ddl-auto=validate` 유지 + +**NEVER:** +- 운영 환경에서 auto-repair 활성화 금지 +- 하드코딩된 DB 자격증명 커밋 금지 diff --git a/CLAUDE.md b/CLAUDE.md index 2e0960a..8e37eab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co --- +## Flyway (Schema Changes) + +When making DB schema changes or handling Flyway issues, use the Flyway skill: +`.claude/skills/flyway/SKILL.md`. + ## Build & Development Commands This is a Spring Boot 3.3.9 application using Java 17 and Gradle. @@ -485,6 +490,7 @@ public class TradeCreatedEvent { When asked to create or update skill files (`*.md` in `.claude/skills/` directory): **MUST read and follow** `.claude/skills/GUIDELINES.md` + (includes the required `md-ko-ver` Korean translation rules). **Requirements:** 1. Read GUIDELINES.md before starting diff --git a/build.gradle b/build.gradle index db1f64f..dc45d01 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.3.9' id 'io.spring.dependency-management' version '1.1.7' + id 'org.flywaydb.flyway' version '10.10.0' } group = 'greenfirst' @@ -38,6 +39,9 @@ dependencies { testAnnotationProcessor 'org.projectlombok:lombok' // maria-db runtimeOnly 'org.mariadb.jdbc:mariadb-java-client' + // flyway + implementation 'org.flywaydb:flyway-core' + runtimeOnly 'org.flywaydb:flyway-mysql' // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' // modelmapper @@ -75,3 +79,7 @@ dependencies { tasks.named('test') { useJUnitPlatform() } + +flyway { + locations = ['filesystem:src/main/resources/db/migration'] +} diff --git a/md-ko-ver/claude/skills/flyway.ko.md b/md-ko-ver/claude/skills/flyway.ko.md new file mode 100644 index 0000000..36a619b --- /dev/null +++ b/md-ko-ver/claude/skills/flyway.ko.md @@ -0,0 +1,245 @@ +--- +name: flyway +description: > + 데이터베이스 스키마 마이그레이션을 생성/수정하거나 Flyway 검증/리페어 문제를 + 해결할 때 사용한다. 변경 불가 마이그레이션, ddl-auto=validate 사용, + 안전한 백필, 프로필 범위의 자동 repair 규칙을 강제한다. +--- + +# Flyway 마이그레이션 + +모든 스키마 변경과 마이그레이션 복구는 Flyway로 수행한다. +이 스킬은 DB 스키마 워크플로 전용이다. + +## When to Activate + +다음 상황에서 이 스킬을 사용한다: +- `src/main/resources/db/migration` 아래 SQL 마이그레이션을 생성/수정할 때 +- JPA 엔티티 필드 변경으로 스키마 변경이 필요할 때 +- Flyway 오류 처리(체크섬 불일치, 검증 실패, migrate 실패) +- 클린 환경용 베이스라인(초기 스키마) 마이그레이션을 계획할 때 +- DDL 관련 설정 변경 시 (예: `ddl-auto`) + +## Out of Scope (DO NOT use this skill) + +- 스키마와 무관한 비즈니스 로직 변경 +- 스키마 진화와 무관한 데이터 정정 스크립트 +- 성능 튜닝, 쿼리 최적화, 인덱스 분석만 하는 작업 +- 테스트 데이터 정리 (see `test-code` skill) + +--- + +# Core Rules/Principles + +## 🚨 CRITICAL: 적용된 마이그레이션은 절대 수정하지 말 것 + +**Rule:** 어떤 DB에서든 한 번 적용된 버전의 SQL 파일은 수정/삭제 금지. +항상 새 마이그레이션을 추가한다. + +**Why this matters:** +- 수정 시 Flyway 체크섬 검증이 깨진다. +- 환경 간 스키마 불일치가 발생해 복구가 어려워진다. + +## MUST: 모든 스키마 변경은 Flyway로 관리 + +모든 스키마 변경은 `src/main/resources/db/migration`에 +Flyway 마이그레이션으로 추가해야 한다. + +**Why this matters:** `ddl-auto`가 `validate`라서 Hibernate가 스키마를 +변경하지 않는다. Flyway가 없으면 부팅이 실패한다. + +## MUST: `ddl-auto=validate` 유지 + +`ddl-auto=update/create`는 커밋 금지. 모든 프로필에서 `validate` 사용. + +**Why this matters:** 자동 스키마 변경은 버전 관리 밖에서 발생해 +재현 가능한 환경을 깨뜨린다. + +## IMPORTANT: auto-repair는 dev/test 전용 + +auto-repair는 `local`, `test`, `test-dev` 프로필에서만 허용한다. +운영에서는 절대 켜지 않는다. + +**Why this matters:** repair는 실수로 변경된 마이그레이션도 통과시키므로 +운영에서는 실패가 즉시 드러나야 한다. + +## IMPORTANT: NOT NULL 변경은 안전한 백필 필수 + +패턴: nullable 컬럼 추가 -> 백필 -> NOT NULL 적용. + +**Why this matters:** 기존 데이터로 인한 실패와 부분 적용을 방지한다. + +--- + +# Workflow + +## Step 1: 변경 범위 확정 + +**DO:** +- 어떤 엔티티/필드가 스키마 변경을 요구하는지 확인한다. +- 동일 변경을 포함하는 기존 마이그레이션이 있는지 검색한다. + +**DO NOT:** +- 기존 마이그레이션 파일을 수정하지 않는다. + +**Verify:** +- [ ] 기존 마이그레이션에 동일 변경이 없다. + +**Output:** 변경 사항 목록. + +## Step 2: 새 마이그레이션 생성 + +**DO:** +- `src/main/resources/db/migration`에 새 SQL 파일 생성. +- 네이밍 규칙: `VYYYYMMDD__short_description.sql`. + +**DO NOT:** +- `update.sql` 같은 모호한 이름 사용 금지. + +**Verify:** +- [ ] 파일명이 고유하고 순서가 맞다. +- [ ] 가능한 경우 idempotent SQL 사용 (예: `IF NOT EXISTS`). + +**Output:** 새 마이그레이션 파일 경로. + +## Step 3: 백필 안전 처리 + +**DO:** +- NOT NULL 변경은 nullable -> 백필 -> NOT NULL 순서로 적용. +- 결정적(deterministic) 백필만 사용. + +**DO NOT:** +- 백필 없이 NOT NULL을 바로 적용하지 않는다. + +**Verify:** +- [ ] 모든 기존 row가 백필 대상. +- [ ] 백필 이후 제약 조건 적용. + +**Output:** 백필 SQL 단계. + +## Step 4: 검증 및 적용 + +**DO:** +- `./gradlew test` 또는 `./gradlew build` 실행. +- 체크섬 불일치 시 `flyway.repair()`(dev/test 전용) 후 재실행. + +**DO NOT:** +- 운영에서 auto-repair 활성화 금지. + +**Verify:** +- [ ] `ddl-auto=validate` 상태에서 빌드/테스트 통과. + +**Output:** 검증 결과 및 repair 여부. + +--- + +# Output Contract + +반드시 다음을 반환: +1. **Migration File:** 경로 + 파일명 +2. **Schema Change Summary:** 변경 내용 + 백필(있으면) +3. **Safety Notes:** NOT NULL 패턴 / repair 사용 / 프로필 범위 +4. **Verification:** 실행한 테스트/빌드 명령 + +--- + +# Common Anti-Patterns (MUST AVOID) + +## ❌ Anti-Pattern 1: 적용된 마이그레이션 수정 + +**DON'T:** +```sql +-- ❌ 이미 적용된 마이그레이션을 수정 +ALTER TABLE trade_item ADD COLUMN net_payload_weight DECIMAL(19,2); +-- (이후 IF NOT EXISTS 추가로 수정) +``` + +**✅ CORRECT:** +```sql +-- ✅ 새 마이그레이션 파일을 추가 +ALTER TABLE trade_item ADD COLUMN IF NOT EXISTS net_payload_weight DECIMAL(19,2); +``` + +**Consequences:** +- Flyway 체크섬 불일치로 부팅 차단 +- 환경별 스키마 분기 + +## ❌ Anti-Pattern 2: `ddl-auto=update` 사용 + +**DON'T:** +```yaml +spring: + jpa: + hibernate: + ddl-auto: update # ❌ 마이그레이션 규율 붕괴 +``` + +**✅ CORRECT:** +```yaml +spring: + jpa: + hibernate: + ddl-auto: validate # ✅ Flyway가 스키마 관리 +``` + +**Consequences:** +- 추적 불가능한 스키마 변경 +- 재현 불가능한 환경 + +## ❌ Anti-Pattern 3: 백필 없는 NOT NULL 적용 + +**DON'T:** +```sql +ALTER TABLE trade_item ADD COLUMN net_payload_weight DECIMAL(19,2) NOT NULL; +``` + +**✅ CORRECT:** +```sql +ALTER TABLE trade_item ADD COLUMN net_payload_weight DECIMAL(19,2); +UPDATE trade_item SET net_payload_weight = weight - COALESCE(weight_loss, 0); +ALTER TABLE trade_item MODIFY COLUMN net_payload_weight DECIMAL(19,2) NOT NULL; +``` + +**Consequences:** +- 기존 데이터 때문에 마이그레이션 실패 +- 부분 배포 롤백 + +--- + +# Summary Checklist + +## Migration File: +- [ ] 새 마이그레이션 파일 생성 (기존 파일 수정 금지) +- [ ] 파일명 규칙 `VYYYYMMDD__short_description.sql` +- [ ] 가능한 경우 idempotent SQL 적용 + +## Data Safety: +- [ ] NOT NULL 변경 시 백필 포함 +- [ ] 승인 없는 파괴적 변경 없음 + +## Environment: +- [ ] `ddl-auto=validate` 유지 +- [ ] auto-repair는 dev/test 프로필에서만 + +**체크 누락 시 스키마 드리프트/부팅 실패 위험.** + +--- + +# Output Expectations + +이 스킬이 활성화되면 반드시: + +1. 마이그레이션 파일 경로/이름 명시 +2. NOT NULL 변경 시 백필/제약 순서 설명 +3. repair 수행 여부와 적용 프로필 범위 언급 + +**Before writing code:** +- 클린 DB용 베이스라인 마이그레이션 존재 여부 확인 + +**When making changes:** +- 기존 마이그레이션 파일 절대 수정 금지 +- 커밋 설정은 `ddl-auto=validate` 유지 + +**NEVER:** +- 운영 환경에서 auto-repair 활성화 금지 +- 하드코딩된 DB 자격증명 커밋 금지 diff --git a/md-ko-ver/flyway-guide.ko.md b/md-ko-ver/flyway-guide.ko.md new file mode 100644 index 0000000..cc234cf --- /dev/null +++ b/md-ko-ver/flyway-guide.ko.md @@ -0,0 +1,118 @@ +# Flyway 사용 가이드 (한국어) + +이 문서는 **스키마 변경을 Flyway로만 관리**하기 위한 최소한의 사용법을 정리합니다. +현재 설정은 `ddl-auto=validate` 이므로 **스키마 변경 시 반드시 마이그레이션 SQL이 필요**합니다. + +--- + +## 핵심 규칙 (TL;DR) + +1) 모든 스키마 변경은 `src/main/resources/db/migration`에 SQL로 추가 +2) 파일명 규칙: `VYYYYMMDD__short_description.sql` +3) 이미 적용된 마이그레이션은 **수정/삭제 금지** +4) NOT NULL 변경은 **컬럼 추가 → 백필 → NOT NULL** 순서 +5) 실패 시 `flyway_schema_history`에서 실패 row 정리 후 재시도 + +--- + +## 1. 베이스라인(초기 스키마) 마이그레이션 생성 방법 + +**새로운 환경(빈 DB)에서도 앱이 부팅되려면 초기 스키마를 만드는 베이스라인 마이그레이션이 필요합니다.** + +### 방법 A: 로컬 DB에서 스키마 덤프 (권장) + +```bash +# 예시: 로컬 MariaDB (포트/DB명은 환경에 맞게 변경) +mysqldump \ + --no-data --routines --triggers --skip-add-drop-table \ + -h localhost -P 3319 -u root -p \ + greenfirst > src/main/resources/db/migration/V20260129__baseline.sql +``` + +### 방법 B: Docker 컨테이너 사용 중인 경우 + +```bash +# 컨테이너 이름 확인 후 실행 +docker exec -i \ + mysqldump --no-data --routines --triggers --skip-add-drop-table \ + -u root -p greenfirst \ + > src/main/resources/db/migration/V20260129__baseline.sql +``` + +> 덤프 파일에서 `CREATE DATABASE`/`USE` 구문이 있다면 제거하고, +> `CREATE TABLE`/`CREATE INDEX`/`ALTER TABLE` 구문만 남겨주세요. + +--- + +## 2. 새 스키마 변경 절차 + +1) `src/main/resources/db/migration`에 새 SQL 파일 추가 +2) 파일명 규칙: `VYYYYMMDD__short_description.sql` +3) 로컬에서 `./gradlew test` 또는 `./gradlew bootRun` 실행 +4) 정상 동작 확인 후 커밋 + +--- + +## 3. NOT NULL 변경 패턴 (예시) + +```sql +ALTER TABLE trade_item ADD COLUMN new_col DECIMAL(19,2); +UPDATE trade_item SET new_col = old_col WHERE new_col IS NULL; +ALTER TABLE trade_item MODIFY COLUMN new_col DECIMAL(19,2) NOT NULL; +``` + +--- + +## 4. 마이그레이션 실패 시 복구 + +실패 로그에 해당 버전이 남아 있으면 Flyway가 검증 단계에서 막힙니다. + +```sql +-- 실패한 마이그레이션 버전만 삭제 +DELETE FROM flyway_schema_history WHERE version = '20260129'; +``` + +정리 후 다시 실행하면 됩니다. + +--- + +## 5. 자동 Repair (local/test 전용) + +local/test 환경에서는 앱 부팅 시 다음 순서가 자동 수행됩니다. + +1) `flyway.repair()` +2) `flyway.migrate()` + +즉, local/test 환경에서는 **체크섬 불일치가 있어도 자동으로 복구 후 마이그레이션**됩니다. + +운영 환경에서는 자동 repair를 사용하지 않습니다. + +--- + +## 6. 수동 Repair가 필요한 경우 (운영 등) + +```bash +# 예: Gradle Flyway 플러그인 사용 +./gradlew flywayRepair \ + -Dflyway.url=jdbc:mariadb://localhost:3320/greenfirst_test \ + -Dflyway.user=root \ + -Dflyway.password=1234 + +# migrate +./gradlew flywayMigrate \ + -Dflyway.url=jdbc:mariadb://localhost:3320/greenfirst_test \ + -Dflyway.user=root \ + -Dflyway.password=1234 +``` + +> URL/계정은 환경에 맞게 변경해주세요. +> Gradle 작업은 **명시적으로 대상 DB를 지정**하는 것을 권장합니다. + +--- + +## 5. 참고 설정 + +- `spring.jpa.hibernate.ddl-auto=validate` + → Hibernate가 스키마를 생성/수정하지 않음 +- `spring.flyway.baseline-on-migrate=true` + → 기존 DB가 있는 환경에서도 Flyway를 온보딩 가능 diff --git a/src/main/java/greenfirst/be/global/config/flyway/FlywayConfig.java b/src/main/java/greenfirst/be/global/config/flyway/FlywayConfig.java new file mode 100644 index 0000000..901aae4 --- /dev/null +++ b/src/main/java/greenfirst/be/global/config/flyway/FlywayConfig.java @@ -0,0 +1,22 @@ +package greenfirst.be.global.config.flyway; + + +import org.springframework.boot.autoconfigure.flyway.FlywayMigrationStrategy; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + + +@Configuration +@Profile({ "local", "test", "test-dev" }) +public class FlywayConfig { + + @Bean + public FlywayMigrationStrategy flywayMigrationStrategy() { + return flyway -> { + flyway.repair(); + flyway.migrate(); + }; + } + +} diff --git a/src/main/java/greenfirst/be/stats/adapter/in/web/controller/GetDailyStatsController.java b/src/main/java/greenfirst/be/stats/adapter/in/web/controller/GetDailyStatsController.java index 0671c96..7c3c42e 100644 --- a/src/main/java/greenfirst/be/stats/adapter/in/web/controller/GetDailyStatsController.java +++ b/src/main/java/greenfirst/be/stats/adapter/in/web/controller/GetDailyStatsController.java @@ -5,12 +5,15 @@ import greenfirst.be.global.common.security.CustomUserDetails; import greenfirst.be.stats.adapter.in.web.response.BranchDailyItemStatsListResponse; import greenfirst.be.stats.adapter.in.web.response.BranchDailyStatsListResponse; +import greenfirst.be.stats.adapter.in.web.response.PartnerDailyItemStatsListResponse; import greenfirst.be.stats.adapter.in.web.response.PartnerDailyTradeStatsListResponse; import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyStatsInDto; +import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyTradeStatsInDto; import greenfirst.be.stats.application.dto.out.BranchDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.BranchDailyStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerDailyTradeStatsOutDto; import greenfirst.be.stats.application.facade.DailyStatsFacade; import io.swagger.v3.oas.annotations.Operation; @@ -45,7 +48,10 @@ public class GetDailyStatsController { * 2. 지점별 일별 통계 조회 - 로그인 유저 지점 (Agency) * 3. 지점별 일별 품목 통계 조회 (Admin) * 4. 지점별 일별 품목 통계 조회 - 로그인 유저 지점 (Agency) - * 5. 파트너별 일별 거래 통계 조회 + * 5. 파트너별 일별 거래 통계 조회 (Admin) + * 6. 파트너별 일별 거래 통계 조회 - 로그인 파트너 (Partner) + * 7. 파트너별 일별 품목 통계 조회 (Admin) + * 8. 파트너별 일별 품목 통계 조회 - 로그인 파트너 (Partner) */ // 1. 지점별 일별 통계 조회 (Admin) @@ -74,6 +80,7 @@ public BaseResponse getBranchDailyStats( return new BaseResponse<>(BranchDailyStatsListResponse.from(outDto)); } + // 2. 지점별 일별 통계 조회 - 로그인 유저 지점 (Agency) @Operation(summary = "지점별 일별 통계 조회(지점 자동)", description = "로그인 유저가 속한 지점의 일별 통계를 조회합니다. (Agency)", tags = { "Stats - Agency" }) @GetMapping("/branch/my") @@ -126,6 +133,7 @@ public BaseResponse getBranchDailyItemStats( return new BaseResponse<>(BranchDailyItemStatsListResponse.from(outDto)); } + // 4. 지점별 일별 품목 통계 조회 - 로그인 유저 지점 (Agency) @Operation(summary = "지점별 일별 품목 통계 조회(지점 자동)", description = "로그인 유저가 속한 지점의 일별 품목 통계를 조회합니다. (Agency)", tags = { "Stats - Agency" }) @GetMapping("/item/branch/my") @@ -152,10 +160,10 @@ public BaseResponse getMyBranchDailyItemStats( } - // 5. 파트너별 일별 거래 통계 조회 - @Operation(summary = "파트너별 일별 거래 통계 조회", description = "파트너별 일별 거래 통계를 조회합니다. (Admin/Agency/Partner)", tags = { "Stats - Admin", "Stats - Agency", "Stats - Partner" }) + // 5. 파트너별 일별 거래 통계 조회 (Admin) + @Operation(summary = "파트너별 일별 거래 통계 조회", description = "파트너별 일별 거래 통계를 조회합니다. (Admin)", tags = { "Stats - Admin" }) @GetMapping("/partner/{partnerUuid}") - @PreAuthorize("hasAnyAuthority('ADMIN', 'AGENCY', 'PERSONAL_PARTNER', 'CORPORATE_PARTNER')") + @PreAuthorize("hasAuthority('ADMIN')") @SecurityRequirement(name = "Bearer Auth") public BaseResponse getPartnerDailyTradeStats( @PathVariable UUID partnerUuid, @@ -179,4 +187,86 @@ public BaseResponse getPartnerDailyTradeStat return new BaseResponse<>(PartnerDailyTradeStatsListResponse.from(outDto)); } + + // 6. 파트너별 일별 거래 통계 조회 - 로그인 파트너 (Partner) + @Operation(summary = "파트너별 일별 거래 통계 조회(본인)", description = "로그인한 파트너의 일별 거래 통계를 조회합니다. (Partner)", tags = { "Stats - Partner" }) + @GetMapping("/partner/my") + @PreAuthorize("hasAnyAuthority('PERSONAL_PARTNER', 'CORPORATE_PARTNER')") + @SecurityRequirement(name = "Bearer Auth") + public BaseResponse getMyPartnerDailyTradeStats( + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate, + @AuthenticationPrincipal CustomUserDetails authentication) { + + // mapping + GetPartnerDailyTradeStatsInDto inDto = GetPartnerDailyTradeStatsInDto.builder() + .targetPartnerUuid(authentication.getUserUuid()) + .requestorUuid(authentication.getUserUuid()) + .requestorType(authentication.getUserType()) + .startDate(startDate) + .endDate(endDate) + .build(); + + // 일별 거래 통계 조회 + List outDto = dailyStatsFacade.getPartnerDailyTradeStats(inDto); + + // response + return new BaseResponse<>(PartnerDailyTradeStatsListResponse.from(outDto)); + } + + + // 7. 파트너별 일별 품목 통계 조회 (Admin) + @Operation(summary = "파트너별 일별 품목 통계 조회", description = "파트너별 일별 품목 통계를 조회합니다. (Admin)", tags = { "Stats - Admin" }) + @GetMapping("/item/partner/{partnerUuid}") + @PreAuthorize("hasAuthority('ADMIN')") + @SecurityRequirement(name = "Bearer Auth") + public BaseResponse getPartnerDailyItemStats( + @PathVariable UUID partnerUuid, + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate, + @AuthenticationPrincipal CustomUserDetails authentication) { + + // mapping + GetPartnerDailyItemStatsInDto inDto = GetPartnerDailyItemStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(authentication.getUserUuid()) + .requestorType(authentication.getUserType()) + .startDate(startDate) + .endDate(endDate) + .build(); + + // 일별 품목 통계 조회 + List outDto = dailyStatsFacade.getPartnerDailyItemStats(inDto); + + // response + return new BaseResponse<>(PartnerDailyItemStatsListResponse.from(outDto)); + } + + + // 8. 파트너별 일별 품목 통계 조회 - 로그인 파트너 (Partner) + @Operation(summary = "파트너별 일별 품목 통계 조회(본인)", description = "로그인한 파트너의 일별 품목 통계를 조회합니다. (Partner)", tags = { "Stats - Partner" }) + @GetMapping("/item/partner/my") + @PreAuthorize("hasAnyAuthority('PERSONAL_PARTNER', 'CORPORATE_PARTNER')") + @SecurityRequirement(name = "Bearer Auth") + public BaseResponse getMyPartnerDailyItemStats( + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate, + @AuthenticationPrincipal CustomUserDetails authentication) { + + // mapping + GetPartnerDailyItemStatsInDto inDto = GetPartnerDailyItemStatsInDto.builder() + .targetPartnerUuid(authentication.getUserUuid()) + .requestorUuid(authentication.getUserUuid()) + .requestorType(authentication.getUserType()) + .startDate(startDate) + .endDate(endDate) + .build(); + + // 일별 품목 통계 조회 + List outDto = dailyStatsFacade.getPartnerDailyItemStats(inDto); + + // response + return new BaseResponse<>(PartnerDailyItemStatsListResponse.from(outDto)); + } + } diff --git a/src/main/java/greenfirst/be/stats/adapter/in/web/controller/GetMonthlyStatsController.java b/src/main/java/greenfirst/be/stats/adapter/in/web/controller/GetMonthlyStatsController.java index e83cdba..1f9ecb7 100644 --- a/src/main/java/greenfirst/be/stats/adapter/in/web/controller/GetMonthlyStatsController.java +++ b/src/main/java/greenfirst/be/stats/adapter/in/web/controller/GetMonthlyStatsController.java @@ -3,17 +3,12 @@ import greenfirst.be.global.common.response.base.BaseResponse; import greenfirst.be.global.common.security.CustomUserDetails; -import greenfirst.be.stats.adapter.in.web.response.AllBranchStatsSumResponse; -import greenfirst.be.stats.adapter.in.web.response.BranchMonthlyItemStatsListResponse; -import greenfirst.be.stats.adapter.in.web.response.BranchMonthlyStatsListResponse; -import greenfirst.be.stats.adapter.in.web.response.PartnerMonthlyStatsListResponse; +import greenfirst.be.stats.adapter.in.web.response.*; import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyStatsInDto; +import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyStatsInDto; -import greenfirst.be.stats.application.dto.out.AllBranchStatsSumOutDto; -import greenfirst.be.stats.application.dto.out.BranchMonthlyItemStatsOutDto; -import greenfirst.be.stats.application.dto.out.BranchMonthlyStatsOutDto; -import greenfirst.be.stats.application.dto.out.PartnerMonthlyStatsOutDto; +import greenfirst.be.stats.application.dto.out.*; import greenfirst.be.stats.application.facade.MonthlyStatsFacade; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -47,7 +42,10 @@ public class GetMonthlyStatsController { * 3. 지점별 월별 품목 통계 조회 (Admin) * 4. 지점별 월별 품목 통계 조회 - 로그인 유저 지점 (Agency) * 5. 전체지점 통합 월별 통계 조회 (Admin) - * 6. 파트너 월별 거래 통계 조회 + * 6. 파트너 월별 거래 통계 조회 (Admin) + * 7. 파트너 월별 거래 통계 조회 - 로그인 파트너 (Partner) + * 8. 파트너 월별 품목 통계 조회 (Admin) + * 9. 파트너 월별 품목 통계 조회 - 로그인 파트너 (Partner) */ // 1. 지점별 월별 통계 조회 (Admin) @@ -164,17 +162,17 @@ public BaseResponse getAllBranchStatsSum(@RequestPara return new BaseResponse<>(AllBranchStatsSumResponse.of(targetMonth, totalStats)); } - // 6. 파트너 월별 거래 통계 조회 + // 6. 파트너 월별 거래 통계 조회 (Admin) /** * 파트너 월별 거래 통계 조회 - * - 조회 권한: Admin(모든 파트너), Agency(모든 파트너), Partner(본인만) + * - 조회 권한: Admin(모든 파트너) * - 특정 파트너의 월별 거래 통계를 기간 범위로 조회 */ - @Operation(summary = "파트너 월별 거래 통계 조회", description = "파트너 월별 거래 통계를 조회합니다.", tags = { "Stats - Admin", "Stats - Agency", "Stats - Partner" }) + @Operation(summary = "파트너 월별 거래 통계 조회", description = "파트너 월별 거래 통계를 조회합니다. (Admin)", tags = { "Stats - Admin" }) @GetMapping("/partner/{partnerUuid}") - @PreAuthorize("hasAnyAuthority('ADMIN', 'AGENCY', 'PERSONAL_PARTNER', 'CORPORATE_PARTNER')") + @PreAuthorize("hasAuthority('ADMIN')") @SecurityRequirement(name = "Bearer Auth") public BaseResponse getPartnerMonthlyStats( @PathVariable UUID partnerUuid, @@ -198,4 +196,86 @@ public BaseResponse getPartnerMonthlyStats( return new BaseResponse<>(PartnerMonthlyStatsListResponse.from(outDtoList)); } + + // 7. 파트너 월별 거래 통계 조회 - 로그인 파트너 (Partner) + @Operation(summary = "파트너 월별 거래 통계 조회(본인)", description = "로그인한 파트너의 월별 거래 통계를 조회합니다. (Partner)", tags = { "Stats - Partner" }) + @GetMapping("/partner/my") + @PreAuthorize("hasAnyAuthority('PERSONAL_PARTNER', 'CORPORATE_PARTNER')") + @SecurityRequirement(name = "Bearer Auth") + public BaseResponse getMyPartnerMonthlyStats( + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate, + @AuthenticationPrincipal CustomUserDetails authentication) { + + // mapping + GetPartnerMonthlyStatsInDto inDto = GetPartnerMonthlyStatsInDto.builder() + .targetPartnerUuid(authentication.getUserUuid()) + .requestorUuid(authentication.getUserUuid()) + .requestorType(authentication.getUserType()) + .startDate(startDate) + .endDate(endDate) + .build(); + + // 파트너 월별 거래 통계 조회 + List outDtoList = monthlyStatsFacade.getPartnerMonthlyStats(inDto); + + // response + return new BaseResponse<>(PartnerMonthlyStatsListResponse.from(outDtoList)); + } + + + // 8. 파트너 월별 품목 통계 조회 (Admin) + @Operation(summary = "파트너 월별 품목 통계 조회", description = "파트너 월별 품목 통계를 조회합니다. (Admin)", tags = { "Stats - Admin" }) + @GetMapping("/item/partner/{partnerUuid}") + @PreAuthorize("hasAuthority('ADMIN')") + @SecurityRequirement(name = "Bearer Auth") + public BaseResponse getPartnerMonthlyItemStats( + @PathVariable UUID partnerUuid, + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate, + @AuthenticationPrincipal CustomUserDetails authentication) { + + // mapping + GetPartnerMonthlyItemStatsInDto inDto = GetPartnerMonthlyItemStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(authentication.getUserUuid()) + .requestorType(authentication.getUserType()) + .startDate(startDate) + .endDate(endDate) + .build(); + + // 파트너 월별 품목 통계 조회 + List outDtoList = monthlyStatsFacade.getPartnerMonthlyItemStats(inDto); + + // response + return new BaseResponse<>(PartnerMonthlyItemStatsListResponse.from(outDtoList)); + } + + + // 9. 파트너 월별 품목 통계 조회 - 로그인 파트너 (Partner) + @Operation(summary = "파트너 월별 품목 통계 조회(본인)", description = "로그인한 파트너의 월별 품목 통계를 조회합니다. (Partner)", tags = { "Stats - Partner" }) + @GetMapping("/item/partner/my") + @PreAuthorize("hasAnyAuthority('PERSONAL_PARTNER', 'CORPORATE_PARTNER')") + @SecurityRequirement(name = "Bearer Auth") + public BaseResponse getMyPartnerMonthlyItemStats( + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate, + @AuthenticationPrincipal CustomUserDetails authentication) { + + // mapping + GetPartnerMonthlyItemStatsInDto inDto = GetPartnerMonthlyItemStatsInDto.builder() + .targetPartnerUuid(authentication.getUserUuid()) + .requestorUuid(authentication.getUserUuid()) + .requestorType(authentication.getUserType()) + .startDate(startDate) + .endDate(endDate) + .build(); + + // 파트너 월별 품목 통계 조회 + List outDtoList = monthlyStatsFacade.getPartnerMonthlyItemStats(inDto); + + // response + return new BaseResponse<>(PartnerMonthlyItemStatsListResponse.from(outDtoList)); + } + } diff --git a/src/main/java/greenfirst/be/stats/adapter/in/web/controller/GetTotalStatsController.java b/src/main/java/greenfirst/be/stats/adapter/in/web/controller/GetTotalStatsController.java new file mode 100644 index 0000000..9ebd737 --- /dev/null +++ b/src/main/java/greenfirst/be/stats/adapter/in/web/controller/GetTotalStatsController.java @@ -0,0 +1,137 @@ +package greenfirst.be.stats.adapter.in.web.controller; + + +import greenfirst.be.global.common.response.base.BaseResponse; +import greenfirst.be.global.common.security.CustomUserDetails; +import greenfirst.be.stats.adapter.in.web.response.PartnerItemTotalStatsListResponse; +import greenfirst.be.stats.adapter.in.web.response.PartnerTotalStatsResponse; +import greenfirst.be.stats.application.dto.in.total.GetPartnerItemTotalStatsInDto; +import greenfirst.be.stats.application.dto.in.total.GetPartnerTotalStatsInDto; +import greenfirst.be.stats.application.dto.out.PartnerItemTotalStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerTotalStatsOutDto; +import greenfirst.be.stats.application.facade.TotalStatsFacade; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + + +@RestController +@RequestMapping("/api/v1/stats/total") +@RequiredArgsConstructor +public class GetTotalStatsController { + + // facade + private final TotalStatsFacade totalStatsFacade; + // util + private final ModelMapper modelMapper; + + + /** + * 누적 통계 조회 컨트롤러 + * 1. 파트너 누적 통계 조회 (Admin) + * 2. 파트너 누적 통계 조회 - 로그인 파트너 (Partner) + * 3. 파트너 품목별 누적 통계 조회 (Admin) + * 4. 파트너 품목별 누적 통계 조회 - 로그인 파트너 (Partner) + */ + + // 1. 파트너 누적 통계 조회 (Admin) + @Operation(summary = "파트너 누적 통계 조회", description = "파트너 누적 통계를 조회합니다. (Admin)", tags = { "Stats - Admin" }) + @GetMapping("/partner/{partnerUuid}") + @PreAuthorize("hasAuthority('ADMIN')") + @SecurityRequirement(name = "Bearer Auth") + public BaseResponse getPartnerTotalStats( + @PathVariable UUID partnerUuid, + @AuthenticationPrincipal CustomUserDetails authentication) { + + // mapping + GetPartnerTotalStatsInDto inDto = GetPartnerTotalStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(authentication.getUserUuid()) + .requestorType(authentication.getUserType()) + .build(); + + // 누적 통계 조회 + PartnerTotalStatsOutDto outDto = totalStatsFacade.getPartnerTotalStats(inDto); + + // response + return new BaseResponse<>(PartnerTotalStatsResponse.from(outDto)); + } + + + // 2. 파트너 누적 통계 조회 - 로그인 파트너 (Partner) + @Operation(summary = "파트너 누적 통계 조회(본인)", description = "로그인한 파트너의 누적 통계를 조회합니다. (Partner)", tags = { "Stats - Partner" }) + @GetMapping("/partner/my") + @PreAuthorize("hasAnyAuthority('PERSONAL_PARTNER', 'CORPORATE_PARTNER')") + @SecurityRequirement(name = "Bearer Auth") + public BaseResponse getMyPartnerTotalStats( + @AuthenticationPrincipal CustomUserDetails authentication) { + + // mapping + GetPartnerTotalStatsInDto inDto = GetPartnerTotalStatsInDto.builder() + .targetPartnerUuid(authentication.getUserUuid()) + .requestorUuid(authentication.getUserUuid()) + .requestorType(authentication.getUserType()) + .build(); + + // 누적 통계 조회 + PartnerTotalStatsOutDto outDto = totalStatsFacade.getPartnerTotalStats(inDto); + + // response + return new BaseResponse<>(PartnerTotalStatsResponse.from(outDto)); + } + + + // 3. 파트너 품목별 누적 통계 조회 (Admin) + @Operation(summary = "파트너 품목별 누적 통계 조회", description = "파트너 품목별 누적 통계를 조회합니다. (Admin)", tags = { "Stats - Admin" }) + @GetMapping("/item/partner/{partnerUuid}") + @PreAuthorize("hasAuthority('ADMIN')") + @SecurityRequirement(name = "Bearer Auth") + public BaseResponse getPartnerItemTotalStats( + @PathVariable UUID partnerUuid, + @AuthenticationPrincipal CustomUserDetails authentication) { + + // mapping + GetPartnerItemTotalStatsInDto inDto = GetPartnerItemTotalStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(authentication.getUserUuid()) + .requestorType(authentication.getUserType()) + .build(); + + // 품목별 누적 통계 조회 + List outDtoList = totalStatsFacade.getPartnerItemTotalStats(inDto); + + // response + return new BaseResponse<>(PartnerItemTotalStatsListResponse.from(outDtoList)); + } + + + // 4. 파트너 품목별 누적 통계 조회 - 로그인 파트너 (Partner) + @Operation(summary = "파트너 품목별 누적 통계 조회(본인)", description = "로그인한 파트너의 품목별 누적 통계를 조회합니다. (Partner)", tags = { "Stats - Partner" }) + @GetMapping("/item/partner/my") + @PreAuthorize("hasAnyAuthority('PERSONAL_PARTNER', 'CORPORATE_PARTNER')") + @SecurityRequirement(name = "Bearer Auth") + public BaseResponse getMyPartnerItemTotalStats( + @AuthenticationPrincipal CustomUserDetails authentication) { + + // mapping + GetPartnerItemTotalStatsInDto inDto = GetPartnerItemTotalStatsInDto.builder() + .targetPartnerUuid(authentication.getUserUuid()) + .requestorUuid(authentication.getUserUuid()) + .requestorType(authentication.getUserType()) + .build(); + + // 품목별 누적 통계 조회 + List outDtoList = totalStatsFacade.getPartnerItemTotalStats(inDto); + + // response + return new BaseResponse<>(PartnerItemTotalStatsListResponse.from(outDtoList)); + } + +} diff --git a/src/main/java/greenfirst/be/stats/adapter/in/web/response/PartnerDailyItemStatsListResponse.java b/src/main/java/greenfirst/be/stats/adapter/in/web/response/PartnerDailyItemStatsListResponse.java new file mode 100644 index 0000000..d5db3f9 --- /dev/null +++ b/src/main/java/greenfirst/be/stats/adapter/in/web/response/PartnerDailyItemStatsListResponse.java @@ -0,0 +1,76 @@ +package greenfirst.be.stats.adapter.in.web.response; + + +import greenfirst.be.stats.application.dto.out.PartnerDailyItemStatsOutDto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class PartnerDailyItemStatsListResponse { + + private List dailyStatsList; + + + // 정적 팩토리 메서드 + public static PartnerDailyItemStatsListResponse from(List dataList) { + List responseList = dataList.stream() + .map(data -> { + return PartnerDailyItemStatsResponse.builder() + .statsDate(data.getStatsDate()) + .itemId(data.getItemId()) + .itemName(data.getItemName()) + .itemCode(data.getItemCode()) + .itemTypeId(data.getItemTypeId()) + .itemTypeName(data.getItemTypeName()) + .totalTradeCount(data.getTotalTradeCount()) + .totalPayloadWeight(data.getTotalPayloadWeight()) + .totalTradeAmount(data.getTotalTradeAmount()) + .build(); + }) + .toList(); + + return new PartnerDailyItemStatsListResponse(responseList); + } + + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + private static class PartnerDailyItemStatsResponse { + + /** + * 파트너 일별 품목 통계 응답 + * - 통계 날짜 + * - 품목 id + * - 품목명 + * - 품목 코드 + * - 품목 종류 id + * - 품목 종류명 + * - 총 거래 수 + * - 총 거래 실중량 + * - 총 거래 금액 + */ + + private LocalDate statsDate; + private Long itemId; + private String itemName; + private String itemCode; + private Integer itemTypeId; + private String itemTypeName; + private Long totalTradeCount; + private BigDecimal totalPayloadWeight; + private BigDecimal totalTradeAmount; + + } + +} diff --git a/src/main/java/greenfirst/be/stats/adapter/in/web/response/PartnerItemTotalStatsListResponse.java b/src/main/java/greenfirst/be/stats/adapter/in/web/response/PartnerItemTotalStatsListResponse.java new file mode 100644 index 0000000..6d1844c --- /dev/null +++ b/src/main/java/greenfirst/be/stats/adapter/in/web/response/PartnerItemTotalStatsListResponse.java @@ -0,0 +1,85 @@ +package greenfirst.be.stats.adapter.in.web.response; + + +import greenfirst.be.stats.application.dto.out.PartnerItemTotalStatsOutDto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; + + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class PartnerItemTotalStatsListResponse { + + private List itemStatsList; + + + // 정적 팩토리 메서드 + public static PartnerItemTotalStatsListResponse from(List dataList) { + + List responseList = dataList.stream() + .map(data -> { + return PartnerItemTotalStatsResponse.builder() + .itemId(data.getItemId()) + .itemName(data.getItemName()) + .itemCode(data.getItemCode()) + .itemTypeId(data.getItemTypeId()) + .itemTypeName(data.getItemTypeName()) + .totalTradeCount(data.getTotalTradeCount()) + .totalPayloadWeight(data.getTotalPayloadWeight()) + .totalTradeAmount(data.getTotalTradeAmount()) + .lastTradeTime(trimToSeconds(data.getLastTradeTime())) + .build(); + }) + .toList(); + + return new PartnerItemTotalStatsListResponse(responseList); + } + + private static LocalDateTime trimToSeconds(LocalDateTime time) { + if (time == null) { + return null; + } + return time.truncatedTo(ChronoUnit.SECONDS); + } + + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + private static class PartnerItemTotalStatsResponse { + + /** + * 파트너 품목별 누적 통계 응답 + * - 품목 id + * - 품목명 + * - 품목 코드 + * - 품목 종류 id + * - 품목 종류명 + * - 총 거래 횟수 + * - 총 거래 실중량(net) + * - 총 거래 금액 + * - 최근 거래 시간 (초 단위) + */ + + private Long itemId; + private String itemName; + private String itemCode; + private Integer itemTypeId; + private String itemTypeName; + private Long totalTradeCount; + private BigDecimal totalPayloadWeight; + private BigDecimal totalTradeAmount; + private LocalDateTime lastTradeTime; + + } + +} diff --git a/src/main/java/greenfirst/be/stats/adapter/in/web/response/PartnerMonthlyItemStatsListResponse.java b/src/main/java/greenfirst/be/stats/adapter/in/web/response/PartnerMonthlyItemStatsListResponse.java new file mode 100644 index 0000000..25049ef --- /dev/null +++ b/src/main/java/greenfirst/be/stats/adapter/in/web/response/PartnerMonthlyItemStatsListResponse.java @@ -0,0 +1,77 @@ +package greenfirst.be.stats.adapter.in.web.response; + + +import greenfirst.be.stats.application.dto.out.PartnerMonthlyItemStatsOutDto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.YearMonth; +import java.util.List; + + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class PartnerMonthlyItemStatsListResponse { + + private List monthlyStatsList; + + + // 정적 팩토리 메서드 + public static PartnerMonthlyItemStatsListResponse from(List dataList) { + + List responseList = dataList.stream() + .map(data -> { + return PartnerMonthlyItemStatsResponse.builder() + .statsMonth(data.getStatsMonth()) + .itemId(data.getItemId()) + .itemName(data.getItemName()) + .itemCode(data.getItemCode()) + .itemTypeId(data.getItemTypeId()) + .itemTypeName(data.getItemTypeName()) + .totalTradeCount(data.getTotalTradeCount()) + .totalPayloadWeight(data.getTotalPayloadWeight()) + .totalTradeAmount(data.getTotalTradeAmount()) + .build(); + }) + .toList(); + + return new PartnerMonthlyItemStatsListResponse(responseList); + } + + + @Getter + @Builder + @AllArgsConstructor + @NoArgsConstructor + private static class PartnerMonthlyItemStatsResponse { + + /** + * 파트너 월별 품목 통계 응답 + * - 통계 월 + * - 품목 id + * - 품목명 + * - 품목 코드 + * - 품목 종류 id + * - 품목 종류명 + * - 총 거래 수 + * - 총 거래 실중량 + * - 총 거래 금액 + */ + + private YearMonth statsMonth; + private Long itemId; + private String itemName; + private String itemCode; + private Integer itemTypeId; + private String itemTypeName; + private Long totalTradeCount; + private BigDecimal totalPayloadWeight; + private BigDecimal totalTradeAmount; + + } + +} diff --git a/src/main/java/greenfirst/be/stats/adapter/in/web/response/PartnerTotalStatsResponse.java b/src/main/java/greenfirst/be/stats/adapter/in/web/response/PartnerTotalStatsResponse.java new file mode 100644 index 0000000..16ad3ac --- /dev/null +++ b/src/main/java/greenfirst/be/stats/adapter/in/web/response/PartnerTotalStatsResponse.java @@ -0,0 +1,60 @@ +package greenfirst.be.stats.adapter.in.web.response; + + +import greenfirst.be.stats.application.dto.out.PartnerTotalStatsOutDto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; + + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PartnerTotalStatsResponse { + + /** + * 파트너 누적 통계 응답 + * - 총 거래 횟수 + * - 총 거래 실중량(net) + * - 총 거래 금액 + * - 최근 거래 시간 (초 단위) + */ + + private Long totalTradeCount; + private BigDecimal totalPayloadWeight; + private BigDecimal totalTradeAmount; + private LocalDateTime lastTradeTime; + + + public static PartnerTotalStatsResponse from(PartnerTotalStatsOutDto outDto) { + if (outDto == null) { + return PartnerTotalStatsResponse.builder() + .totalTradeCount(0L) + .totalPayloadWeight(BigDecimal.ZERO) + .totalTradeAmount(BigDecimal.ZERO) + .lastTradeTime(null) + .build(); + } + + return PartnerTotalStatsResponse.builder() + .totalTradeCount(outDto.getTotalTradeCount()) + .totalPayloadWeight(outDto.getTotalPayloadWeight()) + .totalTradeAmount(outDto.getTotalTradeAmount()) + .lastTradeTime(trimToSeconds(outDto.getLastTradeTime())) + .build(); + } + + private static LocalDateTime trimToSeconds(LocalDateTime time) { + if (time == null) { + return null; + } + return time.truncatedTo(ChronoUnit.SECONDS); + } + +} diff --git a/src/main/java/greenfirst/be/stats/adapter/out/persistence/querydsl/DailyStatsQueryDslRepository.java b/src/main/java/greenfirst/be/stats/adapter/out/persistence/querydsl/DailyStatsQueryDslRepository.java index ddd051c..bb72043 100644 --- a/src/main/java/greenfirst/be/stats/adapter/out/persistence/querydsl/DailyStatsQueryDslRepository.java +++ b/src/main/java/greenfirst/be/stats/adapter/out/persistence/querydsl/DailyStatsQueryDslRepository.java @@ -14,12 +14,15 @@ import greenfirst.be.stats.adapter.out.persistence.querydsl.util.StatsQueryDslUtil; import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyStatsInDto; +import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyTradeStatsInDto; import greenfirst.be.stats.application.dto.out.BranchDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.BranchDailyStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerDailyTradeStatsOutDto; import greenfirst.be.stats.application.dto.out.QBranchDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.QBranchDailyStatsOutDto; +import greenfirst.be.stats.application.dto.out.QPartnerDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.QPartnerDailyTradeStatsOutDto; import greenfirst.be.trade.adapter.out.persistence.entity.QTradeEntity; import greenfirst.be.trade.adapter.out.persistence.entity.QTradeItemEntity; @@ -125,7 +128,7 @@ public List getBranchDailyItemStats(GetBranchDailyIt qItem.itemTypeId, qItemType.typeName, qTradeItem.count(), - qTradeItem.weight.sum().subtract(Expressions.numberTemplate(BigDecimal.class, "COALESCE({0}, 0)", qTradeItem.weightLoss.sum())), + qTradeItem.netPayloadWeight.sum(), qTradeItem.itemTradeAmount.sum() )) .from(qTrade) @@ -178,4 +181,48 @@ public List getPartnerDailyTradeStats(GetPartnerDa return content; } + // 파트너별 일별 품목 통계 조회 + public List getPartnerDailyItemStats(GetPartnerDailyItemStatsInDto inDto) { + + // 요청 파라미터 + UUID targetPartnerUuid = inDto.getTargetPartnerUuid(); + LocalDate startDate = inDto.getStartDate(); + LocalDate endDate = inDto.getEndDate(); + + // 조건 + BooleanBuilder where = new BooleanBuilder() + // 파트너 필터 (sellerUuid 기준) + .and(statsUtil.partnerFilter(qTrade, targetPartnerUuid)) + // 날짜 필터 + .and(statsUtil.dateFilter(qTrade, startDate, endDate)); + + // 날짜 그룹핑 표현식: qTrade.tradeAt(YYYY-MM-DD hh:mm:ss)을 -> DATE() 함수로 날짜 부분만 추출(YYYY-MM-DD) + DateTemplate statsDate = Expressions.dateTemplate(Date.class, "DATE({0})", qTrade.tradeAt); + + // content + List content = queryFactory + .select(new QPartnerDailyItemStatsOutDto( + statsDate, + qItem.id, + qItem.itemName, + qItem.itemCode, + qItem.itemTypeId, + qItemType.typeName, + qTradeItem.count(), + qTradeItem.netPayloadWeight.sum(), + qTradeItem.itemTradeAmount.sum() + )) + .from(qTrade) + .join(qTradeItem).on(qTradeItem.trade.eq(qTrade)) + .join(qItem).on(qItem.id.eq(qTradeItem.itemId)) + .join(qItemType).on(qItemType.itemTypeId.eq(qItem.itemTypeId)) + .where(where) + .groupBy(statsDate, qItem.id, qItem.itemName, qItem.itemCode, qItem.itemTypeId, qItemType.typeName) + .orderBy(statsDate.asc(), qItem.id.asc()) + .fetch(); + + // result + return content; + } + } diff --git a/src/main/java/greenfirst/be/stats/adapter/out/persistence/querydsl/MonthlyStatsQueryDslRepository.java b/src/main/java/greenfirst/be/stats/adapter/out/persistence/querydsl/MonthlyStatsQueryDslRepository.java index 0147c30..85dfea0 100644 --- a/src/main/java/greenfirst/be/stats/adapter/out/persistence/querydsl/MonthlyStatsQueryDslRepository.java +++ b/src/main/java/greenfirst/be/stats/adapter/out/persistence/querydsl/MonthlyStatsQueryDslRepository.java @@ -15,6 +15,7 @@ import greenfirst.be.stats.adapter.out.persistence.querydsl.util.StatsQueryDslUtil; import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyStatsInDto; +import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyStatsInDto; import greenfirst.be.stats.application.dto.out.*; import greenfirst.be.trade.adapter.out.persistence.entity.QTradeEntity; @@ -166,7 +167,7 @@ public List getBranchMonthlyItemStats(GetBranchMon qItem.itemTypeId, qItemType.typeName, qTradeItem.count(), - qTradeItem.weight.sum().subtract(Expressions.numberTemplate(BigDecimal.class, "COALESCE({0}, 0)", qTradeItem.weightLoss.sum())), + qTradeItem.netPayloadWeight.sum(), qTradeItem.itemTradeAmount.sum() )) .from(qTrade) @@ -483,4 +484,46 @@ public List getPartnerMonthlyStats(GetPartnerMonthlyS .fetch(); } + /** + * 파트너 월별 품목 통계 조회 + * - TradeItemEntity 기준으로 월별 합산 + * - 기간 범위 내의 월별 품목 통계 조회 + */ + public List getPartnerMonthlyItemStats(GetPartnerMonthlyItemStatsInDto inDto) { + UUID partnerUuid = inDto.getTargetPartnerUuid(); + LocalDate startDate = inDto.getStartDate(); + LocalDate endDate = inDto.getEndDate(); + + // 조건 + BooleanBuilder where = new BooleanBuilder() + // 파트너 필터 (sellerUuid 기준) + .and(statsUtil.partnerFilter(qTrade, partnerUuid)) + // 날짜 필터 + .and(statsUtil.dateFilter(qTrade, startDate, endDate)); + + // 날짜 그룹핑 표현식: qTrade.tradeAt을 -> DATE() 함수로 날짜 부분만 추출 + DateTemplate statsDate = Expressions.dateTemplate(Date.class, "DATE({0})", qTrade.tradeAt); + + return queryFactory + .select(new QPartnerMonthlyItemStatsOutDto( + statsDate, + qItem.id, + qItem.itemName, + qItem.itemCode, + qItem.itemTypeId, + qItemType.typeName, + qTradeItem.count(), + qTradeItem.netPayloadWeight.sum(), + qTradeItem.itemTradeAmount.sum() + )) + .from(qTrade) + .join(qTradeItem).on(qTradeItem.trade.eq(qTrade)) + .join(qItem).on(qItem.id.eq(qTradeItem.itemId)) + .join(qItemType).on(qItemType.itemTypeId.eq(qItem.itemTypeId)) + .where(where) + .groupBy(statsDate.year(), statsDate.month(), qItem.id, qItem.itemName, qItem.itemCode, qItem.itemTypeId, qItemType.typeName) + .orderBy(statsDate.year().asc(), statsDate.month().asc(), qItem.id.asc()) + .fetch(); + } + } diff --git a/src/main/java/greenfirst/be/stats/adapter/out/persistence/querydsl/TotalStatsQueryDslRepository.java b/src/main/java/greenfirst/be/stats/adapter/out/persistence/querydsl/TotalStatsQueryDslRepository.java new file mode 100644 index 0000000..939f78b --- /dev/null +++ b/src/main/java/greenfirst/be/stats/adapter/out/persistence/querydsl/TotalStatsQueryDslRepository.java @@ -0,0 +1,100 @@ +package greenfirst.be.stats.adapter.out.persistence.querydsl; + + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.dsl.DateTimeExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import greenfirst.be.stats.adapter.out.persistence.querydsl.util.StatsQueryDslUtil; +import greenfirst.be.stats.application.dto.in.total.GetPartnerItemTotalStatsInDto; +import greenfirst.be.stats.application.dto.in.total.GetPartnerTotalStatsInDto; +import greenfirst.be.stats.application.dto.out.PartnerItemTotalStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerTotalStatsOutDto; +import greenfirst.be.stats.application.dto.out.QPartnerItemTotalStatsOutDto; +import greenfirst.be.stats.application.dto.out.QPartnerTotalStatsOutDto; +import greenfirst.be.item.adapter.out.persistence.entity.QItemEntity; +import greenfirst.be.item.adapter.out.persistence.entity.QItemTypeEntity; +import greenfirst.be.trade.adapter.out.persistence.entity.QTradeEntity; +import greenfirst.be.trade.adapter.out.persistence.entity.QTradeItemEntity; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + + +@Repository +@RequiredArgsConstructor +public class TotalStatsQueryDslRepository { + + // querydsl + private final JPAQueryFactory queryFactory; + private final StatsQueryDslUtil statsUtil; + // query value + private final QTradeEntity qTrade = QTradeEntity.tradeEntity; + private final QTradeItemEntity qTradeItem = QTradeItemEntity.tradeItemEntity; + private final QItemEntity qItem = QItemEntity.itemEntity; + private final QItemTypeEntity qItemType = QItemTypeEntity.itemTypeEntity; + + + // 파트너 누적 통계 조회 + public PartnerTotalStatsOutDto getPartnerTotalStats(GetPartnerTotalStatsInDto inDto) { + + UUID targetPartnerUuid = inDto.getTargetPartnerUuid(); + + BooleanBuilder where = new BooleanBuilder() + .and(statsUtil.partnerFilter(qTrade, targetPartnerUuid)); + + PartnerTotalStatsOutDto result = queryFactory + .select(new QPartnerTotalStatsOutDto( + qTrade.count(), + qTrade.netPayloadWeight.sum().coalesce(BigDecimal.ZERO), + qTrade.tradeAmount.sum().coalesce(BigDecimal.ZERO), + qTrade.tradeAt.max() + )) + .from(qTrade) + .where(where) + .fetchOne(); + + if (result == null) { + return new PartnerTotalStatsOutDto(0L, BigDecimal.ZERO, BigDecimal.ZERO, null); + } + + return result; + } + + + // 파트너 품목별 누적 통계 조회 + public List getPartnerItemTotalStats(GetPartnerItemTotalStatsInDto inDto) { + + UUID targetPartnerUuid = inDto.getTargetPartnerUuid(); + + BooleanBuilder where = new BooleanBuilder() + .and(statsUtil.partnerFilter(qTrade, targetPartnerUuid)); + + DateTimeExpression lastTradeTime = qTrade.tradeAt.max(); + + return queryFactory + .select(new QPartnerItemTotalStatsOutDto( + qItem.id, + qItem.itemName, + qItem.itemCode, + qItem.itemTypeId, + qItemType.typeName, + qTradeItem.count(), + qTradeItem.netPayloadWeight.sum(), + qTradeItem.itemTradeAmount.sum(), + lastTradeTime + )) + .from(qTrade) + .join(qTradeItem).on(qTradeItem.trade.eq(qTrade)) + .join(qItem).on(qItem.id.eq(qTradeItem.itemId)) + .join(qItemType).on(qItemType.itemTypeId.eq(qItem.itemTypeId)) + .where(where) + .groupBy(qItem.id, qItem.itemName, qItem.itemCode, qItem.itemTypeId, qItemType.typeName) + .orderBy(qItem.id.asc()) + .fetch(); + } + +} diff --git a/src/main/java/greenfirst/be/stats/adapter/out/persistence/repository/daily/DailyStatsQueryRepositoryImpl.java b/src/main/java/greenfirst/be/stats/adapter/out/persistence/repository/daily/DailyStatsQueryRepositoryImpl.java index 8db874b..d7704d4 100644 --- a/src/main/java/greenfirst/be/stats/adapter/out/persistence/repository/daily/DailyStatsQueryRepositoryImpl.java +++ b/src/main/java/greenfirst/be/stats/adapter/out/persistence/repository/daily/DailyStatsQueryRepositoryImpl.java @@ -4,9 +4,11 @@ import greenfirst.be.stats.adapter.out.persistence.querydsl.DailyStatsQueryDslRepository; import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyStatsInDto; +import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyTradeStatsInDto; import greenfirst.be.stats.application.dto.out.BranchDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.BranchDailyStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerDailyTradeStatsOutDto; import greenfirst.be.stats.application.port.out.daily.DailyStatsQueryRepository; import lombok.RequiredArgsConstructor; @@ -48,4 +50,10 @@ public List getPartnerDailyTradeStats(GetPartnerDa return dailyStatsQueryDslRepository.getPartnerDailyTradeStats(inDto); } + // 파트너별 일별 품목 통계 조회 + @Override + public List getPartnerDailyItemStats(GetPartnerDailyItemStatsInDto inDto) { + return dailyStatsQueryDslRepository.getPartnerDailyItemStats(inDto); + } + } diff --git a/src/main/java/greenfirst/be/stats/adapter/out/persistence/repository/monthly/MonthlyStatsQueryRepositoryImpl.java b/src/main/java/greenfirst/be/stats/adapter/out/persistence/repository/monthly/MonthlyStatsQueryRepositoryImpl.java index ab5c706..5fac856 100644 --- a/src/main/java/greenfirst/be/stats/adapter/out/persistence/repository/monthly/MonthlyStatsQueryRepositoryImpl.java +++ b/src/main/java/greenfirst/be/stats/adapter/out/persistence/repository/monthly/MonthlyStatsQueryRepositoryImpl.java @@ -4,10 +4,12 @@ import greenfirst.be.stats.adapter.out.persistence.querydsl.MonthlyStatsQueryDslRepository; import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyStatsInDto; +import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyStatsInDto; import greenfirst.be.stats.application.dto.out.AllBranchStatsSumOutDto; import greenfirst.be.stats.application.dto.out.BranchMonthlyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.BranchMonthlyStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerMonthlyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerMonthlyStatsOutDto; import greenfirst.be.stats.application.port.out.monthly.MonthlyStatsQueryRepository; import lombok.RequiredArgsConstructor; @@ -55,4 +57,10 @@ public List getPartnerMonthlyStats(GetPartnerMonthlyS return monthlyStatsQueryDslRepository.getPartnerMonthlyStats(inDto); } + // 파트너 월별 품목 통계 조회 + @Override + public List getPartnerMonthlyItemStats(GetPartnerMonthlyItemStatsInDto inDto) { + return monthlyStatsQueryDslRepository.getPartnerMonthlyItemStats(inDto); + } + } diff --git a/src/main/java/greenfirst/be/stats/adapter/out/persistence/repository/total/TotalStatsQueryRepositoryImpl.java b/src/main/java/greenfirst/be/stats/adapter/out/persistence/repository/total/TotalStatsQueryRepositoryImpl.java new file mode 100644 index 0000000..86ff1ae --- /dev/null +++ b/src/main/java/greenfirst/be/stats/adapter/out/persistence/repository/total/TotalStatsQueryRepositoryImpl.java @@ -0,0 +1,38 @@ +package greenfirst.be.stats.adapter.out.persistence.repository.total; + + +import greenfirst.be.stats.adapter.out.persistence.querydsl.TotalStatsQueryDslRepository; +import greenfirst.be.stats.application.dto.in.total.GetPartnerItemTotalStatsInDto; +import greenfirst.be.stats.application.dto.in.total.GetPartnerTotalStatsInDto; +import greenfirst.be.stats.application.dto.out.PartnerItemTotalStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerTotalStatsOutDto; +import greenfirst.be.stats.application.port.out.total.TotalStatsQueryRepository; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Repository; + +import java.util.List; + + +@Repository +@RequiredArgsConstructor +public class TotalStatsQueryRepositoryImpl implements TotalStatsQueryRepository { + + // querydsl + private final TotalStatsQueryDslRepository totalStatsQueryDslRepository; + // util + private final ModelMapper modelMapper; + + + @Override + public PartnerTotalStatsOutDto getPartnerTotalStats(GetPartnerTotalStatsInDto inDto) { + return totalStatsQueryDslRepository.getPartnerTotalStats(inDto); + } + + + @Override + public List getPartnerItemTotalStats(GetPartnerItemTotalStatsInDto inDto) { + return totalStatsQueryDslRepository.getPartnerItemTotalStats(inDto); + } + +} diff --git a/src/main/java/greenfirst/be/stats/application/dto/in/daily/GetPartnerDailyItemStatsInDto.java b/src/main/java/greenfirst/be/stats/application/dto/in/daily/GetPartnerDailyItemStatsInDto.java new file mode 100644 index 0000000..3876002 --- /dev/null +++ b/src/main/java/greenfirst/be/stats/application/dto/in/daily/GetPartnerDailyItemStatsInDto.java @@ -0,0 +1,41 @@ +package greenfirst.be.stats.application.dto.in.daily; + + +import greenfirst.be.global.common.enums.common.UserType; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.UUID; + + +@Getter +public class GetPartnerDailyItemStatsInDto { + + /** + * 파트너별 일별 품목 통계 조회 요청 DTO + * - targetPartnerUuid: 조회 대상 파트너 UUID + * - requestorUuid: 요청자 UUID (보안 컨텍스트) + * - requestorType: 요청자 유저 타입 (접근 제어) + * - startDate: 조회 시작 날짜 + * - endDate: 조회 종료 날짜 + */ + + private UUID targetPartnerUuid; + private UUID requestorUuid; + private UserType requestorType; + private LocalDate startDate; + private LocalDate endDate; + + + @Builder + public GetPartnerDailyItemStatsInDto(UUID targetPartnerUuid, UUID requestorUuid, + UserType requestorType, LocalDate startDate, LocalDate endDate) { + this.targetPartnerUuid = targetPartnerUuid; + this.requestorUuid = requestorUuid; + this.requestorType = requestorType; + this.startDate = startDate; + this.endDate = endDate; + } + +} diff --git a/src/main/java/greenfirst/be/stats/application/dto/in/monthly/GetPartnerMonthlyItemStatsInDto.java b/src/main/java/greenfirst/be/stats/application/dto/in/monthly/GetPartnerMonthlyItemStatsInDto.java new file mode 100644 index 0000000..fecc1ab --- /dev/null +++ b/src/main/java/greenfirst/be/stats/application/dto/in/monthly/GetPartnerMonthlyItemStatsInDto.java @@ -0,0 +1,41 @@ +package greenfirst.be.stats.application.dto.in.monthly; + + +import greenfirst.be.global.common.enums.common.UserType; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDate; +import java.util.UUID; + + +@Getter +public class GetPartnerMonthlyItemStatsInDto { + + /** + * 파트너별 월별 품목 통계 조회 요청 DTO + * - targetPartnerUuid: 조회 대상 파트너 UUID + * - requestorUuid: 요청자 UUID (보안 컨텍스트) + * - requestorType: 요청자 유저 타입 (접근 제어) + * - startDate: 조회 시작 날짜 + * - endDate: 조회 종료 날짜 + */ + + private UUID targetPartnerUuid; + private UUID requestorUuid; + private UserType requestorType; + private LocalDate startDate; + private LocalDate endDate; + + + @Builder + public GetPartnerMonthlyItemStatsInDto(UUID targetPartnerUuid, UUID requestorUuid, + UserType requestorType, LocalDate startDate, LocalDate endDate) { + this.targetPartnerUuid = targetPartnerUuid; + this.requestorUuid = requestorUuid; + this.requestorType = requestorType; + this.startDate = startDate; + this.endDate = endDate; + } + +} diff --git a/src/main/java/greenfirst/be/stats/application/dto/in/total/GetPartnerItemTotalStatsInDto.java b/src/main/java/greenfirst/be/stats/application/dto/in/total/GetPartnerItemTotalStatsInDto.java new file mode 100644 index 0000000..d701a41 --- /dev/null +++ b/src/main/java/greenfirst/be/stats/application/dto/in/total/GetPartnerItemTotalStatsInDto.java @@ -0,0 +1,33 @@ +package greenfirst.be.stats.application.dto.in.total; + + +import greenfirst.be.global.common.enums.common.UserType; +import lombok.Builder; +import lombok.Getter; + +import java.util.UUID; + + +@Getter +public class GetPartnerItemTotalStatsInDto { + + /** + * 파트너 품목별 누적 통계 조회 요청 DTO + * - targetPartnerUuid: 조회 대상 파트너 UUID + * - requestorUuid: 요청자 UUID (보안 컨텍스트) + * - requestorType: 요청자 유저 타입 (접근 제어) + */ + + private UUID targetPartnerUuid; + private UUID requestorUuid; + private UserType requestorType; + + + @Builder + public GetPartnerItemTotalStatsInDto(UUID targetPartnerUuid, UUID requestorUuid, UserType requestorType) { + this.targetPartnerUuid = targetPartnerUuid; + this.requestorUuid = requestorUuid; + this.requestorType = requestorType; + } + +} diff --git a/src/main/java/greenfirst/be/stats/application/dto/in/total/GetPartnerTotalStatsInDto.java b/src/main/java/greenfirst/be/stats/application/dto/in/total/GetPartnerTotalStatsInDto.java new file mode 100644 index 0000000..b29d633 --- /dev/null +++ b/src/main/java/greenfirst/be/stats/application/dto/in/total/GetPartnerTotalStatsInDto.java @@ -0,0 +1,33 @@ +package greenfirst.be.stats.application.dto.in.total; + + +import greenfirst.be.global.common.enums.common.UserType; +import lombok.Builder; +import lombok.Getter; + +import java.util.UUID; + + +@Getter +public class GetPartnerTotalStatsInDto { + + /** + * 파트너 누적 통계 조회 요청 DTO + * - targetPartnerUuid: 조회 대상 파트너 UUID + * - requestorUuid: 요청자 UUID (보안 컨텍스트) + * - requestorType: 요청자 유저 타입 (접근 제어) + */ + + private UUID targetPartnerUuid; + private UUID requestorUuid; + private UserType requestorType; + + + @Builder + public GetPartnerTotalStatsInDto(UUID targetPartnerUuid, UUID requestorUuid, UserType requestorType) { + this.targetPartnerUuid = targetPartnerUuid; + this.requestorUuid = requestorUuid; + this.requestorType = requestorType; + } + +} diff --git a/src/main/java/greenfirst/be/stats/application/dto/out/PartnerDailyItemStatsOutDto.java b/src/main/java/greenfirst/be/stats/application/dto/out/PartnerDailyItemStatsOutDto.java new file mode 100644 index 0000000..a786da8 --- /dev/null +++ b/src/main/java/greenfirst/be/stats/application/dto/out/PartnerDailyItemStatsOutDto.java @@ -0,0 +1,53 @@ +package greenfirst.be.stats.application.dto.out; + + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; + +import java.math.BigDecimal; +import java.sql.Date; +import java.time.LocalDate; + + +@Getter +public class PartnerDailyItemStatsOutDto { + + /** + * 파트너 일별 품목 통계 DTO + * - 통계 날짜 (거래날짜) + * - 품목 id + * - 품목명 + * - 품목 코드 + * - 품목 종류 id + * - 품목 종류명 + * - 총 품목 거래 수 (buy 기준) + * - 총 품목 거래 실중량 + * - 총 품목 거래 금액 + */ + + private LocalDate statsDate; + private Long itemId; + private String itemName; + private String itemCode; + private Integer itemTypeId; + private String itemTypeName; + private Long totalTradeCount; + private BigDecimal totalPayloadWeight; + private BigDecimal totalTradeAmount; + + + @QueryProjection + public PartnerDailyItemStatsOutDto(Date statsDate, Long itemId, String itemName, String itemCode, Integer itemTypeId, + String itemTypeName, Long totalTradeCount, BigDecimal totalPayloadWeight, BigDecimal totalTradeAmount) { + this.statsDate = statsDate.toLocalDate(); + this.itemId = itemId; + this.itemName = itemName; + this.itemCode = itemCode; + this.itemTypeId = itemTypeId; + this.itemTypeName = itemTypeName; + this.totalTradeCount = totalTradeCount; + this.totalPayloadWeight = totalPayloadWeight; + this.totalTradeAmount = totalTradeAmount; + } + +} diff --git a/src/main/java/greenfirst/be/stats/application/dto/out/PartnerItemTotalStatsOutDto.java b/src/main/java/greenfirst/be/stats/application/dto/out/PartnerItemTotalStatsOutDto.java new file mode 100644 index 0000000..e42dfe9 --- /dev/null +++ b/src/main/java/greenfirst/be/stats/application/dto/out/PartnerItemTotalStatsOutDto.java @@ -0,0 +1,53 @@ +package greenfirst.be.stats.application.dto.out; + + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + + +@Getter +public class PartnerItemTotalStatsOutDto { + + /** + * 파트너 품목별 누적 통계 DTO + * - 품목 id + * - 품목명 + * - 품목 코드 + * - 품목 종류 id + * - 품목 종류명 + * - 총 거래 횟수 + * - 총 거래 실중량(net) + * - 총 거래 금액 + * - 최근 거래 시간 + */ + + private Long itemId; + private String itemName; + private String itemCode; + private Integer itemTypeId; + private String itemTypeName; + private Long totalTradeCount; + private BigDecimal totalPayloadWeight; + private BigDecimal totalTradeAmount; + private LocalDateTime lastTradeTime; + + + @QueryProjection + public PartnerItemTotalStatsOutDto(Long itemId, String itemName, String itemCode, Integer itemTypeId, + String itemTypeName, Long totalTradeCount, BigDecimal totalPayloadWeight, + BigDecimal totalTradeAmount, LocalDateTime lastTradeTime) { + this.itemId = itemId; + this.itemName = itemName; + this.itemCode = itemCode; + this.itemTypeId = itemTypeId; + this.itemTypeName = itemTypeName; + this.totalTradeCount = totalTradeCount; + this.totalPayloadWeight = totalPayloadWeight; + this.totalTradeAmount = totalTradeAmount; + this.lastTradeTime = lastTradeTime; + } + +} diff --git a/src/main/java/greenfirst/be/stats/application/dto/out/PartnerMonthlyItemStatsOutDto.java b/src/main/java/greenfirst/be/stats/application/dto/out/PartnerMonthlyItemStatsOutDto.java new file mode 100644 index 0000000..5060695 --- /dev/null +++ b/src/main/java/greenfirst/be/stats/application/dto/out/PartnerMonthlyItemStatsOutDto.java @@ -0,0 +1,62 @@ +package greenfirst.be.stats.application.dto.out; + + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; + +import java.math.BigDecimal; +import java.sql.Date; +import java.time.LocalDate; +import java.time.YearMonth; + + +@Getter +public class PartnerMonthlyItemStatsOutDto { + + /** + * 파트너 월별 품목 통계 DTO + * - 통계 월 + * - 품목 id + * - 품목명 + * - 품목 코드 + * - 품목 종류 id + * - 품목 종류명 + * - 총 품목 거래 수 (buy 기준) + * - 총 품목 거래 실중량 + * - 총 품목 거래 금액 + */ + + private YearMonth statsMonth; + private Long itemId; + private String itemName; + private String itemCode; + private Integer itemTypeId; + private String itemTypeName; + private Long totalTradeCount; + private BigDecimal totalPayloadWeight; + private BigDecimal totalTradeAmount; + + + @QueryProjection + public PartnerMonthlyItemStatsOutDto(Date statsMonth, Long itemId, String itemName, String itemCode, + Integer itemTypeId, String itemTypeName, Long totalTradeCount, BigDecimal totalPayloadWeight, + BigDecimal totalTradeAmount) { + + // YearMonth 변환 + LocalDate date = statsMonth.toLocalDate(); + int month = date.getMonthValue(); + int year = date.getYear(); + + // assign + this.statsMonth = YearMonth.of(year, month); + this.itemId = itemId; + this.itemName = itemName; + this.itemCode = itemCode; + this.itemTypeId = itemTypeId; + this.itemTypeName = itemTypeName; + this.totalTradeCount = totalTradeCount; + this.totalPayloadWeight = totalPayloadWeight; + this.totalTradeAmount = totalTradeAmount; + } + +} diff --git a/src/main/java/greenfirst/be/stats/application/dto/out/PartnerTotalStatsOutDto.java b/src/main/java/greenfirst/be/stats/application/dto/out/PartnerTotalStatsOutDto.java new file mode 100644 index 0000000..c321fff --- /dev/null +++ b/src/main/java/greenfirst/be/stats/application/dto/out/PartnerTotalStatsOutDto.java @@ -0,0 +1,37 @@ +package greenfirst.be.stats.application.dto.out; + + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Getter; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + + +@Getter +public class PartnerTotalStatsOutDto { + + /** + * 파트너 누적 통계 DTO + * - 총 거래 횟수 + * - 총 거래 실중량(net) + * - 총 거래 금액 + * - 최근 거래 시간 + */ + + private Long totalTradeCount; + private BigDecimal totalPayloadWeight; + private BigDecimal totalTradeAmount; + private LocalDateTime lastTradeTime; + + + @QueryProjection + public PartnerTotalStatsOutDto(Long totalTradeCount, BigDecimal totalPayloadWeight, + BigDecimal totalTradeAmount, LocalDateTime lastTradeTime) { + this.totalTradeCount = totalTradeCount; + this.totalPayloadWeight = totalPayloadWeight; + this.totalTradeAmount = totalTradeAmount; + this.lastTradeTime = lastTradeTime; + } + +} diff --git a/src/main/java/greenfirst/be/stats/application/facade/DailyStatsFacade.java b/src/main/java/greenfirst/be/stats/application/facade/DailyStatsFacade.java index f4b4d0e..f42cb59 100644 --- a/src/main/java/greenfirst/be/stats/application/facade/DailyStatsFacade.java +++ b/src/main/java/greenfirst/be/stats/application/facade/DailyStatsFacade.java @@ -3,9 +3,11 @@ import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyStatsInDto; +import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyTradeStatsInDto; import greenfirst.be.stats.application.dto.out.BranchDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.BranchDailyStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerDailyTradeStatsOutDto; import greenfirst.be.stats.application.service.daily.DailyStatsQueryService; import lombok.RequiredArgsConstructor; @@ -30,6 +32,7 @@ public class DailyStatsFacade { * 1. 지점별 일별 통계 조회 * 2. 지점별 일별 품목 통계 조회 * 3. 파트너별 일별 거래 통계 조회 + * 4. 파트너별 일별 품목 통계 조회 */ // 1. 지점별 일별 통계 조회 @@ -49,4 +52,9 @@ public List getPartnerDailyTradeStats(GetPartnerDa return dailyStatsQueryService.getPartnerDailyTradeStats(inDto); } + // 4. 파트너별 일별 품목 통계 조회 + public List getPartnerDailyItemStats(GetPartnerDailyItemStatsInDto inDto) { + return dailyStatsQueryService.getPartnerDailyItemStats(inDto); + } + } diff --git a/src/main/java/greenfirst/be/stats/application/facade/MonthlyStatsFacade.java b/src/main/java/greenfirst/be/stats/application/facade/MonthlyStatsFacade.java index bac422a..252095b 100644 --- a/src/main/java/greenfirst/be/stats/application/facade/MonthlyStatsFacade.java +++ b/src/main/java/greenfirst/be/stats/application/facade/MonthlyStatsFacade.java @@ -3,10 +3,12 @@ import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyStatsInDto; +import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyStatsInDto; import greenfirst.be.stats.application.dto.out.AllBranchStatsSumOutDto; import greenfirst.be.stats.application.dto.out.BranchMonthlyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.BranchMonthlyStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerMonthlyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerMonthlyStatsOutDto; import greenfirst.be.stats.application.service.monthly.MonthlyStatsQueryService; import lombok.RequiredArgsConstructor; @@ -33,6 +35,7 @@ public class MonthlyStatsFacade { * 2. 지점별 월별 품목 통계 조회 * 3. 전체지점 통계 합 조회 * 4. 파트너 월별 거래 통계 조회 + * 5. 파트너 월별 품목 통계 조회 */ // 1. 지점별 월별 통계 조회 @@ -58,4 +61,9 @@ public List getPartnerMonthlyStats(GetPartnerMonthlyS return monthlyStatsQueryService.getPartnerMonthlyStats(inDto); } + // 5. 파트너 월별 품목 통계 조회 + public List getPartnerMonthlyItemStats(GetPartnerMonthlyItemStatsInDto inDto) { + return monthlyStatsQueryService.getPartnerMonthlyItemStats(inDto); + } + } diff --git a/src/main/java/greenfirst/be/stats/application/facade/TotalStatsFacade.java b/src/main/java/greenfirst/be/stats/application/facade/TotalStatsFacade.java new file mode 100644 index 0000000..f1666b3 --- /dev/null +++ b/src/main/java/greenfirst/be/stats/application/facade/TotalStatsFacade.java @@ -0,0 +1,43 @@ +package greenfirst.be.stats.application.facade; + + +import greenfirst.be.stats.application.dto.in.total.GetPartnerItemTotalStatsInDto; +import greenfirst.be.stats.application.dto.in.total.GetPartnerTotalStatsInDto; +import greenfirst.be.stats.application.dto.out.PartnerItemTotalStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerTotalStatsOutDto; +import greenfirst.be.stats.application.service.total.TotalStatsQueryService; +import lombok.RequiredArgsConstructor; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Service; + +import java.util.List; + + +@Service +@RequiredArgsConstructor +public class TotalStatsFacade { + + // service + private final TotalStatsQueryService totalStatsQueryService; + // util + private final ModelMapper modelMapper; + + + /** + * 누적 통계 조회 퍼사드 + * 1. 파트너 누적 통계 조회 + * 2. 파트너 품목별 누적 통계 조회 + */ + + // 1. 파트너 누적 통계 조회 + public PartnerTotalStatsOutDto getPartnerTotalStats(GetPartnerTotalStatsInDto inDto) { + return totalStatsQueryService.getPartnerTotalStats(inDto); + } + + + // 2. 파트너 품목별 누적 통계 조회 + public List getPartnerItemTotalStats(GetPartnerItemTotalStatsInDto inDto) { + return totalStatsQueryService.getPartnerItemTotalStats(inDto); + } + +} diff --git a/src/main/java/greenfirst/be/stats/application/port/out/daily/DailyStatsQueryRepository.java b/src/main/java/greenfirst/be/stats/application/port/out/daily/DailyStatsQueryRepository.java index df526d2..704aefd 100644 --- a/src/main/java/greenfirst/be/stats/application/port/out/daily/DailyStatsQueryRepository.java +++ b/src/main/java/greenfirst/be/stats/application/port/out/daily/DailyStatsQueryRepository.java @@ -3,9 +3,11 @@ import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyStatsInDto; +import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyTradeStatsInDto; import greenfirst.be.stats.application.dto.out.BranchDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.BranchDailyStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerDailyTradeStatsOutDto; import java.util.List; @@ -22,4 +24,7 @@ public interface DailyStatsQueryRepository { // 파트너별 일별 거래 통계 조회 List getPartnerDailyTradeStats(GetPartnerDailyTradeStatsInDto inDto); + // 파트너별 일별 품목 통계 조회 + List getPartnerDailyItemStats(GetPartnerDailyItemStatsInDto inDto); + } diff --git a/src/main/java/greenfirst/be/stats/application/port/out/monthly/MonthlyStatsQueryRepository.java b/src/main/java/greenfirst/be/stats/application/port/out/monthly/MonthlyStatsQueryRepository.java index c651a64..c9a9580 100644 --- a/src/main/java/greenfirst/be/stats/application/port/out/monthly/MonthlyStatsQueryRepository.java +++ b/src/main/java/greenfirst/be/stats/application/port/out/monthly/MonthlyStatsQueryRepository.java @@ -3,10 +3,12 @@ import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyStatsInDto; +import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyStatsInDto; import greenfirst.be.stats.application.dto.out.AllBranchStatsSumOutDto; import greenfirst.be.stats.application.dto.out.BranchMonthlyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.BranchMonthlyStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerMonthlyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerMonthlyStatsOutDto; import java.time.YearMonth; @@ -27,4 +29,7 @@ public interface MonthlyStatsQueryRepository { // 파트너 월별 거래 통계 조회 List getPartnerMonthlyStats(GetPartnerMonthlyStatsInDto inDto); + // 파트너 월별 품목 통계 조회 + List getPartnerMonthlyItemStats(GetPartnerMonthlyItemStatsInDto inDto); + } diff --git a/src/main/java/greenfirst/be/stats/application/port/out/total/TotalStatsQueryRepository.java b/src/main/java/greenfirst/be/stats/application/port/out/total/TotalStatsQueryRepository.java new file mode 100644 index 0000000..fe4612a --- /dev/null +++ b/src/main/java/greenfirst/be/stats/application/port/out/total/TotalStatsQueryRepository.java @@ -0,0 +1,20 @@ +package greenfirst.be.stats.application.port.out.total; + + +import greenfirst.be.stats.application.dto.in.total.GetPartnerItemTotalStatsInDto; +import greenfirst.be.stats.application.dto.in.total.GetPartnerTotalStatsInDto; +import greenfirst.be.stats.application.dto.out.PartnerItemTotalStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerTotalStatsOutDto; + +import java.util.List; + + +public interface TotalStatsQueryRepository { + + // 파트너 누적 통계 조회 + PartnerTotalStatsOutDto getPartnerTotalStats(GetPartnerTotalStatsInDto inDto); + + // 파트너 품목별 누적 통계 조회 + List getPartnerItemTotalStats(GetPartnerItemTotalStatsInDto inDto); + +} diff --git a/src/main/java/greenfirst/be/stats/application/service/daily/DailyStatsQueryService.java b/src/main/java/greenfirst/be/stats/application/service/daily/DailyStatsQueryService.java index c977bdd..8e4c924 100644 --- a/src/main/java/greenfirst/be/stats/application/service/daily/DailyStatsQueryService.java +++ b/src/main/java/greenfirst/be/stats/application/service/daily/DailyStatsQueryService.java @@ -6,9 +6,11 @@ import greenfirst.be.global.common.response.base.BaseResponseStatus; import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyStatsInDto; +import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyTradeStatsInDto; import greenfirst.be.stats.application.dto.out.BranchDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.BranchDailyStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerDailyTradeStatsOutDto; import greenfirst.be.stats.application.port.out.daily.DailyStatsQueryRepository; import greenfirst.be.stats.domain.service.QueryDatePolicy; @@ -39,6 +41,7 @@ public class DailyStatsQueryService { * 1. 지점별 일별 통계 조회 * 2. 지점별 일별 품목 통계 조회 * 3. 파트너별 일별 거래 통계 조회 + * 4. 파트너별 일별 품목 통계 조회 */ // 1. 지점별 일별 통계 조회 @@ -77,12 +80,26 @@ public List getPartnerDailyTradeStats(GetPartnerDa return dailyStatsQueryRepository.getPartnerDailyTradeStats(inDto); } + // 4. 파트너별 일별 품목 통계 조회 + public List getPartnerDailyItemStats(GetPartnerDailyItemStatsInDto inDto) { + + // 조회 날짜 검증 + queryDatePolicy.validateDateRange(inDto.getStartDate(), inDto.getEndDate()); + + // 파트너 접근 권한 검증 + validatePartnerAccess(inDto.getRequestorType(), inDto.getRequestorUuid(), + inDto.getTargetPartnerUuid()); + + // 파트너별 일별 품목 통계 조회 + return dailyStatsQueryRepository.getPartnerDailyItemStats(inDto); + } + // 파트너 접근 권한 검증 private void validatePartnerAccess(UserType requestorType, UUID requestorUuid, UUID targetPartnerUuid) { - // ADMIN, AGENCY는 모든 파트너 조회 가능 - if (requestorType == UserType.ADMIN || requestorType == UserType.AGENCY) { + // ADMIN은 모든 파트너 조회 가능 + if (requestorType == UserType.ADMIN) { return; } diff --git a/src/main/java/greenfirst/be/stats/application/service/monthly/MonthlyStatsQueryService.java b/src/main/java/greenfirst/be/stats/application/service/monthly/MonthlyStatsQueryService.java index 20a7cfd..e5a4c4d 100644 --- a/src/main/java/greenfirst/be/stats/application/service/monthly/MonthlyStatsQueryService.java +++ b/src/main/java/greenfirst/be/stats/application/service/monthly/MonthlyStatsQueryService.java @@ -6,10 +6,12 @@ import greenfirst.be.global.common.response.base.BaseResponseStatus; import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyStatsInDto; +import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyStatsInDto; import greenfirst.be.stats.application.dto.out.AllBranchStatsSumOutDto; import greenfirst.be.stats.application.dto.out.BranchMonthlyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.BranchMonthlyStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerMonthlyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerMonthlyStatsOutDto; import greenfirst.be.stats.application.port.out.monthly.MonthlyStatsQueryRepository; import greenfirst.be.stats.domain.service.QueryDatePolicy; @@ -39,6 +41,8 @@ public class MonthlyStatsQueryService { * 1. 지점별 월별 통계 조회 * 2. 지점별 월별 품목 통계 조회 * 3. 전체지점 통계 합 조회 + * 4. 파트너 월별 거래 통계 조회 + * 5. 파트너 월별 품목 통계 조회 */ // 1. 지점별 월별 통계 조회 @@ -74,7 +78,7 @@ public AllBranchStatsSumOutDto getAllBranchStatsSum(YearMonth targetMonth) { /** * 파트너 월별 거래 통계 조회 * - 날짜 검증 - * - 접근 권한 검증 (Admin/Agency: 모두, Partner: 본인만) + * - 접근 권한 검증 (Admin: 모두, Partner: 본인만) */ public List getPartnerMonthlyStats(GetPartnerMonthlyStatsInDto inDto) { @@ -86,8 +90,8 @@ public List getPartnerMonthlyStats(GetPartnerMonthlyS UserType requestorType = inDto.getRequestorType(); UUID requestorUuid = inDto.getRequestorUuid(); - // Admin, Agency: 모든 파트너 조회 가능 - if (requestorType == UserType.ADMIN || requestorType == UserType.AGENCY) { + // Admin: 모든 파트너 조회 가능 + if (requestorType == UserType.ADMIN) { return monthlyStatsQueryRepository.getPartnerMonthlyStats(inDto); } @@ -102,4 +106,36 @@ public List getPartnerMonthlyStats(GetPartnerMonthlyS throw new BaseException(BaseResponseStatus.PARTNER_STATS_ACCESS_DENIED); } + // 5. 파트너 월별 품목 통계 조회 + /** + * 파트너 월별 품목 통계 조회 + * - 날짜 검증 + * - 접근 권한 검증 (Admin: 모두, Partner: 본인만) + */ + public List getPartnerMonthlyItemStats(GetPartnerMonthlyItemStatsInDto inDto) { + + // 1. 조회 날짜 검증 + queryDatePolicy.validateDateRange(inDto.getStartDate(), inDto.getEndDate()); + + // 2. 접근 권한 검증 + UUID targetPartnerUuid = inDto.getTargetPartnerUuid(); + UserType requestorType = inDto.getRequestorType(); + UUID requestorUuid = inDto.getRequestorUuid(); + + // Admin: 모든 파트너 조회 가능 + if (requestorType == UserType.ADMIN) { + return monthlyStatsQueryRepository.getPartnerMonthlyItemStats(inDto); + } + + // Partner (개인/기업): 본인 통계만 조회 가능 + if (requestorType == UserType.PERSONAL_PARTNER || requestorType == UserType.CORPORATE_PARTNER) { + if (!requestorUuid.equals(targetPartnerUuid)) { + throw new BaseException(BaseResponseStatus.PARTNER_STATS_ACCESS_DENIED); + } + return monthlyStatsQueryRepository.getPartnerMonthlyItemStats(inDto); + } + + throw new BaseException(BaseResponseStatus.PARTNER_STATS_ACCESS_DENIED); + } + } diff --git a/src/main/java/greenfirst/be/stats/application/service/total/TotalStatsQueryService.java b/src/main/java/greenfirst/be/stats/application/service/total/TotalStatsQueryService.java new file mode 100644 index 0000000..a307b2e --- /dev/null +++ b/src/main/java/greenfirst/be/stats/application/service/total/TotalStatsQueryService.java @@ -0,0 +1,80 @@ +package greenfirst.be.stats.application.service.total; + + +import greenfirst.be.global.common.enums.common.UserType; +import greenfirst.be.global.common.exception.BaseException; +import greenfirst.be.global.common.response.base.BaseResponseStatus; +import greenfirst.be.stats.application.dto.in.total.GetPartnerItemTotalStatsInDto; +import greenfirst.be.stats.application.dto.in.total.GetPartnerTotalStatsInDto; +import greenfirst.be.stats.application.dto.out.PartnerItemTotalStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerTotalStatsOutDto; +import greenfirst.be.stats.application.port.out.total.TotalStatsQueryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.modelmapper.ModelMapper; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.UUID; + + +@Slf4j +@Service +@RequiredArgsConstructor +public class TotalStatsQueryService { + + // port + private final TotalStatsQueryRepository totalStatsQueryRepository; + // util + private final ModelMapper modelMapper; + + + /** + * 누적 통계 조회 서비스 + * 1. 파트너 누적 통계 조회 + * 2. 파트너 품목별 누적 통계 조회 + */ + + // 1. 파트너 누적 통계 조회 + public PartnerTotalStatsOutDto getPartnerTotalStats(GetPartnerTotalStatsInDto inDto) { + + // 파트너 접근 권한 검증 + validatePartnerAccess(inDto.getRequestorType(), inDto.getRequestorUuid(), inDto.getTargetPartnerUuid()); + + // 파트너 누적 통계 조회 + return totalStatsQueryRepository.getPartnerTotalStats(inDto); + } + + + // 2. 파트너 품목별 누적 통계 조회 + public List getPartnerItemTotalStats(GetPartnerItemTotalStatsInDto inDto) { + + // 파트너 접근 권한 검증 + validatePartnerAccess(inDto.getRequestorType(), inDto.getRequestorUuid(), inDto.getTargetPartnerUuid()); + + // 파트너 품목별 누적 통계 조회 + return totalStatsQueryRepository.getPartnerItemTotalStats(inDto); + } + + + // 파트너 접근 권한 검증 + private void validatePartnerAccess(UserType requestorType, UUID requestorUuid, UUID targetPartnerUuid) { + + // ADMIN은 모든 파트너 조회 가능 + if (requestorType == UserType.ADMIN) { + return; + } + + // PARTNER는 자신의 통계만 조회 가능 + if ((requestorType == UserType.PERSONAL_PARTNER || requestorType == UserType.CORPORATE_PARTNER) + && requestorUuid.equals(targetPartnerUuid)) { + return; + } + + // 그 외: 접근 거부 + log.error("파트너 누적 통계 조회 거부(권한없음) -> 요청자Type: {}, 요청자Uuid: {}, 대상PartnerUuid: {}", + requestorType, requestorUuid, targetPartnerUuid); + throw new BaseException(BaseResponseStatus.PARTNER_STATS_ACCESS_DENIED); + } + +} diff --git a/src/main/java/greenfirst/be/trade/adapter/in/web/request/CreateTradeItemRequest.java b/src/main/java/greenfirst/be/trade/adapter/in/web/request/CreateTradeItemRequest.java index 4debcf5..ce5115e 100644 --- a/src/main/java/greenfirst/be/trade/adapter/in/web/request/CreateTradeItemRequest.java +++ b/src/main/java/greenfirst/be/trade/adapter/in/web/request/CreateTradeItemRequest.java @@ -30,6 +30,10 @@ public class CreateTradeItemRequest { @PositiveOrZero private BigDecimal weightLoss; + @NotNull + @PositiveOrZero + private BigDecimal netPayloadWeight; + @NotNull @Positive private BigDecimal userTradeItemUnitPrice; @@ -46,4 +50,4 @@ public class CreateTradeItemRequest { @Positive private Long carbonEmissionFactorId; -} \ No newline at end of file +} diff --git a/src/main/java/greenfirst/be/trade/adapter/out/persistence/entity/TradeItemEntity.java b/src/main/java/greenfirst/be/trade/adapter/out/persistence/entity/TradeItemEntity.java index 2fea533..83b8579 100644 --- a/src/main/java/greenfirst/be/trade/adapter/out/persistence/entity/TradeItemEntity.java +++ b/src/main/java/greenfirst/be/trade/adapter/out/persistence/entity/TradeItemEntity.java @@ -31,6 +31,9 @@ public class TradeItemEntity { @Column(name = "weight_loss", nullable = false, precision = 19, scale = 2) private BigDecimal weightLoss; + @Column(name = "net_payload_weight", nullable = false, precision = 19, scale = 2) + private BigDecimal netPayloadWeight; + @Column(name = "user_trade_item_unit_price", nullable = false, precision = 19, scale = 2) private BigDecimal userTradeItemUnitPrice; @@ -58,4 +61,4 @@ public Long getItemsTradeId() { return this.trade.getId(); } -} \ No newline at end of file +} diff --git a/src/main/java/greenfirst/be/trade/application/dto/in/CalcCarbonReductionInDto.java b/src/main/java/greenfirst/be/trade/application/dto/in/CalcCarbonReductionInDto.java index 5be3626..51f0670 100644 --- a/src/main/java/greenfirst/be/trade/application/dto/in/CalcCarbonReductionInDto.java +++ b/src/main/java/greenfirst/be/trade/application/dto/in/CalcCarbonReductionInDto.java @@ -11,6 +11,7 @@ public class CalcCarbonReductionInDto { private BigDecimal weight; private BigDecimal weightLoss; + private BigDecimal netPayloadWeight; private Long carbonEmissionFactorId; } diff --git a/src/main/java/greenfirst/be/trade/application/dto/in/CreateTradeItemInDto.java b/src/main/java/greenfirst/be/trade/application/dto/in/CreateTradeItemInDto.java index f8958c0..6403408 100644 --- a/src/main/java/greenfirst/be/trade/application/dto/in/CreateTradeItemInDto.java +++ b/src/main/java/greenfirst/be/trade/application/dto/in/CreateTradeItemInDto.java @@ -19,9 +19,10 @@ public class CreateTradeItemInDto { private String itemName; private BigDecimal weight; private BigDecimal weightLoss; + private BigDecimal netPayloadWeight; private BigDecimal userTradeItemUnitPrice; private BigDecimal defaultTradeItemUnitPrice; private BigDecimal itemTradeAmount; private Long carbonEmissionFactorId; -} \ No newline at end of file +} diff --git a/src/main/java/greenfirst/be/trade/application/dto/in/TradeItemWeightInDto.java b/src/main/java/greenfirst/be/trade/application/dto/in/TradeItemWeightInDto.java index 0c48b06..2dc19f5 100644 --- a/src/main/java/greenfirst/be/trade/application/dto/in/TradeItemWeightInDto.java +++ b/src/main/java/greenfirst/be/trade/application/dto/in/TradeItemWeightInDto.java @@ -8,5 +8,6 @@ public class TradeItemWeightInDto { private BigDecimal weight; private BigDecimal weightLoss; + private BigDecimal netPayloadWeight; } diff --git a/src/main/java/greenfirst/be/trade/application/dto/out/TradeItemDataOutDto.java b/src/main/java/greenfirst/be/trade/application/dto/out/TradeItemDataOutDto.java index ce2d2fc..ffff5cd 100644 --- a/src/main/java/greenfirst/be/trade/application/dto/out/TradeItemDataOutDto.java +++ b/src/main/java/greenfirst/be/trade/application/dto/out/TradeItemDataOutDto.java @@ -21,6 +21,7 @@ public class TradeItemDataOutDto { private String itemName; private BigDecimal weight; private BigDecimal weightLoss; + private BigDecimal netPayloadWeight; private BigDecimal itemTradeAmount; private Long carbonEmissionFactorId; private BigDecimal userTradeItemUnitPrice; @@ -34,6 +35,7 @@ public static TradeItemDataOutDto of(TradeItem tradeItem) { .itemName(tradeItem.getItemName()) .weight(tradeItem.getWeight()) .weightLoss(tradeItem.getWeightLoss()) + .netPayloadWeight(tradeItem.getNetPayloadWeight()) .itemTradeAmount(tradeItem.getItemTradeAmount()) .carbonEmissionFactorId(tradeItem.getCarbonEmissionFactorId()) .userTradeItemUnitPrice(tradeItem.getUserTradeItemUnitPrice()) diff --git a/src/main/java/greenfirst/be/trade/application/facade/CreateTradeFacade.java b/src/main/java/greenfirst/be/trade/application/facade/CreateTradeFacade.java index 1144b93..e76f3d3 100644 --- a/src/main/java/greenfirst/be/trade/application/facade/CreateTradeFacade.java +++ b/src/main/java/greenfirst/be/trade/application/facade/CreateTradeFacade.java @@ -5,6 +5,7 @@ import greenfirst.be.trade.application.dto.in.CalcCarbonReductionInDto; import greenfirst.be.trade.application.dto.in.CreateTradeFacadeInDto; import greenfirst.be.trade.application.dto.in.CreateTradeInDto; +import greenfirst.be.trade.application.dto.in.CreateTradeItemInDto; import greenfirst.be.trade.application.service.carbonemissionfactor.CarbonEmissionFactorManagementService; import greenfirst.be.trade.application.service.trade.TradeManagementService; import greenfirst.be.trade.application.service.tradeitem.TradeItemManagementService; @@ -15,6 +16,7 @@ import lombok.extern.slf4j.Slf4j; import org.modelmapper.ModelMapper; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.util.List; @@ -40,10 +42,13 @@ public class CreateTradeFacade { */ // 1. 거래 생성 + @Transactional public void createTrade(CreateTradeFacadeInDto inDto) { + List tradeItems = inDto.getTradeItems(); + // 탄소 감소량 계산 - List calcCarbonReductionInDtos = inDto.getTradeItems().stream() + List calcCarbonReductionInDtos = tradeItems.stream() .map(item -> modelMapper.map(item, CalcCarbonReductionInDto.class)) .toList(); BigDecimal reductionAmount = carbonEmissionFactorManagementService.calcCarbonReductionAmount(calcCarbonReductionInDtos); @@ -79,7 +84,7 @@ public void createTrade(CreateTradeFacadeInDto inDto) { Trade trade = tradeManagementService.createTrade(createTradeInDto); // 거래 품목 생성 - tradeItemManagementService.createTradeItems(trade.getId(), inDto.getTradeItems()); + tradeItemManagementService.createTradeItems(trade.getId(), tradeItems); // 인센티브 생성은 TradeCreatedEvent 이벤트 핸들러에서 처리됨 } diff --git a/src/main/java/greenfirst/be/trade/application/service/tradeitem/TradeItemManagementService.java b/src/main/java/greenfirst/be/trade/application/service/tradeitem/TradeItemManagementService.java index 55d3187..8a6ff86 100644 --- a/src/main/java/greenfirst/be/trade/application/service/tradeitem/TradeItemManagementService.java +++ b/src/main/java/greenfirst/be/trade/application/service/tradeitem/TradeItemManagementService.java @@ -11,6 +11,7 @@ import org.modelmapper.ModelMapper; import org.springframework.stereotype.Service; +import java.math.BigDecimal; import java.util.List; @@ -42,6 +43,7 @@ public void createTradeItems(Long tradeId, List inDtos) { .itemName(inDto.getItemName()) .weight(inDto.getWeight()) .weightLoss(inDto.getWeightLoss()) + .netPayloadWeight(inDto.getNetPayloadWeight()) .userTradeItemUnitPrice(inDto.getUserTradeItemUnitPrice()) .defaultTradeItemUnitPrice(inDto.getDefaultTradeItemUnitPrice()) .itemTradeAmount(inDto.getItemTradeAmount()) diff --git a/src/main/java/greenfirst/be/trade/domain/command/create/CreateTradeItemCommand.java b/src/main/java/greenfirst/be/trade/domain/command/create/CreateTradeItemCommand.java index a39f28f..45f054f 100644 --- a/src/main/java/greenfirst/be/trade/domain/command/create/CreateTradeItemCommand.java +++ b/src/main/java/greenfirst/be/trade/domain/command/create/CreateTradeItemCommand.java @@ -17,6 +17,7 @@ public class CreateTradeItemCommand { private final String itemName; private final BigDecimal weight; private final BigDecimal weightLoss; + private final BigDecimal netPayloadWeight; private final BigDecimal userTradeItemUnitPrice; private final BigDecimal defaultTradeItemUnitPrice; private final BigDecimal itemTradeAmount; @@ -24,12 +25,11 @@ public class CreateTradeItemCommand { @Builder - public CreateTradeItemCommand(Long tradeId, Long itemId, String itemName, BigDecimal weight, BigDecimal weightLoss, BigDecimal userTradeItemUnitPrice, BigDecimal defaultTradeItemUnitPrice, - BigDecimal itemTradeAmount, - Long carbonEmissionFactorId) { + public CreateTradeItemCommand(Long tradeId, Long itemId, String itemName, BigDecimal weight, BigDecimal weightLoss, BigDecimal netPayloadWeight, + BigDecimal userTradeItemUnitPrice, BigDecimal defaultTradeItemUnitPrice, BigDecimal itemTradeAmount, Long carbonEmissionFactorId) { // validate - validateCreateTradeItemValue(tradeId, itemId, itemName, weight, userTradeItemUnitPrice, defaultTradeItemUnitPrice, itemTradeAmount, carbonEmissionFactorId); + validateCreateTradeItemValue(tradeId, itemId, itemName, weight, weightLoss, netPayloadWeight, userTradeItemUnitPrice, defaultTradeItemUnitPrice, itemTradeAmount, carbonEmissionFactorId); // assign this.tradeId = tradeId; @@ -37,6 +37,7 @@ public CreateTradeItemCommand(Long tradeId, Long itemId, String itemName, BigDec this.itemName = itemName; this.weight = weight; this.weightLoss = weightLoss; + this.netPayloadWeight = netPayloadWeight; this.userTradeItemUnitPrice = userTradeItemUnitPrice; this.defaultTradeItemUnitPrice = defaultTradeItemUnitPrice; this.itemTradeAmount = itemTradeAmount; @@ -44,9 +45,8 @@ public CreateTradeItemCommand(Long tradeId, Long itemId, String itemName, BigDec } - private static void validateCreateTradeItemValue(Long tradeId, Long itemId, String itemName, BigDecimal weight, BigDecimal userTradeItemUnitPrice, BigDecimal defaultTradeItemUnitPrice, - BigDecimal itemTradeAmount, - Long carbonEmissionFactorId) { + private static void validateCreateTradeItemValue(Long tradeId, Long itemId, String itemName, BigDecimal weight, BigDecimal weightLoss, BigDecimal netPayloadWeight, + BigDecimal userTradeItemUnitPrice, BigDecimal defaultTradeItemUnitPrice, BigDecimal itemTradeAmount, Long carbonEmissionFactorId) { if (tradeId == null || tradeId <= 0) { throw new BaseException(BaseResponseStatus.MISSING_CREATE_TRADE_ITEM_VALUE.withMessage("거래 ID")); } @@ -59,6 +59,14 @@ private static void validateCreateTradeItemValue(Long tradeId, Long itemId, Stri if (weight == null || weight.compareTo(BigDecimal.ZERO) < 0) { throw new BaseException(BaseResponseStatus.MISSING_CREATE_TRADE_ITEM_VALUE.withMessage("품목 거래 중량")); } + if (netPayloadWeight == null) { + throw new BaseException(BaseResponseStatus.MISSING_CREATE_TRADE_ITEM_VALUE.withMessage("감량 후 실중량(netPayloadWeight)")); + } + BigDecimal expectedNetPayloadWeight = weightLoss == null ? weight : weight.subtract(weightLoss); + if (expectedNetPayloadWeight.compareTo(netPayloadWeight) != 0) { + throw new BaseException(BaseResponseStatus.INVALID_CREATE_TRADE_VALUE.withMessage( + "거래 품목 netPayloadWeight는 weight - weightLoss와 같아야 합니다 (itemId=" + itemId + ")")); + } if (userTradeItemUnitPrice == null || userTradeItemUnitPrice.compareTo(BigDecimal.ZERO) <= 0) { throw new BaseException(BaseResponseStatus.MISSING_CREATE_TRADE_ITEM_VALUE.withMessage("유저 거래 품목 단가")); } diff --git a/src/main/java/greenfirst/be/trade/domain/dto/in/TradeItemCarbonData.java b/src/main/java/greenfirst/be/trade/domain/dto/in/TradeItemCarbonData.java index b924a4c..6093a62 100644 --- a/src/main/java/greenfirst/be/trade/domain/dto/in/TradeItemCarbonData.java +++ b/src/main/java/greenfirst/be/trade/domain/dto/in/TradeItemCarbonData.java @@ -17,6 +17,7 @@ public class TradeItemCarbonData { private BigDecimal weight; private BigDecimal weightLoss; + private BigDecimal netPayloadWeight; private Long carbonEmissionFactorId; } diff --git a/src/main/java/greenfirst/be/trade/domain/model/TradeItem.java b/src/main/java/greenfirst/be/trade/domain/model/TradeItem.java index c35b228..a10b1ce 100644 --- a/src/main/java/greenfirst/be/trade/domain/model/TradeItem.java +++ b/src/main/java/greenfirst/be/trade/domain/model/TradeItem.java @@ -23,6 +23,7 @@ public class TradeItem { * - 품목명 * - 중량 * - 감량 + * - 감량 후 실중량 * - 유저 거래 품목 단가 * - 기본 품목 단가 * - 품목 거래 금액 @@ -35,6 +36,7 @@ public class TradeItem { private String itemName; private BigDecimal weight; private BigDecimal weightLoss; + private BigDecimal netPayloadWeight; private BigDecimal userTradeItemUnitPrice; private BigDecimal defaultTradeItemUnitPrice; private BigDecimal itemTradeAmount; @@ -42,7 +44,8 @@ public class TradeItem { @Builder(toBuilder = true) - public TradeItem(Long id, Long tradeId, Long itemId, String itemName, BigDecimal weight, BigDecimal weightLoss, BigDecimal userTradeItemUnitPrice, BigDecimal defaultTradeItemUnitPrice, BigDecimal itemTradeAmount, + public TradeItem(Long id, Long tradeId, Long itemId, String itemName, BigDecimal weight, BigDecimal weightLoss, BigDecimal netPayloadWeight, + BigDecimal userTradeItemUnitPrice, BigDecimal defaultTradeItemUnitPrice, BigDecimal itemTradeAmount, Long carbonEmissionFactorId) { this.id = id; this.tradeId = tradeId; @@ -50,6 +53,7 @@ public TradeItem(Long id, Long tradeId, Long itemId, String itemName, BigDecimal this.itemName = itemName; this.weight = weight; this.weightLoss = weightLoss; + this.netPayloadWeight = netPayloadWeight; this.userTradeItemUnitPrice = userTradeItemUnitPrice; this.defaultTradeItemUnitPrice = defaultTradeItemUnitPrice; this.itemTradeAmount = itemTradeAmount; @@ -64,6 +68,7 @@ public static TradeItem from(CreateTradeItemCommand command) { .itemName(command.getItemName()) .weight(command.getWeight()) .weightLoss(command.getWeightLoss()) + .netPayloadWeight(command.getNetPayloadWeight()) .userTradeItemUnitPrice(command.getUserTradeItemUnitPrice()) .defaultTradeItemUnitPrice(command.getDefaultTradeItemUnitPrice()) .itemTradeAmount(command.getItemTradeAmount()) @@ -71,4 +76,4 @@ public static TradeItem from(CreateTradeItemCommand command) { .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/greenfirst/be/trade/domain/service/CarbonReductionCalculator.java b/src/main/java/greenfirst/be/trade/domain/service/CarbonReductionCalculator.java index 8a1d9f9..9530958 100644 --- a/src/main/java/greenfirst/be/trade/domain/service/CarbonReductionCalculator.java +++ b/src/main/java/greenfirst/be/trade/domain/service/CarbonReductionCalculator.java @@ -2,6 +2,8 @@ import greenfirst.be.item.domain.model.CarbonEmissionFactor; +import greenfirst.be.global.common.exception.BaseException; +import greenfirst.be.global.common.response.base.BaseResponseStatus; import greenfirst.be.trade.domain.dto.in.TradeItemCarbonData; import org.springframework.stereotype.Component; @@ -33,7 +35,14 @@ public BigDecimal calculateTotalCarbonReduction(List datas, CarbonEmissionFactor factor = emissionFactorMap.get(data.getCarbonEmissionFactorId()); if (factor != null) { - BigDecimal netWeight = data.getWeight().subtract(data.getWeightLoss()); // 순중량 = 중량 - 중량손실 + BigDecimal netWeight = data.getNetPayloadWeight(); + if (netWeight == null && data.getWeight() != null) { + netWeight = data.getWeightLoss() == null ? data.getWeight() : data.getWeight().subtract(data.getWeightLoss()); + } + if (netWeight == null) { + throw new BaseException(BaseResponseStatus.INVALID_CREATE_TRADE_VALUE.withMessage( + "탄소 저감량 계산에 필요한 중량 정보가 없습니다")); + } BigDecimal itemCarbonReduction = netWeight.multiply(factor.getEmissionFactorKgCo2ePerKg()); // 품목별 탄소 저감량 totalCarbonReduction = totalCarbonReduction.add(itemCarbonReduction); // 총 탄소 저감량 누적 } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fc31704..ab92bbc 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,6 +3,13 @@ spring: name: greenfirst-be profiles: default: local + flyway: + baseline-on-migrate: true + lifecycle: + timeout-per-shutdown-phase: 30s # 타임 아웃 + +server: + shutdown: graceful # swagger 설정 springdoc: diff --git a/src/main/resources/db/migration/V20260129__add_trade_item_net_payload_weight.sql b/src/main/resources/db/migration/V20260129__add_trade_item_net_payload_weight.sql new file mode 100644 index 0000000..2291ea0 --- /dev/null +++ b/src/main/resources/db/migration/V20260129__add_trade_item_net_payload_weight.sql @@ -0,0 +1,12 @@ +-- 'trade_item'에 '실 거래중량' 추가를 위한 flyway sql + +-- set column: 'trade_item' 테이블에 'net_payload_weight' 컬럼 추가. 이때 기본적으로 null은 허용 (이미 데이터 존재하므로) +ALTER TABLE trade_item ADD COLUMN IF NOT EXISTS net_payload_weight DECIMAL(19,2); + +-- backfill: 'trade_item'의 'net_payload_weight' 컬럼에서, null인 값들을 (weight-weight_loss)로 채우기. 이때 weight_loss가 null이면 0으로 계산 -- +UPDATE trade_item +SET net_payload_weight = weight - COALESCE(weight_loss, 0) +WHERE net_payload_weight IS NULL; + +-- enforce constraint: backfill 완료 후 net_payload_weight는 항상 값이 존재해야 하므로 NOT NULL 제약을 적용 +ALTER TABLE trade_item MODIFY COLUMN net_payload_weight DECIMAL(19,2) NOT NULL; \ No newline at end of file diff --git a/src/test/java/greenfirst/be/integration/CarbonEmissionFactorMigrationPhase4Test.java b/src/test/java/greenfirst/be/integration/CarbonEmissionFactorMigrationPhase4Test.java index b485f65..bd919c4 100644 --- a/src/test/java/greenfirst/be/integration/CarbonEmissionFactorMigrationPhase4Test.java +++ b/src/test/java/greenfirst/be/integration/CarbonEmissionFactorMigrationPhase4Test.java @@ -83,11 +83,13 @@ void tradeContext_shouldUseCarbonEmissionFactorFromItemContext() { TradeItemCarbonData.builder() .weight(new BigDecimal("10.0")) .weightLoss(new BigDecimal("1.0")) + .netPayloadWeight(new BigDecimal("9.0")) .carbonEmissionFactorId(1L) .build(), TradeItemCarbonData.builder() .weight(new BigDecimal("20.0")) .weightLoss(new BigDecimal("2.0")) + .netPayloadWeight(new BigDecimal("18.0")) .carbonEmissionFactorId(2L) .build() ); @@ -155,6 +157,7 @@ void multipleContexts_shouldShareSameCarbonEmissionFactorConsistently() { TradeItemCarbonData tradeItem = TradeItemCarbonData.builder() .weight(new BigDecimal("10.0")) .weightLoss(new BigDecimal("0.0")) + .netPayloadWeight(new BigDecimal("10.0")) .carbonEmissionFactorId(1L) .build(); @@ -185,6 +188,7 @@ void system_shouldWorkWithoutTradeCarbonEmissionFactorCode() { TradeItemCarbonData.builder() .weight(new BigDecimal("5.0")) .weightLoss(new BigDecimal("0.5")) + .netPayloadWeight(new BigDecimal("4.5")) .carbonEmissionFactorId(1L) .build() ); diff --git a/src/test/java/greenfirst/be/stats/adapter/in/web/TotalStatsApiE2ETest.java b/src/test/java/greenfirst/be/stats/adapter/in/web/TotalStatsApiE2ETest.java new file mode 100644 index 0000000..914153e --- /dev/null +++ b/src/test/java/greenfirst/be/stats/adapter/in/web/TotalStatsApiE2ETest.java @@ -0,0 +1,341 @@ +package greenfirst.be.stats.adapter.in.web; + + +import com.fasterxml.jackson.databind.ObjectMapper; +import greenfirst.be.global.common.enums.common.UserType; +import greenfirst.be.global.common.security.CustomUserDetails; +import greenfirst.be.item.adapter.out.persistence.entity.CarbonEmissionFactorEntity; +import greenfirst.be.item.adapter.out.persistence.entity.ItemEntity; +import greenfirst.be.item.adapter.out.persistence.entity.ItemTypeEntity; +import greenfirst.be.item.adapter.out.persistence.jpa.CarbonEmissionFactorJpaRepository; +import greenfirst.be.item.adapter.out.persistence.jpa.ItemJpaRepository; +import greenfirst.be.item.adapter.out.persistence.jpa.ItemTypeJpaRepository; +import greenfirst.be.item.domain.enums.Unit; +import greenfirst.be.trade.adapter.out.persistence.entity.TradeEntity; +import greenfirst.be.trade.adapter.out.persistence.entity.TradeItemEntity; +import greenfirst.be.trade.adapter.out.persistence.jpa.trade.TradeJpaRepository; +import greenfirst.be.trade.adapter.out.persistence.jpa.tradeitem.TradeItemJpaRepository; +import greenfirst.be.user.adapter.out.persistence.entity.UserEntity; +import greenfirst.be.user.adapter.out.persistence.entity.vo.BranchOptionVo; +import greenfirst.be.user.adapter.out.persistence.jpa.UserJpaRepository; +import greenfirst.be.user.domain.model.Users; +import greenfirst.be.user.domain.model.vo.BranchOption; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class TotalStatsApiE2ETest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private CarbonEmissionFactorJpaRepository carbonEmissionFactorJpaRepository; + + @Autowired + private ItemTypeJpaRepository itemTypeJpaRepository; + + @Autowired + private ItemJpaRepository itemJpaRepository; + + @Autowired + private TradeJpaRepository tradeJpaRepository; + + @Autowired + private TradeItemJpaRepository tradeItemJpaRepository; + + // 테스트에서 생성한 데이터 추적 (tearDown에서 삭제용) + private final List createdUserUuids = new ArrayList<>(); + private final List createdCarbonEmissionFactorIds = new ArrayList<>(); + private final List createdItemTypeIds = new ArrayList<>(); + private final List createdItemIds = new ArrayList<>(); + private final List createdTradeIds = new ArrayList<>(); + private final List createdTradeItemIds = new ArrayList<>(); + + + @AfterEach + void tearDown() { + // 테스트에서 생성한 데이터만 삭제 (역순으로 삭제하여 FK 제약조건 만족) + if (!createdTradeItemIds.isEmpty()) { + tradeItemJpaRepository.deleteAllById(createdTradeItemIds); + } + + if (!createdTradeIds.isEmpty()) { + tradeJpaRepository.deleteAllById(createdTradeIds); + } + + if (!createdItemIds.isEmpty()) { + itemJpaRepository.deleteAllById(createdItemIds); + } + + if (!createdItemTypeIds.isEmpty()) { + itemTypeJpaRepository.deleteAllById(createdItemTypeIds); + } + + if (!createdCarbonEmissionFactorIds.isEmpty()) { + carbonEmissionFactorJpaRepository.deleteAllById(createdCarbonEmissionFactorIds); + } + + if (!createdUserUuids.isEmpty()) { + createdUserUuids.forEach(uuid -> { + userJpaRepository.findByUserUuid(uuid).ifPresent(userJpaRepository::delete); + }); + } + + createdUserUuids.clear(); + createdCarbonEmissionFactorIds.clear(); + createdItemTypeIds.clear(); + createdItemIds.clear(); + createdTradeIds.clear(); + createdTradeItemIds.clear(); + } + + + @Test + @DisplayName("관리자용 파트너 누적/품목 누적 통계 조회 API가 정상 동작한다") + void getPartnerTotalStats_admin_e2e() throws Exception { + // given + UUID adminUuid = UUID.randomUUID(); + UUID partnerUuid = UUID.randomUUID(); + saveUser(adminUuid, UserType.ADMIN, "admin", BranchOptionVo.of(1L, "본사")); + saveUser(partnerUuid, UserType.PERSONAL_PARTNER, "partner", null); + createdUserUuids.add(adminUuid); + createdUserUuids.add(partnerUuid); + + CarbonEmissionFactorEntity factor = createCarbonEmissionFactor(); + ItemTypeEntity itemType = createItemType("철금속류", "METAL"); + ItemEntity itemA = createItem(itemType.getItemTypeId(), "철스크랩", "ITEM001", factor.getId()); + ItemEntity itemB = createItem(itemType.getItemTypeId(), "알루미늄캔", "ITEM002", factor.getId()); + + LocalDateTime tradeAt1 = LocalDateTime.of(2024, 9, 1, 12, 30, 45, 123_000_000); + LocalDateTime tradeAt2 = LocalDateTime.of(2024, 9, 2, 10, 5, 30, 789_000_000); + + TradeEntity trade1 = createTrade(partnerUuid, adminUuid, tradeAt1, + new BigDecimal("1000"), new BigDecimal("80"), new BigDecimal("75")); + TradeEntity trade2 = createTrade(partnerUuid, adminUuid, tradeAt2, + new BigDecimal("500"), new BigDecimal("50"), new BigDecimal("45")); + + createTradeItem(trade1, itemA, factor.getId(), new BigDecimal("50"), new BigDecimal("5"), new BigDecimal("600")); + createTradeItem(trade1, itemB, factor.getId(), new BigDecimal("30"), new BigDecimal("0"), new BigDecimal("400")); + createTradeItem(trade2, itemA, factor.getId(), new BigDecimal("30"), new BigDecimal("5"), new BigDecimal("300")); + createTradeItem(trade2, itemB, factor.getId(), new BigDecimal("20"), new BigDecimal("0"), new BigDecimal("200")); + + Users adminUser = Users.builder() + .userUuid(adminUuid) + .userType(UserType.ADMIN) + .userName("admin") + .userPhoneNumber("01099998888") + .userLoginId("admin-" + adminUuid) + .userPassword("password") + .branchOption(BranchOption.builder().branchId(1L).branchName("본사").build()) + .build(); + CustomUserDetails principal = new CustomUserDetails(adminUser); + + // when & then - 파트너 누적 통계 조회 + mockMvc.perform(get("/api/v1/stats/total/partner/{partnerUuid}", partnerUuid) + .with(SecurityMockMvcRequestPostProcessors.user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.totalTradeCount").value(2)) + .andExpect(jsonPath("$.result.totalPayloadWeight").value(120)) + .andExpect(jsonPath("$.result.totalTradeAmount").value(1500)) + .andExpect(jsonPath("$.result.lastTradeTime").value("2024-09-02T10:05:30")); + + // when & then - 파트너 품목별 누적 통계 조회 + mockMvc.perform(get("/api/v1/stats/total/item/partner/{partnerUuid}", partnerUuid) + .with(SecurityMockMvcRequestPostProcessors.user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.itemStatsList", Matchers.hasSize(2))) + .andExpect(jsonPath("$.result.itemStatsList[?(@.itemId==%d)].totalPayloadWeight", itemA.getId()) + .value(Matchers.contains(70.0))) + .andExpect(jsonPath("$.result.itemStatsList[?(@.itemId==%d)].totalTradeAmount", itemA.getId()) + .value(Matchers.contains(900.0))) + .andExpect(jsonPath("$.result.itemStatsList[?(@.itemId==%d)].lastTradeTime", itemA.getId()) + .value(Matchers.contains("2024-09-02T10:05:30"))); + } + + + @Test + @DisplayName("파트너용 파트너 누적/품목 누적 통계 조회 API가 정상 동작한다") + void getPartnerTotalStats_partner_e2e() throws Exception { + // given + UUID adminUuid = UUID.randomUUID(); + UUID partnerUuid = UUID.randomUUID(); + saveUser(adminUuid, UserType.ADMIN, "admin", BranchOptionVo.of(1L, "본사")); + saveUser(partnerUuid, UserType.CORPORATE_PARTNER, "partner", null); + createdUserUuids.add(adminUuid); + createdUserUuids.add(partnerUuid); + + CarbonEmissionFactorEntity factor = createCarbonEmissionFactor(); + ItemTypeEntity itemType = createItemType("비철금속류", "NONMETAL"); + ItemEntity item = createItem(itemType.getItemTypeId(), "구리", "ITEM003", factor.getId()); + + LocalDateTime tradeAt = LocalDateTime.of(2024, 9, 10, 9, 15, 30, 999_000_000); + TradeEntity trade = createTrade(partnerUuid, adminUuid, tradeAt, + new BigDecimal("700"), new BigDecimal("40"), new BigDecimal("40")); + createTradeItem(trade, item, factor.getId(), new BigDecimal("40"), new BigDecimal("0"), new BigDecimal("700")); + + Users partnerUser = Users.builder() + .userUuid(partnerUuid) + .userType(UserType.CORPORATE_PARTNER) + .userName("partner") + .userPhoneNumber("01088887777") + .userLoginId("partner-" + partnerUuid) + .userPassword("password") + .build(); + CustomUserDetails principal = new CustomUserDetails(partnerUser); + + // when & then - 파트너 누적 통계 조회(본인) + mockMvc.perform(get("/api/v1/stats/total/partner/my") + .with(SecurityMockMvcRequestPostProcessors.user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.totalTradeCount").value(1)) + .andExpect(jsonPath("$.result.totalPayloadWeight").value(40)) + .andExpect(jsonPath("$.result.totalTradeAmount").value(700)) + .andExpect(jsonPath("$.result.lastTradeTime").value("2024-09-10T09:15:30")); + + // when & then - 파트너 품목별 누적 통계 조회(본인) + mockMvc.perform(get("/api/v1/stats/total/item/partner/my") + .with(SecurityMockMvcRequestPostProcessors.user(principal))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.result.itemStatsList", Matchers.hasSize(1))) + .andExpect(jsonPath("$.result.itemStatsList[0].itemId").value(item.getId().intValue())) + .andExpect(jsonPath("$.result.itemStatsList[0].totalTradeAmount").value(700)) + .andExpect(jsonPath("$.result.itemStatsList[0].lastTradeTime").value("2024-09-10T09:15:30")); + } + + + private void saveUser(UUID uuid, UserType userType, String name, BranchOptionVo branchOption) { + userJpaRepository.save(UserEntity.builder() + .userType(userType) + .userUuid(uuid) + .userName(name) + .userPhoneNumber("010-" + uuid.toString().substring(0, 8)) + .userLoginId(name + "-" + uuid) + .userPassword("password") + .branchOption(branchOption) + .build()); + } + + + private CarbonEmissionFactorEntity createCarbonEmissionFactor() { + CarbonEmissionFactorEntity factor = carbonEmissionFactorJpaRepository.save( + CarbonEmissionFactorEntity.builder() + .code("TEST-" + UUID.randomUUID()) + .description("test factor") + .emissionFactorKgCo2ePerKg(new BigDecimal("1.234")) + .build() + ); + createdCarbonEmissionFactorIds.add(factor.getId()); + return factor; + } + + + private ItemTypeEntity createItemType(String typeName, String typeCode) { + ItemTypeEntity itemType = itemTypeJpaRepository.save(ItemTypeEntity.builder() + .typeName(typeName) + .typeCode(typeCode + "-" + UUID.randomUUID().toString().substring(0, 4)) + .isEnabled(true) + .build()); + createdItemTypeIds.add(itemType.getItemTypeId()); + return itemType; + } + + + private ItemEntity createItem(Integer itemTypeId, String itemName, String itemCode, Long carbonEmissionFactorId) { + ItemEntity item = itemJpaRepository.save(ItemEntity.builder() + .itemName(itemName) + .itemCode(itemCode + "-" + UUID.randomUUID().toString().substring(0, 4)) + .isEnabled(true) + .itemTypeId(itemTypeId) + .unitPrice(100) + .unit(Unit.KG) + .carbonEmissionFactorId(carbonEmissionFactorId) + .build()); + createdItemIds.add(item.getId()); + return item; + } + + + private TradeEntity createTrade(UUID sellerUuid, UUID buyerUuid, LocalDateTime tradeAt, + BigDecimal tradeAmount, BigDecimal payloadWeight, BigDecimal netPayloadWeight) { + + BigDecimal totalWeightLoss = payloadWeight.subtract(netPayloadWeight); + + TradeEntity trade = tradeJpaRepository.save(TradeEntity.builder() + .tradeNumber("T-" + UUID.randomUUID().toString().substring(0, 8)) + .tradeVehicleNumber("12가3456") + .sellerType(UserType.PERSONAL_PARTNER) + .buyerType(UserType.ADMIN) + .sellerUuid(sellerUuid) + .buyerUuid(buyerUuid) + .sellerName("seller") + .buyerName("admin") + .buyerBranchId(1L) + .buyerBranchName("본사") + .sellerBranchId(null) + .sellerBranchName(null) + .sellerPhoneNumber("01011112222") + .totalWeight(payloadWeight.add(new BigDecimal("10"))) + .curbWeight(new BigDecimal("10")) + .payloadWeight(payloadWeight) + .netPayloadWeight(netPayloadWeight) + .totalWeightLoss(totalWeightLoss) + .totalWeightLossRate(new BigDecimal("0")) + .tradeAmount(tradeAmount) + .tradeAt(tradeAt) + .carbonReductionAmount(BigDecimal.ZERO) + .memo("memo") + .build()); + + createdTradeIds.add(trade.getId()); + return trade; + } + + + private void createTradeItem(TradeEntity trade, ItemEntity item, Long carbonEmissionFactorId, + BigDecimal weight, BigDecimal weightLoss, BigDecimal itemTradeAmount) { + + TradeItemEntity tradeItem = tradeItemJpaRepository.save(TradeItemEntity.builder() + .trade(trade) + .itemId(item.getId()) + .itemName(item.getItemName()) + .weight(weight) + .weightLoss(weightLoss) + .netPayloadWeight(weightLoss == null ? weight : weight.subtract(weightLoss)) + .userTradeItemUnitPrice(new BigDecimal("10")) + .defaultTradeItemUnitPrice(new BigDecimal("9")) + .itemTradeAmount(itemTradeAmount) + .carbonEmissionFactorId(carbonEmissionFactorId) + .build()); + + createdTradeItemIds.add(tradeItem.getId()); + } + +} diff --git a/src/test/java/greenfirst/be/stats/application/service/daily/DailyStatsQueryServiceUnitTest.java b/src/test/java/greenfirst/be/stats/application/service/daily/DailyStatsQueryServiceUnitTest.java index cd1c1d8..a33061c 100644 --- a/src/test/java/greenfirst/be/stats/application/service/daily/DailyStatsQueryServiceUnitTest.java +++ b/src/test/java/greenfirst/be/stats/application/service/daily/DailyStatsQueryServiceUnitTest.java @@ -6,9 +6,11 @@ import greenfirst.be.global.common.response.base.BaseResponseStatus; import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyStatsInDto; +import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyTradeStatsInDto; import greenfirst.be.stats.application.dto.out.BranchDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.BranchDailyStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerDailyTradeStatsOutDto; import greenfirst.be.stats.application.service.daily.fake.FakeDailyStatsQueryRepository; import greenfirst.be.stats.domain.service.QueryDatePolicy; @@ -805,6 +807,123 @@ void getBranchDailyItemStats_MultipleItemsSameDate_ReturnsSeparately() { } + @Nested + @DisplayName("파트너별 일별 품목 통계 조회") + class GetPartnerDailyItemStatsTest { + + private final UUID partnerUuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + private final UUID otherPartnerUuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174001"); + + + @Test + @DisplayName("성공: ADMIN은 모든 파트너의 품목 통계를 조회할 수 있다") + void getPartnerDailyItemStats_Admin_CanAccessAnyPartner() { + // given + UUID adminUuid = UUID.randomUUID(); + LocalDate startDate = LocalDate.of(2024, 9, 1); + LocalDate endDate = LocalDate.of(2024, 9, 30); + + fakeRepository.addPartnerDailyItemStats(partnerUuid, LocalDate.of(2024, 9, 20), + 101L, "알루미늄캔", "ITEM002", 2, "비철금속류", + 5L, new BigDecimal("50.30"), new BigDecimal("500000")); + fakeRepository.addPartnerDailyItemStats(partnerUuid, LocalDate.of(2024, 9, 10), + 100L, "철스크랩", "ITEM001", 1, "철금속류", + 10L, new BigDecimal("100.50"), new BigDecimal("1000000")); + + GetPartnerDailyItemStatsInDto inDto = GetPartnerDailyItemStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(adminUuid) + .requestorType(UserType.ADMIN) + .startDate(startDate) + .endDate(endDate) + .build(); + + // when + List result = dailyStatsQueryService.getPartnerDailyItemStats(inDto); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).getStatsDate()).isEqualTo(LocalDate.of(2024, 9, 10)); + assertThat(result.get(0).getItemId()).isEqualTo(100L); + assertThat(result.get(1).getStatsDate()).isEqualTo(LocalDate.of(2024, 9, 20)); + } + + + @Test + @DisplayName("성공: PERSONAL_PARTNER는 자신의 품목 통계를 조회할 수 있다") + void getPartnerDailyItemStats_PersonalPartner_CanAccessOwnStats() { + // given + LocalDate startDate = LocalDate.of(2024, 9, 1); + LocalDate endDate = LocalDate.of(2024, 9, 30); + + fakeRepository.addPartnerDailyItemStats(partnerUuid, LocalDate.of(2024, 9, 15), + 100L, "철스크랩", "ITEM001", 1, "철금속류", + 10L, new BigDecimal("100.50"), new BigDecimal("1000000")); + + GetPartnerDailyItemStatsInDto inDto = GetPartnerDailyItemStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(partnerUuid) + .requestorType(UserType.PERSONAL_PARTNER) + .startDate(startDate) + .endDate(endDate) + .build(); + + // when + List result = dailyStatsQueryService.getPartnerDailyItemStats(inDto); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getTotalTradeAmount()).isEqualByComparingTo(new BigDecimal("1000000")); + } + + + @Test + @DisplayName("실패: AGENCY는 파트너 품목 통계를 조회할 수 없다") + void getPartnerDailyItemStats_Agency_CannotAccessStats() { + // given + UUID agencyUuid = UUID.randomUUID(); + LocalDate startDate = LocalDate.of(2024, 9, 1); + LocalDate endDate = LocalDate.of(2024, 9, 30); + + GetPartnerDailyItemStatsInDto inDto = GetPartnerDailyItemStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(agencyUuid) + .requestorType(UserType.AGENCY) + .startDate(startDate) + .endDate(endDate) + .build(); + + // when & then + assertThatThrownBy(() -> dailyStatsQueryService.getPartnerDailyItemStats(inDto)) + .isInstanceOf(BaseException.class) + .hasFieldOrPropertyWithValue("status", BaseResponseStatus.PARTNER_STATS_ACCESS_DENIED); + } + + + @Test + @DisplayName("실패: PERSONAL_PARTNER가 다른 파트너의 품목 통계를 조회하면 예외가 발생한다") + void getPartnerDailyItemStats_PersonalPartner_CannotAccessOtherPartner() { + // given + UUID requestorPartnerUuid = UUID.randomUUID(); + LocalDate startDate = LocalDate.of(2024, 9, 1); + LocalDate endDate = LocalDate.of(2024, 9, 30); + + GetPartnerDailyItemStatsInDto inDto = GetPartnerDailyItemStatsInDto.builder() + .targetPartnerUuid(otherPartnerUuid) + .requestorUuid(requestorPartnerUuid) + .requestorType(UserType.PERSONAL_PARTNER) + .startDate(startDate) + .endDate(endDate) + .build(); + + // when & then + assertThatThrownBy(() -> dailyStatsQueryService.getPartnerDailyItemStats(inDto)) + .isInstanceOf(BaseException.class) + .hasFieldOrPropertyWithValue("status", BaseResponseStatus.PARTNER_STATS_ACCESS_DENIED); + } + + } + @Nested @DisplayName("파트너별 일별 거래 통계 조회") class GetPartnerDailyTradeStatsTest { @@ -844,17 +963,13 @@ void getPartnerDailyTradeStats_Admin_CanAccessAnyPartner() { @Test - @DisplayName("성공: AGENCY는 모든 파트너의 통계를 조회할 수 있다") - void getPartnerDailyTradeStats_Agency_CanAccessAnyPartner() { + @DisplayName("실패: AGENCY는 파트너 통계를 조회할 수 없다") + void getPartnerDailyTradeStats_Agency_CannotAccessStats() { // given UUID agencyUuid = UUID.randomUUID(); LocalDate startDate = LocalDate.of(2024, 9, 1); LocalDate endDate = LocalDate.of(2024, 9, 30); - // 파트너 데이터 추가 - fakeRepository.addPartnerDailyTradeStats(partnerUuid, LocalDate.of(2024, 9, 15), - new BigDecimal("800000"), 8L, new BigDecimal("80.30")); - GetPartnerDailyTradeStatsInDto inDto = GetPartnerDailyTradeStatsInDto.builder() .targetPartnerUuid(partnerUuid) .requestorUuid(agencyUuid) @@ -863,12 +978,10 @@ void getPartnerDailyTradeStats_Agency_CanAccessAnyPartner() { .endDate(endDate) .build(); - // when - List result = dailyStatsQueryService.getPartnerDailyTradeStats(inDto); - - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).getTotalTradeAmount()).isEqualByComparingTo(new BigDecimal("800000")); + // when & then + assertThatThrownBy(() -> dailyStatsQueryService.getPartnerDailyTradeStats(inDto)) + .isInstanceOf(BaseException.class) + .hasFieldOrPropertyWithValue("status", BaseResponseStatus.PARTNER_STATS_ACCESS_DENIED); } diff --git a/src/test/java/greenfirst/be/stats/application/service/daily/fake/FakeDailyStatsQueryRepository.java b/src/test/java/greenfirst/be/stats/application/service/daily/fake/FakeDailyStatsQueryRepository.java index 5685502..53cd965 100644 --- a/src/test/java/greenfirst/be/stats/application/service/daily/fake/FakeDailyStatsQueryRepository.java +++ b/src/test/java/greenfirst/be/stats/application/service/daily/fake/FakeDailyStatsQueryRepository.java @@ -3,9 +3,11 @@ import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetBranchDailyStatsInDto; +import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyItemStatsInDto; import greenfirst.be.stats.application.dto.in.daily.GetPartnerDailyTradeStatsInDto; import greenfirst.be.stats.application.dto.out.BranchDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.BranchDailyStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerDailyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerDailyTradeStatsOutDto; import greenfirst.be.stats.application.port.out.daily.DailyStatsQueryRepository; @@ -21,6 +23,7 @@ public class FakeDailyStatsQueryRepository implements DailyStatsQueryRepository private final List storage = new ArrayList<>(); private final List itemStorage = new ArrayList<>(); private final List partnerStorage = new ArrayList<>(); + private final List partnerItemStorage = new ArrayList<>(); @Override @@ -109,6 +112,7 @@ public void clear() { storage.clear(); itemStorage.clear(); partnerStorage.clear(); + partnerItemStorage.clear(); } @@ -132,6 +136,34 @@ public List getPartnerDailyTradeStats(GetPartnerDa .toList(); } + @Override + public List getPartnerDailyItemStats(GetPartnerDailyItemStatsInDto inDto) { + UUID targetPartnerUuid = inDto.getTargetPartnerUuid(); + LocalDate startDate = inDto.getStartDate(); + LocalDate endDate = inDto.getEndDate(); + + return partnerItemStorage.stream() + .filter(stats -> stats.partnerUuid.equals(targetPartnerUuid)) + .filter(stats -> !stats.statsDate.isBefore(startDate) && !stats.statsDate.isAfter(endDate)) + .map(stats -> new PartnerDailyItemStatsOutDto( + java.sql.Date.valueOf(stats.statsDate), + stats.itemId, + stats.itemName, + stats.itemCode, + stats.itemTypeId, + stats.itemTypeName, + stats.totalTradeCount, + stats.totalPayloadWeight, + stats.totalTradeAmount + )) + .sorted((a, b) -> { + int dateComp = a.getStatsDate().compareTo(b.getStatsDate()); + if (dateComp != 0) return dateComp; + return a.getItemId().compareTo(b.getItemId()); + }) + .toList(); + } + // 내부 테스트 데이터 클래스 private static class TestDailyStats { @@ -207,4 +239,42 @@ private static class TestPartnerDailyTradeStats { } + private static class TestPartnerDailyItemStats { + + UUID partnerUuid; + LocalDate statsDate; + Long itemId; + String itemName; + String itemCode; + Integer itemTypeId; + String itemTypeName; + long totalTradeCount; + BigDecimal totalPayloadWeight; + BigDecimal totalTradeAmount; + + + TestPartnerDailyItemStats(UUID partnerUuid, LocalDate statsDate, Long itemId, String itemName, String itemCode, + Integer itemTypeId, String itemTypeName, long totalTradeCount, + BigDecimal totalPayloadWeight, BigDecimal totalTradeAmount) { + this.partnerUuid = partnerUuid; + this.statsDate = statsDate; + this.itemId = itemId; + this.itemName = itemName; + this.itemCode = itemCode; + this.itemTypeId = itemTypeId; + this.itemTypeName = itemTypeName; + this.totalTradeCount = totalTradeCount; + this.totalPayloadWeight = totalPayloadWeight; + this.totalTradeAmount = totalTradeAmount; + } + + } + + public void addPartnerDailyItemStats(UUID partnerUuid, LocalDate statsDate, Long itemId, String itemName, + String itemCode, Integer itemTypeId, String itemTypeName, long tradeCount, BigDecimal payloadWeight, + BigDecimal tradeAmount) { + partnerItemStorage.add(new TestPartnerDailyItemStats(partnerUuid, statsDate, itemId, itemName, itemCode, + itemTypeId, itemTypeName, tradeCount, payloadWeight, tradeAmount)); + } + } diff --git a/src/test/java/greenfirst/be/stats/application/service/monthly/MonthlyStatsQueryServiceUnitTest.java b/src/test/java/greenfirst/be/stats/application/service/monthly/MonthlyStatsQueryServiceUnitTest.java index c070dd1..9bfe915 100644 --- a/src/test/java/greenfirst/be/stats/application/service/monthly/MonthlyStatsQueryServiceUnitTest.java +++ b/src/test/java/greenfirst/be/stats/application/service/monthly/MonthlyStatsQueryServiceUnitTest.java @@ -4,7 +4,9 @@ import greenfirst.be.global.common.enums.common.UserType; import greenfirst.be.global.common.exception.BaseException; import greenfirst.be.global.common.response.base.BaseResponseStatus; +import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyStatsInDto; +import greenfirst.be.stats.application.dto.out.PartnerMonthlyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerMonthlyStatsOutDto; import greenfirst.be.stats.application.service.monthly.fake.FakeMonthlyStatsQueryRepository; import greenfirst.be.stats.domain.service.QueryDatePolicy; @@ -84,16 +86,13 @@ void getPartnerMonthlyStats_Admin_CanAccessAnyPartner() { @Test - @DisplayName("성공: AGENCY는 모든 파트너의 통계를 조회할 수 있다") - void getPartnerMonthlyStats_Agency_CanAccessAnyPartner() { + @DisplayName("실패: AGENCY는 파트너 통계를 조회할 수 없다") + void getPartnerMonthlyStats_Agency_CannotAccessStats() { // given UUID agencyUuid = UUID.randomUUID(); LocalDate startDate = LocalDate.of(2024, 2, 1); LocalDate endDate = LocalDate.of(2024, 2, 28); - fakeRepository.addPartnerMonthlyStats(partnerUuid, LocalDate.of(2024, 2, 1), - new BigDecimal("800000"), 8L, new BigDecimal("80.30")); - GetPartnerMonthlyStatsInDto inDto = GetPartnerMonthlyStatsInDto.builder() .targetPartnerUuid(partnerUuid) .requestorUuid(agencyUuid) @@ -102,13 +101,10 @@ void getPartnerMonthlyStats_Agency_CanAccessAnyPartner() { .endDate(endDate) .build(); - // when - List result = - monthlyStatsQueryService.getPartnerMonthlyStats(inDto); - - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).getTotalTradeAmount()).isEqualByComparingTo(new BigDecimal("800000")); + // when & then + assertThatThrownBy(() -> monthlyStatsQueryService.getPartnerMonthlyStats(inDto)) + .isInstanceOf(BaseException.class) + .hasFieldOrPropertyWithValue("status", BaseResponseStatus.PARTNER_STATS_ACCESS_DENIED); } @@ -470,6 +466,125 @@ void getPartnerMonthlyStats_ZeroValues_Success() { } + @Nested + @DisplayName("파트너별 월별 품목 통계 조회") + class GetPartnerMonthlyItemStatsTest { + + private final UUID partnerUuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + private final UUID otherPartnerUuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174001"); + + + @Test + @DisplayName("성공: ADMIN은 모든 파트너의 품목 통계를 조회할 수 있다") + void getPartnerMonthlyItemStats_Admin_CanAccessAnyPartner() { + // given + UUID adminUuid = UUID.randomUUID(); + LocalDate startDate = LocalDate.of(2024, 2, 1); + LocalDate endDate = LocalDate.of(2024, 3, 31); + + fakeRepository.addPartnerMonthlyItemStats(partnerUuid, LocalDate.of(2024, 3, 1), + 101L, "알루미늄캔", "ITEM002", 2, "비철금속류", + 5L, new BigDecimal("50.30"), new BigDecimal("500000")); + fakeRepository.addPartnerMonthlyItemStats(partnerUuid, LocalDate.of(2024, 2, 1), + 100L, "철스크랩", "ITEM001", 1, "철금속류", + 10L, new BigDecimal("100.50"), new BigDecimal("1000000")); + + GetPartnerMonthlyItemStatsInDto inDto = GetPartnerMonthlyItemStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(adminUuid) + .requestorType(UserType.ADMIN) + .startDate(startDate) + .endDate(endDate) + .build(); + + // when + List result = + monthlyStatsQueryService.getPartnerMonthlyItemStats(inDto); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).getStatsMonth()).isEqualTo(YearMonth.of(2024, 2)); + assertThat(result.get(0).getItemId()).isEqualTo(100L); + assertThat(result.get(1).getStatsMonth()).isEqualTo(YearMonth.of(2024, 3)); + } + + + @Test + @DisplayName("성공: PERSONAL_PARTNER는 자신의 품목 통계를 조회할 수 있다") + void getPartnerMonthlyItemStats_PersonalPartner_CanAccessOwnStats() { + // given + LocalDate startDate = LocalDate.of(2024, 4, 1); + LocalDate endDate = LocalDate.of(2024, 4, 30); + + fakeRepository.addPartnerMonthlyItemStats(partnerUuid, LocalDate.of(2024, 4, 1), + 100L, "철스크랩", "ITEM001", 1, "철금속류", + 10L, new BigDecimal("100.50"), new BigDecimal("1000000")); + + GetPartnerMonthlyItemStatsInDto inDto = GetPartnerMonthlyItemStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(partnerUuid) + .requestorType(UserType.PERSONAL_PARTNER) + .startDate(startDate) + .endDate(endDate) + .build(); + + // when + List result = + monthlyStatsQueryService.getPartnerMonthlyItemStats(inDto); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getTotalTradeAmount()).isEqualByComparingTo(new BigDecimal("1000000")); + } + + + @Test + @DisplayName("실패: AGENCY는 파트너 품목 통계를 조회할 수 없다") + void getPartnerMonthlyItemStats_Agency_CannotAccessStats() { + // given + UUID agencyUuid = UUID.randomUUID(); + LocalDate startDate = LocalDate.of(2024, 2, 1); + LocalDate endDate = LocalDate.of(2024, 2, 28); + + GetPartnerMonthlyItemStatsInDto inDto = GetPartnerMonthlyItemStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(agencyUuid) + .requestorType(UserType.AGENCY) + .startDate(startDate) + .endDate(endDate) + .build(); + + // when & then + assertThatThrownBy(() -> monthlyStatsQueryService.getPartnerMonthlyItemStats(inDto)) + .isInstanceOf(BaseException.class) + .hasFieldOrPropertyWithValue("status", BaseResponseStatus.PARTNER_STATS_ACCESS_DENIED); + } + + + @Test + @DisplayName("실패: PERSONAL_PARTNER가 다른 파트너의 품목 통계를 조회하면 예외가 발생한다") + void getPartnerMonthlyItemStats_PersonalPartner_CannotAccessOtherPartner() { + // given + UUID requestorPartnerUuid = UUID.randomUUID(); + LocalDate startDate = LocalDate.of(2024, 5, 1); + LocalDate endDate = LocalDate.of(2024, 5, 31); + + GetPartnerMonthlyItemStatsInDto inDto = GetPartnerMonthlyItemStatsInDto.builder() + .targetPartnerUuid(otherPartnerUuid) + .requestorUuid(requestorPartnerUuid) + .requestorType(UserType.PERSONAL_PARTNER) + .startDate(startDate) + .endDate(endDate) + .build(); + + // when & then + assertThatThrownBy(() -> monthlyStatsQueryService.getPartnerMonthlyItemStats(inDto)) + .isInstanceOf(BaseException.class) + .hasFieldOrPropertyWithValue("status", BaseResponseStatus.PARTNER_STATS_ACCESS_DENIED); + } + + } + @Nested @DisplayName("파트너 월별 통계 날짜 검증") class PartnerMonthlyStatsDateValidationTest { diff --git a/src/test/java/greenfirst/be/stats/application/service/monthly/fake/FakeMonthlyStatsQueryRepository.java b/src/test/java/greenfirst/be/stats/application/service/monthly/fake/FakeMonthlyStatsQueryRepository.java index d86ebda..0d96f07 100644 --- a/src/test/java/greenfirst/be/stats/application/service/monthly/fake/FakeMonthlyStatsQueryRepository.java +++ b/src/test/java/greenfirst/be/stats/application/service/monthly/fake/FakeMonthlyStatsQueryRepository.java @@ -3,10 +3,12 @@ import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetBranchMonthlyStatsInDto; +import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyItemStatsInDto; import greenfirst.be.stats.application.dto.in.monthly.GetPartnerMonthlyStatsInDto; import greenfirst.be.stats.application.dto.out.AllBranchStatsSumOutDto; import greenfirst.be.stats.application.dto.out.BranchMonthlyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.BranchMonthlyStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerMonthlyItemStatsOutDto; import greenfirst.be.stats.application.dto.out.PartnerMonthlyStatsOutDto; import greenfirst.be.stats.application.port.out.monthly.MonthlyStatsQueryRepository; @@ -25,6 +27,7 @@ public class FakeMonthlyStatsQueryRepository implements MonthlyStatsQueryRepository { private final List partnerStorage = new ArrayList<>(); + private final List partnerItemStorage = new ArrayList<>(); private AllBranchStatsSumOutDto allBranchStatsSum = new AllBranchStatsSumOutDto(0L, BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO); @@ -91,6 +94,37 @@ public List getPartnerMonthlyStats(GetPartnerMonthlyS .toList(); } + @Override + public List getPartnerMonthlyItemStats(GetPartnerMonthlyItemStatsInDto inDto) { + UUID targetPartnerUuid = inDto.getTargetPartnerUuid(); + LocalDate startDate = inDto.getStartDate(); + LocalDate endDate = inDto.getEndDate(); + + LocalDate startMonth = YearMonth.from(startDate).atDay(1); + LocalDate endMonth = YearMonth.from(endDate).atDay(1); + + return partnerItemStorage.stream() + .filter(stats -> stats.partnerUuid.equals(targetPartnerUuid)) + .filter(stats -> !stats.statsMonth.isBefore(startMonth) && !stats.statsMonth.isAfter(endMonth)) + .map(stats -> new PartnerMonthlyItemStatsOutDto( + Date.valueOf(stats.statsMonth), + stats.itemId, + stats.itemName, + stats.itemCode, + stats.itemTypeId, + stats.itemTypeName, + stats.totalTradeCount, + stats.totalPayloadWeight, + stats.totalTradeAmount + )) + .sorted((a, b) -> { + int monthComp = a.getStatsMonth().compareTo(b.getStatsMonth()); + if (monthComp != 0) return monthComp; + return a.getItemId().compareTo(b.getItemId()); + }) + .toList(); + } + public void addPartnerMonthlyStats(UUID partnerUuid, LocalDate statsMonth, BigDecimal tradeAmount, long tradeCount, BigDecimal payloadWeight) { @@ -106,6 +140,7 @@ public void addPartnerMonthlyStats(UUID partnerUuid, LocalDate statsMonth, BigDe public void clear() { partnerStorage.clear(); + partnerItemStorage.clear(); } public void setAllBranchStatsSum(AllBranchStatsSumOutDto allBranchStatsSum) { @@ -151,4 +186,53 @@ private AggregatedPartnerMonthlyStats(LocalDate statsMonth, BigDecimal totalTrad } + private static class TestPartnerMonthlyItemStats { + + private final UUID partnerUuid; + private final LocalDate statsMonth; + private final Long itemId; + private final String itemName; + private final String itemCode; + private final Integer itemTypeId; + private final String itemTypeName; + private final long totalTradeCount; + private final BigDecimal totalPayloadWeight; + private final BigDecimal totalTradeAmount; + + + private TestPartnerMonthlyItemStats(UUID partnerUuid, LocalDate statsMonth, Long itemId, String itemName, + String itemCode, Integer itemTypeId, String itemTypeName, long totalTradeCount, + BigDecimal totalPayloadWeight, BigDecimal totalTradeAmount) { + this.partnerUuid = partnerUuid; + this.statsMonth = statsMonth; + this.itemId = itemId; + this.itemName = itemName; + this.itemCode = itemCode; + this.itemTypeId = itemTypeId; + this.itemTypeName = itemTypeName; + this.totalTradeCount = totalTradeCount; + this.totalPayloadWeight = totalPayloadWeight; + this.totalTradeAmount = totalTradeAmount; + } + + } + + public void addPartnerMonthlyItemStats(UUID partnerUuid, LocalDate statsMonth, Long itemId, String itemName, + String itemCode, Integer itemTypeId, String itemTypeName, long tradeCount, BigDecimal payloadWeight, + BigDecimal tradeAmount) { + LocalDate normalizedMonth = YearMonth.from(statsMonth).atDay(1); + partnerItemStorage.add(new TestPartnerMonthlyItemStats( + partnerUuid, + normalizedMonth, + itemId, + itemName, + itemCode, + itemTypeId, + itemTypeName, + tradeCount, + payloadWeight, + tradeAmount + )); + } + } diff --git a/src/test/java/greenfirst/be/stats/application/service/total/TotalStatsQueryServiceUnitTest.java b/src/test/java/greenfirst/be/stats/application/service/total/TotalStatsQueryServiceUnitTest.java new file mode 100644 index 0000000..3341f6b --- /dev/null +++ b/src/test/java/greenfirst/be/stats/application/service/total/TotalStatsQueryServiceUnitTest.java @@ -0,0 +1,201 @@ +package greenfirst.be.stats.application.service.total; + + +import greenfirst.be.global.common.enums.common.UserType; +import greenfirst.be.global.common.exception.BaseException; +import greenfirst.be.global.common.response.base.BaseResponseStatus; +import greenfirst.be.stats.application.dto.in.total.GetPartnerItemTotalStatsInDto; +import greenfirst.be.stats.application.dto.in.total.GetPartnerTotalStatsInDto; +import greenfirst.be.stats.application.dto.out.PartnerItemTotalStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerTotalStatsOutDto; +import greenfirst.be.stats.application.service.total.fake.FakeTotalStatsQueryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.modelmapper.ModelMapper; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + + +@DisplayName("TotalStatsQueryService 단위 테스트") +class TotalStatsQueryServiceUnitTest { + + private TotalStatsQueryService totalStatsQueryService; + private FakeTotalStatsQueryRepository fakeRepository; + private ModelMapper modelMapper; + + + @BeforeEach + void setUp() { + fakeRepository = new FakeTotalStatsQueryRepository(); + modelMapper = new ModelMapper(); + totalStatsQueryService = new TotalStatsQueryService(fakeRepository, modelMapper); + } + + + @Nested + @DisplayName("파트너 누적 통계 조회") + class GetPartnerTotalStatsTest { + + private final UUID partnerUuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + private final UUID otherPartnerUuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174001"); + + + @Test + @DisplayName("성공: ADMIN은 모든 파트너의 누적 통계를 조회할 수 있다") + void getPartnerTotalStats_Admin_CanAccessAnyPartner() { + // given + UUID adminUuid = UUID.randomUUID(); + LocalDateTime lastTradeTime = LocalDateTime.of(2024, 9, 1, 12, 30, 45); + + fakeRepository.addPartnerTotalStats(partnerUuid, 10L, + new BigDecimal("100.50"), new BigDecimal("1000000"), lastTradeTime); + + GetPartnerTotalStatsInDto inDto = GetPartnerTotalStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(adminUuid) + .requestorType(UserType.ADMIN) + .build(); + + // when + PartnerTotalStatsOutDto result = totalStatsQueryService.getPartnerTotalStats(inDto); + + // then + assertThat(result.getTotalTradeCount()).isEqualTo(10L); + assertThat(result.getTotalPayloadWeight()).isEqualByComparingTo(new BigDecimal("100.50")); + assertThat(result.getTotalTradeAmount()).isEqualByComparingTo(new BigDecimal("1000000")); + assertThat(result.getLastTradeTime()).isEqualTo(lastTradeTime); + } + + + @Test + @DisplayName("실패: PERSONAL_PARTNER가 다른 파트너의 누적 통계를 조회하면 예외가 발생한다") + void getPartnerTotalStats_PersonalPartner_CannotAccessOtherPartner() { + // given + UUID requestorPartnerUuid = UUID.randomUUID(); + + GetPartnerTotalStatsInDto inDto = GetPartnerTotalStatsInDto.builder() + .targetPartnerUuid(otherPartnerUuid) + .requestorUuid(requestorPartnerUuid) + .requestorType(UserType.PERSONAL_PARTNER) + .build(); + + // when & then + assertThatThrownBy(() -> totalStatsQueryService.getPartnerTotalStats(inDto)) + .isInstanceOf(BaseException.class) + .hasFieldOrPropertyWithValue("status", BaseResponseStatus.PARTNER_STATS_ACCESS_DENIED); + } + + + @Test + @DisplayName("성공: 데이터가 없으면 0으로 반환된다") + void getPartnerTotalStats_NoData_ReturnsZero() { + // given + UUID adminUuid = UUID.randomUUID(); + + GetPartnerTotalStatsInDto inDto = GetPartnerTotalStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(adminUuid) + .requestorType(UserType.ADMIN) + .build(); + + // when + PartnerTotalStatsOutDto result = totalStatsQueryService.getPartnerTotalStats(inDto); + + // then + assertThat(result.getTotalTradeCount()).isEqualTo(0L); + assertThat(result.getTotalPayloadWeight()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getTotalTradeAmount()).isEqualByComparingTo(BigDecimal.ZERO); + assertThat(result.getLastTradeTime()).isNull(); + } + + } + + + @Nested + @DisplayName("파트너 품목별 누적 통계 조회") + class GetPartnerItemTotalStatsTest { + + private final UUID partnerUuid = UUID.fromString("123e4567-e89b-12d3-a456-426614174000"); + + + @Test + @DisplayName("성공: ADMIN은 모든 파트너의 품목별 누적 통계를 조회할 수 있다") + void getPartnerItemTotalStats_Admin_CanAccessAnyPartner() { + // given + UUID adminUuid = UUID.randomUUID(); + LocalDateTime lastTradeTime = LocalDateTime.of(2024, 9, 1, 12, 30, 45); + + fakeRepository.addPartnerItemTotalStats(partnerUuid, 101L, "알루미늄캔", "ITEM002", 2, "비철금속류", + 5L, new BigDecimal("50.30"), new BigDecimal("500000"), lastTradeTime); + fakeRepository.addPartnerItemTotalStats(partnerUuid, 100L, "철스크랩", "ITEM001", 1, "철금속류", + 10L, new BigDecimal("100.50"), new BigDecimal("1000000"), lastTradeTime); + + GetPartnerItemTotalStatsInDto inDto = GetPartnerItemTotalStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(adminUuid) + .requestorType(UserType.ADMIN) + .build(); + + // when + List result = totalStatsQueryService.getPartnerItemTotalStats(inDto); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(0).getItemId()).isEqualTo(100L); + assertThat(result.get(1).getItemId()).isEqualTo(101L); + } + + + @Test + @DisplayName("성공: PERSONAL_PARTNER는 자신의 품목별 누적 통계를 조회할 수 있다") + void getPartnerItemTotalStats_PersonalPartner_CanAccessOwnStats() { + // given + LocalDateTime lastTradeTime = LocalDateTime.of(2024, 9, 1, 12, 30, 45); + + fakeRepository.addPartnerItemTotalStats(partnerUuid, 100L, "철스크랩", "ITEM001", 1, "철금속류", + 10L, new BigDecimal("100.50"), new BigDecimal("1000000"), lastTradeTime); + + GetPartnerItemTotalStatsInDto inDto = GetPartnerItemTotalStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(partnerUuid) + .requestorType(UserType.PERSONAL_PARTNER) + .build(); + + // when + List result = totalStatsQueryService.getPartnerItemTotalStats(inDto); + + // then + assertThat(result).hasSize(1); + assertThat(result.get(0).getTotalTradeAmount()).isEqualByComparingTo(new BigDecimal("1000000")); + } + + + @Test + @DisplayName("실패: AGENCY는 파트너 품목별 누적 통계를 조회할 수 없다") + void getPartnerItemTotalStats_Agency_CannotAccessStats() { + // given + UUID agencyUuid = UUID.randomUUID(); + + GetPartnerItemTotalStatsInDto inDto = GetPartnerItemTotalStatsInDto.builder() + .targetPartnerUuid(partnerUuid) + .requestorUuid(agencyUuid) + .requestorType(UserType.AGENCY) + .build(); + + // when & then + assertThatThrownBy(() -> totalStatsQueryService.getPartnerItemTotalStats(inDto)) + .isInstanceOf(BaseException.class) + .hasFieldOrPropertyWithValue("status", BaseResponseStatus.PARTNER_STATS_ACCESS_DENIED); + } + + } + +} diff --git a/src/test/java/greenfirst/be/stats/application/service/total/fake/FakeTotalStatsQueryRepository.java b/src/test/java/greenfirst/be/stats/application/service/total/fake/FakeTotalStatsQueryRepository.java new file mode 100644 index 0000000..ca0b2f2 --- /dev/null +++ b/src/test/java/greenfirst/be/stats/application/service/total/fake/FakeTotalStatsQueryRepository.java @@ -0,0 +1,133 @@ +package greenfirst.be.stats.application.service.total.fake; + + +import greenfirst.be.stats.application.dto.in.total.GetPartnerItemTotalStatsInDto; +import greenfirst.be.stats.application.dto.in.total.GetPartnerTotalStatsInDto; +import greenfirst.be.stats.application.dto.out.PartnerItemTotalStatsOutDto; +import greenfirst.be.stats.application.dto.out.PartnerTotalStatsOutDto; +import greenfirst.be.stats.application.port.out.total.TotalStatsQueryRepository; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; + + +public class FakeTotalStatsQueryRepository implements TotalStatsQueryRepository { + + private final List partnerTotalStorage = new ArrayList<>(); + private final List partnerItemTotalStorage = new ArrayList<>(); + + + @Override + public PartnerTotalStatsOutDto getPartnerTotalStats(GetPartnerTotalStatsInDto inDto) { + UUID targetPartnerUuid = inDto.getTargetPartnerUuid(); + + return partnerTotalStorage.stream() + .filter(stats -> stats.partnerUuid.equals(targetPartnerUuid)) + .findFirst() + .map(stats -> new PartnerTotalStatsOutDto( + stats.totalTradeCount, + stats.totalPayloadWeight, + stats.totalTradeAmount, + stats.lastTradeTime + )) + .orElseGet(() -> new PartnerTotalStatsOutDto(0L, BigDecimal.ZERO, BigDecimal.ZERO, null)); + } + + + @Override + public List getPartnerItemTotalStats(GetPartnerItemTotalStatsInDto inDto) { + UUID targetPartnerUuid = inDto.getTargetPartnerUuid(); + + return partnerItemTotalStorage.stream() + .filter(stats -> stats.partnerUuid.equals(targetPartnerUuid)) + .map(stats -> new PartnerItemTotalStatsOutDto( + stats.itemId, + stats.itemName, + stats.itemCode, + stats.itemTypeId, + stats.itemTypeName, + stats.totalTradeCount, + stats.totalPayloadWeight, + stats.totalTradeAmount, + stats.lastTradeTime + )) + .sorted(Comparator.comparing(PartnerItemTotalStatsOutDto::getItemId)) + .toList(); + } + + + public void addPartnerTotalStats(UUID partnerUuid, long totalTradeCount, BigDecimal totalPayloadWeight, + BigDecimal totalTradeAmount, LocalDateTime lastTradeTime) { + partnerTotalStorage.add(new TestPartnerTotalStats(partnerUuid, totalTradeCount, totalPayloadWeight, + totalTradeAmount, lastTradeTime)); + } + + + public void addPartnerItemTotalStats(UUID partnerUuid, Long itemId, String itemName, String itemCode, + Integer itemTypeId, String itemTypeName, long totalTradeCount, BigDecimal totalPayloadWeight, + BigDecimal totalTradeAmount, LocalDateTime lastTradeTime) { + partnerItemTotalStorage.add(new TestPartnerItemTotalStats(partnerUuid, itemId, itemName, itemCode, + itemTypeId, itemTypeName, totalTradeCount, totalPayloadWeight, totalTradeAmount, lastTradeTime)); + } + + + public void clear() { + partnerTotalStorage.clear(); + partnerItemTotalStorage.clear(); + } + + + private static class TestPartnerTotalStats { + + private final UUID partnerUuid; + private final long totalTradeCount; + private final BigDecimal totalPayloadWeight; + private final BigDecimal totalTradeAmount; + private final LocalDateTime lastTradeTime; + + private TestPartnerTotalStats(UUID partnerUuid, long totalTradeCount, BigDecimal totalPayloadWeight, + BigDecimal totalTradeAmount, LocalDateTime lastTradeTime) { + this.partnerUuid = partnerUuid; + this.totalTradeCount = totalTradeCount; + this.totalPayloadWeight = totalPayloadWeight; + this.totalTradeAmount = totalTradeAmount; + this.lastTradeTime = lastTradeTime; + } + + } + + private static class TestPartnerItemTotalStats { + + private final UUID partnerUuid; + private final Long itemId; + private final String itemName; + private final String itemCode; + private final Integer itemTypeId; + private final String itemTypeName; + private final long totalTradeCount; + private final BigDecimal totalPayloadWeight; + private final BigDecimal totalTradeAmount; + private final LocalDateTime lastTradeTime; + + private TestPartnerItemTotalStats(UUID partnerUuid, Long itemId, String itemName, String itemCode, + Integer itemTypeId, String itemTypeName, long totalTradeCount, BigDecimal totalPayloadWeight, + BigDecimal totalTradeAmount, LocalDateTime lastTradeTime) { + this.partnerUuid = partnerUuid; + this.itemId = itemId; + this.itemName = itemName; + this.itemCode = itemCode; + this.itemTypeId = itemTypeId; + this.itemTypeName = itemTypeName; + this.totalTradeCount = totalTradeCount; + this.totalPayloadWeight = totalPayloadWeight; + this.totalTradeAmount = totalTradeAmount; + this.lastTradeTime = lastTradeTime; + } + + } + +} diff --git a/src/test/java/greenfirst/be/trade/adapter/in/web/TradeCreateApiE2ETest.java b/src/test/java/greenfirst/be/trade/adapter/in/web/TradeCreateApiE2ETest.java index 0c75c20..433bbb9 100644 --- a/src/test/java/greenfirst/be/trade/adapter/in/web/TradeCreateApiE2ETest.java +++ b/src/test/java/greenfirst/be/trade/adapter/in/web/TradeCreateApiE2ETest.java @@ -224,6 +224,7 @@ void createTrade_api_e2e() throws Exception { tradeItem.put("itemName", "item"); tradeItem.put("weight", new BigDecimal("50.00")); tradeItem.put("weightLoss", new BigDecimal("0.00")); + tradeItem.put("netPayloadWeight", new BigDecimal("50.00")); tradeItem.put("userTradeItemUnitPrice", new BigDecimal("10.00")); tradeItem.put("defaultTradeItemUnitPrice", new BigDecimal("9.00")); tradeItem.put("itemTradeAmount", new BigDecimal("500.00")); diff --git a/src/test/java/greenfirst/be/trade/application/CreateTradeFacadeUnitTest.java b/src/test/java/greenfirst/be/trade/application/CreateTradeFacadeUnitTest.java index dd519ca..982d6ec 100644 --- a/src/test/java/greenfirst/be/trade/application/CreateTradeFacadeUnitTest.java +++ b/src/test/java/greenfirst/be/trade/application/CreateTradeFacadeUnitTest.java @@ -85,6 +85,7 @@ void createTrade_fetchesBuyerBranchOnly_whenBuyerIsAdmin() { .itemName("item") .weight(new BigDecimal("10.00")) .weightLoss(new BigDecimal("1.00")) + .netPayloadWeight(new BigDecimal("9.00")) .userTradeItemUnitPrice(new BigDecimal("5.00")) .defaultTradeItemUnitPrice(new BigDecimal("4.00")) .itemTradeAmount(new BigDecimal("50.00")) @@ -129,6 +130,7 @@ void createTrade_fetchesBuyerBranchOnly_whenBuyerIsAdmin() { assertThat(calcDtos).hasSize(1); assertThat(calcDtos.get(0).getWeight()).isEqualByComparingTo("10.00"); assertThat(calcDtos.get(0).getWeightLoss()).isEqualByComparingTo("1.00"); + assertThat(calcDtos.get(0).getNetPayloadWeight()).isEqualByComparingTo("9.00"); assertThat(calcDtos.get(0).getCarbonEmissionFactorId()).isEqualTo(100L); ArgumentCaptor createCaptor = ArgumentCaptor.forClass(CreateTradeInDto.class); @@ -142,7 +144,11 @@ void createTrade_fetchesBuyerBranchOnly_whenBuyerIsAdmin() { verify(getUserDataService).getUserByUuid(buyerUuid); verify(getUserDataService, never()).getUserByUuid(sellerUuid); - verify(tradeItemManagementService).createTradeItems(eq(10L), eq(inDto.getTradeItems())); + ArgumentCaptor> tradeItemsCaptor = ArgumentCaptor.forClass(List.class); + verify(tradeItemManagementService).createTradeItems(eq(10L), tradeItemsCaptor.capture()); + List savedTradeItems = tradeItemsCaptor.getValue(); + assertThat(savedTradeItems).hasSize(1); + assertThat(savedTradeItems.get(0).getNetPayloadWeight()).isEqualByComparingTo("9.00"); } @@ -170,7 +176,17 @@ void createTrade_fetchesSellerBranchOnly_whenSellerIsAgency() { .tradeAmount(new BigDecimal("2000.00")) .tradeAt(LocalDateTime.of(2026, 1, 2, 10, 0)) .memo("memo") - .tradeItems(List.of(CreateTradeItemInDto.builder().itemId(2L).build())) + .tradeItems(List.of(CreateTradeItemInDto.builder() + .itemId(2L) + .itemName("item") + .weight(new BigDecimal("20.00")) + .weightLoss(new BigDecimal("2.00")) + .netPayloadWeight(new BigDecimal("18.00")) + .userTradeItemUnitPrice(new BigDecimal("10.00")) + .defaultTradeItemUnitPrice(new BigDecimal("9.00")) + .itemTradeAmount(new BigDecimal("200.00")) + .carbonEmissionFactorId(200L) + .build())) .build(); when(carbonEmissionFactorManagementService.calcCarbonReductionAmount(any())) @@ -194,7 +210,11 @@ void createTrade_fetchesSellerBranchOnly_whenSellerIsAgency() { verify(getUserDataService).getUserByUuid(sellerUuid); verify(getUserDataService, never()).getUserByUuid(buyerUuid); - verify(tradeItemManagementService).createTradeItems(eq(20L), eq(inDto.getTradeItems())); + ArgumentCaptor> tradeItemsCaptor = ArgumentCaptor.forClass(List.class); + verify(tradeItemManagementService).createTradeItems(eq(20L), tradeItemsCaptor.capture()); + List savedTradeItems = tradeItemsCaptor.getValue(); + assertThat(savedTradeItems).hasSize(1); + assertThat(savedTradeItems.get(0).getNetPayloadWeight()).isEqualByComparingTo("18.00"); } } diff --git a/src/test/java/greenfirst/be/trade/application/TradeItemManagementServiceUnitTest.java b/src/test/java/greenfirst/be/trade/application/TradeItemManagementServiceUnitTest.java index dc3c2b9..87bdf83 100644 --- a/src/test/java/greenfirst/be/trade/application/TradeItemManagementServiceUnitTest.java +++ b/src/test/java/greenfirst/be/trade/application/TradeItemManagementServiceUnitTest.java @@ -50,6 +50,7 @@ void createTradeItems_Success() { .itemName("테스트 품목 1") .weight(new BigDecimal("10.5")) .weightLoss(new BigDecimal("0.5")) + .netPayloadWeight(new BigDecimal("10.0")) .userTradeItemUnitPrice(new BigDecimal("1000")) .defaultTradeItemUnitPrice(new BigDecimal("900")) .itemTradeAmount(new BigDecimal("100000")) @@ -60,6 +61,7 @@ void createTradeItems_Success() { .itemName("테스트 품목 2") .weight(new BigDecimal("20.0")) .weightLoss(new BigDecimal("1.0")) + .netPayloadWeight(new BigDecimal("19.0")) .userTradeItemUnitPrice(new BigDecimal("2000")) .defaultTradeItemUnitPrice(new BigDecimal("1800")) .itemTradeAmount(new BigDecimal("200000")) @@ -80,6 +82,7 @@ void createTradeItems_Success() { assertThat(firstItem.getItemName()).isEqualTo("테스트 품목 1"); assertThat(firstItem.getWeight()).isEqualByComparingTo(new BigDecimal("10.5")); assertThat(firstItem.getWeightLoss()).isEqualByComparingTo(new BigDecimal("0.5")); + assertThat(firstItem.getNetPayloadWeight()).isEqualByComparingTo(new BigDecimal("10.0")); assertThat(firstItem.getUserTradeItemUnitPrice()).isEqualByComparingTo(new BigDecimal("1000")); assertThat(firstItem.getDefaultTradeItemUnitPrice()).isEqualByComparingTo(new BigDecimal("900")); assertThat(firstItem.getItemTradeAmount()).isEqualByComparingTo(new BigDecimal("100000")); @@ -91,6 +94,7 @@ void createTradeItems_Success() { assertThat(secondItem.getItemName()).isEqualTo("테스트 품목 2"); assertThat(secondItem.getWeight()).isEqualByComparingTo(new BigDecimal("20.0")); assertThat(secondItem.getWeightLoss()).isEqualByComparingTo(new BigDecimal("1.0")); + assertThat(secondItem.getNetPayloadWeight()).isEqualByComparingTo(new BigDecimal("19.0")); assertThat(secondItem.getUserTradeItemUnitPrice()).isEqualByComparingTo(new BigDecimal("2000")); assertThat(secondItem.getDefaultTradeItemUnitPrice()).isEqualByComparingTo(new BigDecimal("1800")); assertThat(secondItem.getItemTradeAmount()).isEqualByComparingTo(new BigDecimal("200000")); @@ -109,6 +113,7 @@ void createTradeItems_Success_NullWeightLoss() { .itemName("테스트 품목") .weight(new BigDecimal("10.5")) .weightLoss(null) + .netPayloadWeight(new BigDecimal("10.5")) .userTradeItemUnitPrice(new BigDecimal("500")) .defaultTradeItemUnitPrice(new BigDecimal("450")) .itemTradeAmount(new BigDecimal("50000")) @@ -129,6 +134,7 @@ void createTradeItems_Success_NullWeightLoss() { assertThat(savedItem.getItemName()).isEqualTo("테스트 품목"); assertThat(savedItem.getWeight()).isEqualByComparingTo(new BigDecimal("10.5")); assertThat(savedItem.getWeightLoss()).isNull(); + assertThat(savedItem.getNetPayloadWeight()).isEqualByComparingTo(new BigDecimal("10.5")); assertThat(savedItem.getUserTradeItemUnitPrice()).isEqualByComparingTo(new BigDecimal("500")); assertThat(savedItem.getDefaultTradeItemUnitPrice()).isEqualByComparingTo(new BigDecimal("450")); assertThat(savedItem.getItemTradeAmount()).isEqualByComparingTo(new BigDecimal("50000")); @@ -153,4 +159,4 @@ void createTradeItems_Success_EmptyList() { } -} \ No newline at end of file +} diff --git a/src/test/java/greenfirst/be/trade/application/TradePhase3Test.java b/src/test/java/greenfirst/be/trade/application/TradePhase3Test.java index 9e4a088..5b75f27 100644 --- a/src/test/java/greenfirst/be/trade/application/TradePhase3Test.java +++ b/src/test/java/greenfirst/be/trade/application/TradePhase3Test.java @@ -52,6 +52,7 @@ void carbonReductionCalculator_shouldUseCarbonEmissionFactor() { TradeItemCarbonData.builder() .weight(new BigDecimal("10.0")) .weightLoss(new BigDecimal("1.0")) + .netPayloadWeight(new BigDecimal("9.0")) .carbonEmissionFactorId(1L) .build() ); @@ -115,11 +116,13 @@ void calculateTotalCarbonReduction_withMultipleItems() { TradeItemCarbonData.builder() .weight(new BigDecimal("10.0")) .weightLoss(new BigDecimal("1.0")) + .netPayloadWeight(new BigDecimal("9.0")) .carbonEmissionFactorId(1L) .build(), TradeItemCarbonData.builder() .weight(new BigDecimal("20.0")) .weightLoss(new BigDecimal("2.0")) + .netPayloadWeight(new BigDecimal("18.0")) .carbonEmissionFactorId(2L) .build() ); diff --git a/src/test/java/greenfirst/be/trade/domain/CarbonReductionCalculatorUnitTest.java b/src/test/java/greenfirst/be/trade/domain/CarbonReductionCalculatorUnitTest.java index e562353..76e84dd 100644 --- a/src/test/java/greenfirst/be/trade/domain/CarbonReductionCalculatorUnitTest.java +++ b/src/test/java/greenfirst/be/trade/domain/CarbonReductionCalculatorUnitTest.java @@ -2,6 +2,8 @@ import greenfirst.be.item.domain.model.CarbonEmissionFactor; +import greenfirst.be.global.common.exception.BaseException; +import greenfirst.be.global.common.response.base.BaseResponseStatus; import greenfirst.be.trade.domain.dto.in.TradeItemCarbonData; import greenfirst.be.trade.domain.service.CarbonReductionCalculator; import org.junit.jupiter.api.BeforeEach; @@ -13,6 +15,7 @@ import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; @DisplayName("CarbonReductionCalculator 단위 테스트") @@ -39,11 +42,13 @@ void calculateTotalCarbonReduction_Success() { TradeItemCarbonData.builder() .weight(new BigDecimal("10.0")) .weightLoss(new BigDecimal("1.0")) + .netPayloadWeight(new BigDecimal("9.0")) .carbonEmissionFactorId(1L) .build(), TradeItemCarbonData.builder() .weight(new BigDecimal("20.0")) .weightLoss(new BigDecimal("2.0")) + .netPayloadWeight(new BigDecimal("18.0")) .carbonEmissionFactorId(2L) .build() ); @@ -82,6 +87,7 @@ void calculateTotalCarbonReduction_Success_SingleItem() { TradeItemCarbonData.builder() .weight(new BigDecimal("15.0")) .weightLoss(new BigDecimal("2.5")) + .netPayloadWeight(new BigDecimal("12.5")) .carbonEmissionFactorId(1L) .build() ); @@ -112,11 +118,13 @@ void calculateTotalCarbonReduction_Success_UnmatchedFactor() { TradeItemCarbonData.builder() .weight(new BigDecimal("10.0")) .weightLoss(new BigDecimal("1.0")) + .netPayloadWeight(new BigDecimal("9.0")) .carbonEmissionFactorId(1L) .build(), TradeItemCarbonData.builder() .weight(new BigDecimal("20.0")) .weightLoss(new BigDecimal("2.0")) + .netPayloadWeight(new BigDecimal("18.0")) .carbonEmissionFactorId(999L) // 존재하지 않는 ID .build() ); @@ -162,6 +170,7 @@ void calculateTotalCarbonReduction_Success_NoMatchingFactors() { TradeItemCarbonData.builder() .weight(new BigDecimal("10.0")) .weightLoss(new BigDecimal("1.0")) + .netPayloadWeight(new BigDecimal("9.0")) .carbonEmissionFactorId(999L) .build() ); @@ -182,6 +191,36 @@ void calculateTotalCarbonReduction_Success_NoMatchingFactors() { assertThat(totalCarbonReduction).isEqualByComparingTo(BigDecimal.ZERO); } + @Test + @DisplayName("실패: netPayloadWeight와 weight가 모두 null이면 예외가 발생한다") + void calculateTotalCarbonReduction_Fail_NullNetWeightAndWeight() { + // given + List datas = List.of( + TradeItemCarbonData.builder() + .weight(null) + .weightLoss(null) + .netPayloadWeight(null) + .carbonEmissionFactorId(1L) + .build() + ); + + List emissionFactors = List.of( + CarbonEmissionFactor.builder() + .id(1L) + .code("MIXED_PAPER") + .description("혼합지") + .emissionFactorKgCo2ePerKg(new BigDecimal("2.0")) + .build() + ); + + // when & then + assertThatThrownBy(() -> calculator.calculateTotalCarbonReduction(datas, emissionFactors)) + .isInstanceOf(BaseException.class) + .extracting("status") + .extracting("baseStatus") + .isEqualTo(BaseResponseStatus.INVALID_CREATE_TRADE_VALUE); + } + } } diff --git a/src/test/java/greenfirst/be/trade/domain/CreateTradeItemCommandUnitTest.java b/src/test/java/greenfirst/be/trade/domain/CreateTradeItemCommandUnitTest.java index 528a599..262bf30 100644 --- a/src/test/java/greenfirst/be/trade/domain/CreateTradeItemCommandUnitTest.java +++ b/src/test/java/greenfirst/be/trade/domain/CreateTradeItemCommandUnitTest.java @@ -29,6 +29,7 @@ void createTradeItemCommand_Fail_NullTradeId() { Long itemId = 1L; String itemName = "테스트 품목"; BigDecimal weight = new BigDecimal("10.5"); + BigDecimal netPayloadWeight = weight; BigDecimal userTradeItemUnitPrice = new BigDecimal("1000"); BigDecimal defaultTradeItemUnitPrice = new BigDecimal("900"); BigDecimal itemTradeAmount = new BigDecimal("10500"); @@ -40,6 +41,7 @@ void createTradeItemCommand_Fail_NullTradeId() { .itemId(itemId) .itemName(itemName) .weight(weight) + .netPayloadWeight(netPayloadWeight) .userTradeItemUnitPrice(userTradeItemUnitPrice) .defaultTradeItemUnitPrice(defaultTradeItemUnitPrice) .itemTradeAmount(itemTradeAmount) @@ -60,6 +62,7 @@ void createTradeItemCommand_Fail_InvalidItemId() { Long itemId = 0L; String itemName = "테스트 품목"; BigDecimal weight = new BigDecimal("10.5"); + BigDecimal netPayloadWeight = weight; BigDecimal userTradeItemUnitPrice = new BigDecimal("1000"); BigDecimal defaultTradeItemUnitPrice = new BigDecimal("900"); BigDecimal itemTradeAmount = new BigDecimal("10500"); @@ -71,6 +74,7 @@ void createTradeItemCommand_Fail_InvalidItemId() { .itemId(itemId) .itemName(itemName) .weight(weight) + .netPayloadWeight(netPayloadWeight) .userTradeItemUnitPrice(userTradeItemUnitPrice) .defaultTradeItemUnitPrice(defaultTradeItemUnitPrice) .itemTradeAmount(itemTradeAmount) @@ -91,6 +95,7 @@ void createTradeItemCommand_Fail_BlankItemName() { Long itemId = 1L; String itemName = ""; BigDecimal weight = new BigDecimal("10.5"); + BigDecimal netPayloadWeight = weight; BigDecimal userTradeItemUnitPrice = new BigDecimal("1000"); BigDecimal defaultTradeItemUnitPrice = new BigDecimal("900"); BigDecimal itemTradeAmount = new BigDecimal("10500"); @@ -102,6 +107,7 @@ void createTradeItemCommand_Fail_BlankItemName() { .itemId(itemId) .itemName(itemName) .weight(weight) + .netPayloadWeight(netPayloadWeight) .userTradeItemUnitPrice(userTradeItemUnitPrice) .defaultTradeItemUnitPrice(defaultTradeItemUnitPrice) .itemTradeAmount(itemTradeAmount) @@ -122,6 +128,7 @@ void createTradeItemCommand_Fail_NegativeWeight() { Long itemId = 1L; String itemName = "테스트 품목"; BigDecimal weight = new BigDecimal("-1.0"); + BigDecimal netPayloadWeight = weight; BigDecimal userTradeItemUnitPrice = new BigDecimal("1000"); BigDecimal defaultTradeItemUnitPrice = new BigDecimal("900"); BigDecimal itemTradeAmount = new BigDecimal("10500"); @@ -133,6 +140,7 @@ void createTradeItemCommand_Fail_NegativeWeight() { .itemId(itemId) .itemName(itemName) .weight(weight) + .netPayloadWeight(netPayloadWeight) .userTradeItemUnitPrice(userTradeItemUnitPrice) .defaultTradeItemUnitPrice(defaultTradeItemUnitPrice) .itemTradeAmount(itemTradeAmount) @@ -153,6 +161,7 @@ void createTradeItemCommand_Fail_NullCarbonEmissionFactorId() { Long itemId = 1L; String itemName = "테스트 품목"; BigDecimal weight = new BigDecimal("10.5"); + BigDecimal netPayloadWeight = weight; BigDecimal userTradeItemUnitPrice = new BigDecimal("1000"); BigDecimal defaultTradeItemUnitPrice = new BigDecimal("900"); BigDecimal itemTradeAmount = new BigDecimal("10500"); @@ -164,6 +173,7 @@ void createTradeItemCommand_Fail_NullCarbonEmissionFactorId() { .itemId(itemId) .itemName(itemName) .weight(weight) + .netPayloadWeight(netPayloadWeight) .userTradeItemUnitPrice(userTradeItemUnitPrice) .defaultTradeItemUnitPrice(defaultTradeItemUnitPrice) .itemTradeAmount(itemTradeAmount) @@ -175,6 +185,41 @@ void createTradeItemCommand_Fail_NullCarbonEmissionFactorId() { .isEqualTo(BaseResponseStatus.MISSING_CREATE_TRADE_ITEM_VALUE); } + + @Test + @DisplayName("실패: netPayloadWeight가 weight - weightLoss와 다르면 예외가 발생한다") + void createTradeItemCommand_Fail_InvalidNetPayloadWeight() { + // given + Long tradeId = 1L; + Long itemId = 1L; + String itemName = "테스트 품목"; + BigDecimal weight = new BigDecimal("10.5"); + BigDecimal weightLoss = new BigDecimal("0.5"); + BigDecimal netPayloadWeight = new BigDecimal("9.50"); + BigDecimal userTradeItemUnitPrice = new BigDecimal("1000"); + BigDecimal defaultTradeItemUnitPrice = new BigDecimal("900"); + BigDecimal itemTradeAmount = new BigDecimal("10500"); + Long carbonEmissionFactorId = 1L; + + // when & then + assertThatThrownBy(() -> CreateTradeItemCommand.builder() + .tradeId(tradeId) + .itemId(itemId) + .itemName(itemName) + .weight(weight) + .weightLoss(weightLoss) + .netPayloadWeight(netPayloadWeight) + .userTradeItemUnitPrice(userTradeItemUnitPrice) + .defaultTradeItemUnitPrice(defaultTradeItemUnitPrice) + .itemTradeAmount(itemTradeAmount) + .carbonEmissionFactorId(carbonEmissionFactorId) + .build()) + .isInstanceOf(BaseException.class) + .extracting("status") + .extracting("baseStatus") + .isEqualTo(BaseResponseStatus.INVALID_CREATE_TRADE_VALUE); + } + } -} \ No newline at end of file +} diff --git a/src/test/java/greenfirst/be/trade/domain/TradeItemUnitTest.java b/src/test/java/greenfirst/be/trade/domain/TradeItemUnitTest.java index 231cb97..833dbea 100644 --- a/src/test/java/greenfirst/be/trade/domain/TradeItemUnitTest.java +++ b/src/test/java/greenfirst/be/trade/domain/TradeItemUnitTest.java @@ -29,6 +29,7 @@ void from_Success() { .itemName("테스트 품목") .weight(new BigDecimal("10.5")) .weightLoss(new BigDecimal("0.5")) + .netPayloadWeight(new BigDecimal("10.0")) .userTradeItemUnitPrice(new BigDecimal("1000")) .defaultTradeItemUnitPrice(new BigDecimal("900")) .itemTradeAmount(new BigDecimal("100000")) @@ -44,6 +45,7 @@ void from_Success() { assertThat(tradeItem.getItemName()).isEqualTo("테스트 품목"); assertThat(tradeItem.getWeight()).isEqualByComparingTo(new BigDecimal("10.5")); assertThat(tradeItem.getWeightLoss()).isEqualByComparingTo(new BigDecimal("0.5")); + assertThat(tradeItem.getNetPayloadWeight()).isEqualByComparingTo(new BigDecimal("10.0")); assertThat(tradeItem.getUserTradeItemUnitPrice()).isEqualByComparingTo(new BigDecimal("1000")); assertThat(tradeItem.getDefaultTradeItemUnitPrice()).isEqualByComparingTo(new BigDecimal("900")); assertThat(tradeItem.getItemTradeAmount()).isEqualByComparingTo(new BigDecimal("100000")); @@ -62,6 +64,7 @@ void from_Success_NullWeightLoss() { .itemName("테스트 품목") .weight(new BigDecimal("10.5")) .weightLoss(null) + .netPayloadWeight(new BigDecimal("10.5")) .userTradeItemUnitPrice(new BigDecimal("500")) .defaultTradeItemUnitPrice(new BigDecimal("450")) .itemTradeAmount(new BigDecimal("50000")) @@ -77,6 +80,7 @@ void from_Success_NullWeightLoss() { assertThat(tradeItem.getItemName()).isEqualTo("테스트 품목"); assertThat(tradeItem.getWeight()).isEqualByComparingTo(new BigDecimal("10.5")); assertThat(tradeItem.getWeightLoss()).isNull(); + assertThat(tradeItem.getNetPayloadWeight()).isEqualByComparingTo(new BigDecimal("10.5")); assertThat(tradeItem.getUserTradeItemUnitPrice()).isEqualByComparingTo(new BigDecimal("500")); assertThat(tradeItem.getDefaultTradeItemUnitPrice()).isEqualByComparingTo(new BigDecimal("450")); assertThat(tradeItem.getItemTradeAmount()).isEqualByComparingTo(new BigDecimal("50000")); @@ -85,4 +89,4 @@ void from_Success_NullWeightLoss() { } -} \ No newline at end of file +} diff --git a/src/test/java/greenfirst/be/trade/fixture/TradeItemTestFixture.java b/src/test/java/greenfirst/be/trade/fixture/TradeItemTestFixture.java index 4b7b283..fac1b8b 100644 --- a/src/test/java/greenfirst/be/trade/fixture/TradeItemTestFixture.java +++ b/src/test/java/greenfirst/be/trade/fixture/TradeItemTestFixture.java @@ -39,6 +39,7 @@ public static TradeItem createTradeItem1ForTrade1() { .itemName(ITEM_NAME_1) .weight(new BigDecimal("100.50")) .weightLoss(new BigDecimal("5.25")) + .netPayloadWeight(new BigDecimal("95.25")) .userTradeItemUnitPrice(new BigDecimal("500.00")) .defaultTradeItemUnitPrice(new BigDecimal("450.00")) .itemTradeAmount(new BigDecimal("50000.00")) @@ -58,6 +59,7 @@ public static TradeItem createTradeItem2ForTrade1() { .itemName(ITEM_NAME_2) .weight(new BigDecimal("200.75")) .weightLoss(new BigDecimal("10.50")) + .netPayloadWeight(new BigDecimal("190.25")) .userTradeItemUnitPrice(new BigDecimal("375.00")) .defaultTradeItemUnitPrice(new BigDecimal("350.00")) .itemTradeAmount(new BigDecimal("75000.00")) @@ -77,6 +79,7 @@ public static TradeItem createTradeItem3ForTrade1() { .itemName(ITEM_NAME_3) .weight(new BigDecimal("150.25")) .weightLoss(new BigDecimal("7.75")) + .netPayloadWeight(new BigDecimal("142.50")) .userTradeItemUnitPrice(new BigDecimal("400.00")) .defaultTradeItemUnitPrice(new BigDecimal("380.00")) .itemTradeAmount(new BigDecimal("60000.00")) @@ -96,6 +99,7 @@ public static TradeItem createTradeItemForTrade2() { .itemName(ITEM_NAME_1) .weight(new BigDecimal("300.00")) .weightLoss(new BigDecimal("15.00")) + .netPayloadWeight(new BigDecimal("285.00")) .userTradeItemUnitPrice(new BigDecimal("500.00")) .defaultTradeItemUnitPrice(new BigDecimal("450.00")) .itemTradeAmount(new BigDecimal("150000.00")) @@ -115,6 +119,7 @@ public static TradeItem createTradeItemWithTradeId(Long tradeId) { .itemName(ITEM_NAME_1) .weight(new BigDecimal("100.00")) .weightLoss(new BigDecimal("5.00")) + .netPayloadWeight(new BigDecimal("95.00")) .userTradeItemUnitPrice(new BigDecimal("500.00")) .defaultTradeItemUnitPrice(new BigDecimal("450.00")) .itemTradeAmount(new BigDecimal("50000.00")) @@ -143,6 +148,7 @@ public static TradeItem createTradeItemWithCustomFields( .itemName(itemName) .weight(weight) .weightLoss(weightLoss) + .netPayloadWeight(weightLoss == null ? weight : weight.subtract(weightLoss)) .userTradeItemUnitPrice(userTradeItemUnitPrice) .defaultTradeItemUnitPrice(defaultTradeItemUnitPrice) .itemTradeAmount(itemTradeAmount)