diff --git a/AuditReceipts.java b/AuditReceipts.java new file mode 100644 index 000000000..9f3f59c95 --- /dev/null +++ b/AuditReceipts.java @@ -0,0 +1,31 @@ +import java.sql.*; +import java.math.BigDecimal; + +public class AuditReceipts { + public static void main(String[] args) throws Exception { + String url = "jdbc:mariadb://beyond21.iptime.org:7001/chaing"; + String user = "root"; + String pass = "root"; + + Class.forName("org.mariadb.jdbc.Driver"); + + try (Connection conn = DriverManager.getConnection(url, user, pass)) { + System.out.println("=== Daily Settlement Receipt Audit ==="); + + String sql = "SELECT settlement_date, total_sale_amount, commission_fee, final_amount, franchise_id " + + "FROM daily_settlement_receipt " + + "ORDER BY settlement_date DESC"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + System.out.printf("Date: %s | Sales: %s | Commission: %s | Final: %s%n", + rs.getDate("settlement_date"), + rs.getBigDecimal("total_sale_amount"), + rs.getBigDecimal("commission_fee"), + rs.getBigDecimal("final_amount")); + } + } + } + } +} diff --git a/AuditReturn.java b/AuditReturn.java new file mode 100644 index 000000000..ed4bcd45e --- /dev/null +++ b/AuditReturn.java @@ -0,0 +1,65 @@ +import java.sql.*; +import java.util.*; +import java.math.BigDecimal; + +public class AuditReturn { + public static void main(String[] args) throws Exception { + String url = "jdbc:mariadb://beyond21.iptime.org:7001/chaing"; + String user = "root"; + String pass = "root"; + + try { + Class.forName("org.mariadb.jdbc.Driver"); + } catch (ClassNotFoundException e) { + System.err.println("MariaDB Driver not found!"); + return; + } + + try (Connection conn = DriverManager.getConnection(url, user, pass)) { + System.out.println("--- DB Diagnostic Info ---"); + System.out.println("Catalog: " + conn.getCatalog()); + System.out.println("Schema: " + conn.getSchema()); + + System.out.println("\n--- Returns Table Snapshot (No Filter) ---"); + String sqlSnapshot = "SELECT return_code, created_at, total_return_amount FROM returns LIMIT 10"; + + try (PreparedStatement stmtSnapshot = conn.prepareStatement(sqlSnapshot)) { + ResultSet rsSnapshot = stmtSnapshot.executeQuery(); + if (!rsSnapshot.isBeforeFirst()) { + System.out.println("No data found in returns table!"); + } + while (rsSnapshot.next()) { + System.out.printf("Code: %s | Created: %s | Amount: %s%n", + rsSnapshot.getString("return_code"), + rsSnapshot.getTimestamp("created_at"), + rsSnapshot.getBigDecimal("total_return_amount")); + } + } + + System.out.println("\nReturn Data Audit for 2026-03-07"); + + String sql = "SELECT r.franchise_id, r.return_code, r.return_type, r.total_return_amount, " + + "foi.unit_price, foi.quantity, foi.total_price " + + "FROM returns r " + + "JOIN return_item ri ON r.return_id = ri.return_id " + + "JOIN franchise_order_item foi ON ri.franchise_order_item_id = foi.franchise_order_item_id " + + "WHERE r.created_at >= '2026-03-07 00:00:00' AND r.created_at < '2026-03-08 00:00:00'"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + ResultSet rs = stmt.executeQuery(); + System.out.println("Detailed Item Breakdown for Returns on 3/7:"); + while (rs.next()) { + System.out.printf( + "Franchise: %d | [%s] Type: %s, ReturnTotal: %s | Unit: %s, Qty: %d, LineTotal: %s%n", + rs.getLong("franchise_id"), + rs.getString("return_code"), + rs.getString("return_type"), + rs.getBigDecimal("total_return_amount"), + rs.getBigDecimal("unit_price"), + rs.getInt("quantity"), + rs.getBigDecimal("total_price")); + } + } + } + } +} diff --git a/AuditReturnDetail.java b/AuditReturnDetail.java new file mode 100644 index 000000000..c563e989a --- /dev/null +++ b/AuditReturnDetail.java @@ -0,0 +1,33 @@ +import java.sql.*; +import java.math.BigDecimal; + +public class AuditReturnDetail { + public static void main(String[] args) throws Exception { + String url = "jdbc:mariadb://beyond21.iptime.org:7001/chaing"; + String user = "root"; + String pass = "root"; + + try (Connection conn = DriverManager.getConnection(url, user, pass)) { + System.out.println("Return Detail Audit"); + + String sql = "SELECT r.return_code, r.return_type, r.total_return_amount, ri.product_id, ri.quantity, ri.total_price " + + + "FROM returns r " + + "JOIN return_item ri ON r.return_id = ri.return_id " + + "WHERE r.return_code IN ('SE0120260307001', 'SE0120260307002')"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + System.out.printf("Code: %s, Type: %s, TotalAmt: %s, Prod: %d, Qty: %d, ItemPrice: %s%n", + rs.getString("return_code"), + rs.getString("return_type"), + rs.getBigDecimal("total_return_amount"), + rs.getLong("product_id"), + rs.getInt("quantity"), + rs.getBigDecimal("total_price")); + } + } + } + } +} diff --git a/AuditSales.java b/AuditSales.java new file mode 100644 index 000000000..b6e2a6367 --- /dev/null +++ b/AuditSales.java @@ -0,0 +1,47 @@ +import java.sql.*; +import java.math.BigDecimal; +import java.time.LocalDate; + +public class AuditSales { + public static void main(String[] args) throws Exception { + String url = "jdbc:mariadb://beyond21.iptime.org:7001/chaing"; + String user = "root"; + String pass = "root"; + + Class.forName("org.mariadb.jdbc.Driver"); + + try (Connection conn = DriverManager.getConnection(url, user, pass)) { + System.out.println("=== Real-time Sales Aggregation Audit ==="); + + String sql = "SELECT si.unit_price, si.quantity, s.sales_id, s.sales_code, s.is_canceled, s.created_at " + + "FROM sales_item si " + + "JOIN sales s ON si.sales_id = s.sales_id " + + "WHERE s.franchise_id = 1 AND s.deleted_at IS NULL "; + + BigDecimal totalSale = BigDecimal.ZERO; + int count = 0; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + boolean canceled = rs.getBoolean("is_canceled"); + BigDecimal price = rs.getBigDecimal("unit_price"); + int qty = rs.getInt("quantity"); + BigDecimal lineTotal = price.multiply(BigDecimal.valueOf(qty)); + + System.out.printf("[%s] Canceled: %b | Price: %s | Qty: %d | LineTotal: %s | Created: %s%n", + rs.getString("sales_code"), canceled, price, qty, lineTotal, rs.getTimestamp("created_at")); + + if (!canceled) { + totalSale = totalSale.add(lineTotal); + count++; + } + } + } + + System.out.println("\n--- Aggregation Summary (2026-03-13) ---"); + System.out.println("Valid Sales Count: " + count); + System.out.println("Total Sales Amount: " + totalSale); + } + } +} diff --git a/DebugData.java b/DebugData.java new file mode 100644 index 000000000..78d5301aa --- /dev/null +++ b/DebugData.java @@ -0,0 +1,38 @@ +import java.sql.*; + +public class DebugData { + public static void main(String[] args) { + String url = "jdbc:mariadb://beyond21.iptime.org:7001/chaing"; + String user = "chaing"; + String pass = "chaing1234!"; + + try (Connection conn = DriverManager.getConnection(url, user, pass)) { + System.out.println("--- Current User Info (Franchise 1) ---"); + String fSql = "SELECT franchise_id, name, owner_name FROM franchise WHERE franchise_id = 1"; + try (PreparedStatement pstmt = conn.prepareStatement(fSql)) { + ResultSet rs = pstmt.executeQuery(); + if (rs.next()) { + System.out.printf("Franchise 1: %s (Owner: %s)%n", rs.getString("name"), + rs.getString("owner_name")); + } else { + System.out.println("Franchise 1 NOT FOUND!"); + } + } + + System.out.println("\n--- All Sales Records ---"); + String sSql = "SELECT franchise_id, sales_code, created_at, is_canceled FROM sales LIMIT 20"; + try (PreparedStatement pstmt = conn.prepareStatement(sSql)) { + ResultSet rs = pstmt.executeQuery(); + while (rs.next()) { + System.out.printf("FranchiseIdx: %d, Code: %s, Created: %s, Canceled: %b%n", + rs.getLong("franchise_id"), + rs.getString("sales_code"), + rs.getTimestamp("created_at"), + rs.getBoolean("is_canceled")); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + } +} diff --git a/README.md b/README.md index c0ef344d4..fd4ff5f21 100644 --- a/README.md +++ b/README.md @@ -1,289 +1,295 @@ -# Back-end -## 공통 사항 -- TDD(Test-Driven Development) -- 단위 테스트 작성(controller, service, repository 메소드 별로) : Junit 사용 - - service 메소드가 많아져 분리한다면 테스트코드도 그에 맞게 분리 -- 지라 티켓을 생성하고 브랜치 생성 후 작업 시작 -- 데이터 입출력에 대한 로그 잘 작성 -- 평일 오전 09시 10분간 데일리 스크럼 실시 -- 매주 화요일 09시 위클리 스크럼 +# 📦 CHAIN-G (Back-end) -
- -## 개발규칙 +Gemini_Generated_Image_8j8ddp8j8ddp8j8d-Photoroom -### ⭐ Code Convention +> CONNECT GOOD, VALUE CHAIN +> +**CHAIN-G**는 떡볶이 밀키트의 생산부터 유통, 가맹점 판매 및 정산까지의 전 과정을 효율적으로 관리하는 **공급망 관리(SCM) 시스템**입니다. +본사, 가맹점, 공장 세 주체 간의 유기적인 발주, 재고 관리, 물류 배차 및 정산 프로세스를 자동화합니다. ---- - -
-Naming -
+
-- 패키지 : 언더스코어(`_`)나 대문자를 섞지 않고 소문자를 사용하여 작성합니다. -- 클래스 : 클래스 이름은 명사나 명사절로 지으며, 대문자 카멜표기법(Upper camel case)을 사용합니다. -- 메서드 : 메서드 이름은 동사/전치사로 시작하며, 소문자 카멜표기법(Lower camel case)를 사용합니다. 의도가 전달되도록 최대한 간결하게 표현합니다. -- 변수 : 소문자 카멜표기법(Lower camel case)를 사용합니다. -- ENUM, 상수 : 상태를 가지지 않는 자료형이면서 `static final`로 선언되어 있는 필드일 때를 상수로 간주하며, 대문자와 언더스코어(Upper_snake_case)로 구성합니다. -- DB 테이블: 소문자와 언더스코어로(lower_snake_case) 구성합니다. -- 컬렉션(Collection): **복수형**을 사용하거나 **컬렉션을 명시합니다**. (Ex. userList, users, userMap) +## 👥 팀원 소개 (Team Flow-er) +| | | | | | +| :---: | :---: | :---: | :---: | :---: | +| **김채우** | **김성은** | **김윤경** | **유찬연** | **조윤호** | +| Order / Sales /
Return | User / BusinessUnit /
Notice / Notification | Settlement | In&Outbound /
Transport | Inventory / Product | +| [@chaewoo-kim](https://github.com/chaewoo-kim) | [@rlatjddms](https://github.com/rlatjddms) | [@kyk5095](https://github.com/kyk5095) | [@Yoocy0](https://github.com/Yoocy0) | [@cho-yunho01](https://github.com/cho-yunho01) | -
-
-
-Comment -
+
-### 1. 한줄 주석은 // 를 사용한다. +## 🛠 기술 스택 -```java -// 하이~ -``` +| Category | Stack | +| :--- | :--- | +| **Language** | Java 21 | +| **Framework** | Spring Boot 3.2.2 | +| **Persistence** | Spring Data JPA, MySQL 8.x | +| **Build Tool** | Gradle | +| **Testing** | JUnit 5, AssertJ, JaCoCo (Line Coverage 80% 이상 준수) | +| **CI/CD & Tools** | SonarQube, Lombok, GitHub Actions | -### 2. Bracket 사용 시 내부에 주석을 작성한다. +
-```java -/* - 하이~! -*/ -``` +## 🏢 프로젝트 아키텍처 -
-
-
-Import -
+본 프로젝트는 유지보수성과 확장성을 위해 **멀티 모듈 아키텍처**를 채택하고 있으며, 도메인 중심 설계(Domain-Driven Design)를 지향합니다. -### 1. 소스파일당 1개의 탑레벨 클래스를 담기 +### 모듈 구조 +- **`module-core`**: 시스템 전반에서 공통으로 사용되는 유틸리티, 예외 처리, 공통 응답 규격을 포함합니다. +- **`module-domain`**: 순수 비즈니스 로직과 데이터 모델(Entity)을 담당하는 핵심 모듈입니다. + - `domain-orders`: 가맹점 및 본사의 발주(Order) 프로세스 + - `domain-inventories`: 공장 및 가맹점의 재고(Inventory) 관리 + - `domain-settlements`: 가맹점 수익 및 본사 정산(Settlement) 로직 + - `domain-transports`: 물류 및 차량 배차(Logistics) 정보 + - `domain-users`: 회원 정보 및 권한 관리 + - *기타 도메인*: `products`, `notices`, `notifications`, `sales`, `returns` 등 +- **`module-app:app-api`**: 외부 요청을 처리하는 API 엔드포인트를 제공하며 도메인 모듈을 조합하여 실제 서비스를 제공합니다. +- **`module-external-transport`**: 외부 시스템(물류 시스템 등)과의 연동을 담당합니다. -> 탑레벨 클래스(Top level class)는 소스 파일에 1개만 존재해야 한다. +
-### 2. static import에만 와일드 카드 허용 +## 🚀 주요 비즈니스 프로세스 -> 클래스를 import할때는 와일드카드(`*`) 없이 모든 클래스명을 다 쓴다. static import에서는 와일드카드를 허용한다. +### 1. 발주 및 생산 (Ordering & Production) +- **가맹점 -> 본사 발주**: 가맹점에서 부족한 밀키트 품목을 본사에 요청합니다. (화/금 도착 기준) +- **본사 -> 공장 생산 요청**: 본사에서 전체 가맹점의 수요를 취합하여 공장에 생산 지시를 내립니다. +- **제품 코드 체계**: 제품명 + 매운맛(01~04) + 사이즈(01, 03)의 조합으로 관리됩니다. -### 3. 애너테이션 선언 후 새줄 사용 +- ### 2. 물류 및 재고 (Logistics & Inventory) +- **박스 및 제품 식별 코드**: 생산된 제품은 지역/공장/생산라인 정보가 포함된 고유 코드로 관리됩니다. +- **피킹 및 차량 배차**: 출고 전 패키징 확정(피킹) 후 적재 중량을 고려하여 물류 차량에 자동/반자동 배차를 진행합니다. -> 클래스, 인터페이스, 메서드, 생성자에 붙는 애너테이션은 선언 후 새줄을 사용한다. 이 위치에서도 파라미터가 없는 애너테이션 1개는 같은 줄에 선언할 수 있다. +### 3. 정산 (Settlement) +- **매출 및 대금 정산**: 가맹점의 판매 데이터를 기반으로 본사 대금 차감 및 수수료 정산을 수행합니다. +- **반품 관리**: 하자 상품에 대한 반품 요청 및 검수 후 대금 차감을 지원합니다. +
-### 4. 배열에서 대괄호는 타입 뒤에 선언 +## 📝 프로젝트 문서 -> 배열 선언에 오는 대괄호(`[]`)는 타입의 바로 뒤에 붙인다. 변수명 뒤에 붙이지 않는다. +### ⚡ 요구사항 정의서 -### 5. `long`형 값의 마지막에 `L`붙이기 +
+요구사항 정의서 +
-> long형의 숫자에는 마지막에 대문자 'L’을 붙인다. 소문자 'l’보다 숫자 '1’과의 차이가 커서 가독성이 높아진다. +image +image +image +image +image
-
-URL -
-### URL -URL은 RESTful API 설계 가이드에 따라 작성합니다. +### ⚡ ERD -- HTTP Method로 구분할 수 있는 get, put 등의 행위는 url에 표현하지 않습니다. -- 마지막에 `/` 를 포함하지 않습니다. -- `_` 대신 `-`를 사용합니다. -- 소문자를 사용합니다. -- 확장자는 포함하지 않습니다. +
+ERD +
+image
-
- -### ☀️ Commit Convention - ---- +### ⚡ WBS
-Rules +WBS
-### 1. Git Flow - -작업 시작 시 선행되어야 할 작업은 다음과 같습니다. - - -> 1. issue를 생성합니다. -> 2. 생성된 Jira의 티켓의 세부 내용(Story Point, Start Date, Due Date 등)을 관리합니다. -> 3. Jira의 티켓 번호에 따라 feature branch를 생성합니다. -> 4. add → commit → push → pull request 를 진행합니다. -> 5. pull request를 dev branch로 merge 합니다. -> 6. 이전에 merge된 작업이 있을 경우 다른 branch에서 진행하던 작업에 merge된 작업을 pull 받아옵니다. +image +
+
-### 2. Etc +### ⚡API 테스트케이스 -준수해야 할 규칙은 다음과 같습니다. +
+API 테스트케이스 +
-> 1. develop branch에서의 작업은 원칙적으로 금지합니다. 단, README 작성은 develop branch에서 수행합니다. -> 2. commit, push, merge, pull request 등 모든 작업은 오류 없이 정상적으로 실행되는 지 확인 후 수행합니다. +image +image +image +image +image +image +image +image
+### ⚡ API 문서 +
-Branch +API 문서
-### 1. Branch +image +image +image +image +image +image +image +image +image +image -branch는 작업 단위 & 기능 단위로 생성하며 이는 Jira Ticket를 기반으로 합니다. +
+
-### 2. Branch Naming Rule +
-branch를 생성하기 전 issue를 먼저 작성합니다. issue 작성 후 생성되는 티켓의 번호와 domain 명을 조합하여 branch의 이름을 결정합니다. `/--` 의 양식을 준수합니다. +## 📖 개발 가이드 및 컨벤션 -### 3. Prefix +### 👥 팀 협업 규칙 -- `main` : 개발이 완료된 산출물이 저장될 공간입니다. -- `dev`: feature branch에서 구현된 기능들이 merge될 default branch 입니다. -- `feat`: 기능을 개발하는 branch 입니다. 이슈 별 & 작업 별로 branch를 생성 후 기능을 개발하며 naming은 소문자를 사용합니다. +- **데일리 스크럼**: 평일 오전 09:00 (10분 내외) +- **지라(Jira) 연동**: 작업 시작 전 이슈 생성 및 브랜치(`feat/이슈번호-도메인`) 생성 필수 +- **품질 관리**: 모든 PR은 SonarQube 분석 및 테스트 통과를 기본으로 합니다. -### 4. Domain +### ⭐ Code Convention -- `user`, `inventory`, `settlement`, `order`, `purchase` +
+1. Naming (명명 규칙) +
+- **패키지**: 언더스코어(`_`) 없이 소문자만 사용 +- **클래스**: 대문자 카멜 케이스 (`UpperCamelCase`) +- **메서드**: 동사/전치사 시작, 소문자 카멜 케이스 (`lowerCamelCase`) +- **변수**: 소문자 카멜 케이스 (`lowerCamelCase`) +- **ENUM/상수**: 대문자 및 언더스코어 (`UPPER_SNAKE_CASE`) +- **DB 테이블**: 소문자 및 언더스코어 (`lower_snake_case`) +- **컬렉션**: 복수형 사용 혹은 명시 (`users`, `userList`, `userMap`) -### 5. Etc +
+
-- `feat/7-order-create-order`, `feat/5-settlement-monthly` +
+2. Comment & Import (주석 및 임포트) +
+- **주석**: 한 줄 주석은 `//`, 여러 줄은 `/* ... */` 사용 +- **파일 구조**: 소스파일당 1개의 탑레벨 클래스만 포함 +- **Import**: 와일드카드(`*`) 사용 금지 (단, static import는 허용) +- **Annotation**: 선언 후 새 줄 사용 (파라미터 없는 1개는 같은 줄 허용) +- **기타**: 배열 대괄호는 타입 뒤에 (`String[]`), `long`형 값 끝에는 대문자 `L` 사용
-Issue +3. URL (RESTful API)
-### 1. Issue - -작업 시작 전 issue 생성이 선행되어야 합니다. issue 는 작업 단위 & 기능 단위로 생성하며 연동되어 생성되는 Jira의 ticket number 를 참조하여 branch 이름과 commit message를 작성합니다. +- **행위 배제**: URL에 get, put 등 행위 표현 금지 (HTTP Method로 구분) +- **구분자**: `_` 대신 `-`(Kebab-case) 사용 +- **형식**: 소문자 사용, 마지막 `/` 및 확장자 포함 금지 -issue 제목에는 기능의 대표적인 설명을 적고 내용에는 세부적인 내용 및 작업 진행 상황을 작성합니다. +
+
-### 2. Issue Naming Rule +### ⭐ Commit Convention -`[] ` 의 양식을 준수하되, prefix는 commit message convention을 따릅니다. +
+1. Git Flow & Rules +
-### 3. Etc +- **프로세스**: Issue 생성 → Jira 티켓 관리 → feature 브랜치 생성 → add/commit/push → PR 생성 → dev 머지 +- **기본 규칙**: `dev` 브랜치 직접 작업 금지 (README 제외), 모든 작업은 정상 실행 확인 후 수행 +- **브랜치 네이밍**: `/--` + - 예시: `feat/7-order-create-order`, `feat/5-settlement-monthly` -
+
- +
+2. Commit Message & PR +
---- +- **양식**: `[] # ` +- **Prefix**: + - `feat`: 새로운 기능 구현 + - `fix`: 버그 수정 + - `del`: 코드 삭제 + - `docs`: 문서 개정 + - `refactor`: 리팩터링 + - `chore`: 빌드 업무, 패키지 구조 변경, 의존성 추가 + - `test`: 테스트 코드 작성 및 수정
+
+ +## 📝 프로젝트 회고 + +프로젝트를 마치며 팀원 각자가 느낀 기술적 도전과 성장을 기록합니다. +
-Commit +🦫 김채우
-### 1. Commit Message Convention +멀티 모듈을 도입해 배포까지 진행했던 프로젝트는 이번이 처음이었습니다. +5명이서 협업을 하며 하나의 결과물을 냈습니다. 실제 도메인을 구매해 접속해 보는 과정은 새로웠습니다. +2개월간 밀도 높은 시간으로 하나의 프로젝트에 몰입한 것 자체가 즐거웠습니다. 아쉬움도 많이 남지만 이 경험을 발판으로 앞으로 더욱 뛰어난 개발자가 되도록 노력하겠습니다. -`[] # ` 의 양식을 준수합니다. +
+
-- **feat** : 새로운 기능 구현 `[feat] #11 구글 로그인 API 기능 구현` -- **fix** : 코드 오류 수정 `[fix] #10 회원가입 비즈니스 로직 오류 수정` -- **del** : 쓸모없는 코드 삭제 `[del] #12 불필요한 import 제거` -- **docs** : README나 wiki 등의 문서 개정 `[docs] #14 리드미 수정` -- **refactor** : 내부 로직은 변경 하지 않고 기존의 코드를 개선하는 리팩터링 `[refactor] #15 코드 로직 개선` -- **chore** : 의존성 추가, yml 추가와 수정, 패키지 구조 변경, 파일 이동 `[chore] #21 yml 수정`, `[chore] #22 lombok 의존성 추가` -- **test**: 테스트 코드 작성, 수정 `[test] #20 로그인 API 테스트 코드 작성` -- **style** : 코드에 관련 없는 주석 달기, 줄바꿈 +
+☠️ 김성은 +
-커밋에 대해 설명해야 하는 부분이 많다면 git commit -m "[] # " -m ""으로 작성합니다. +이번 프로젝트에서는 회원, 사업장 관리, 공지사항 및 알림 기능을 담당했으며, 동시에 인프라를 맡아 CI/CD 환경을 구축했습니다. +AWS를 처음 다뤄보는 상황이라 초반에는 어려움이 있었지만, 시행착오를 거치며 점차 익숙해졌고 실제 서비스 흐름을 고려한 배포 과정을 경험할 수 있었습니다. +또한 GitHub PR과 Jira 연동을 활용해 협업 프로세스를 체계적으로 경험했고, 팀원들과의 커뮤니케이션을 통해 기능을 조율하며 협업의 중요성을 느낄 수 있었습니다. +이번 프로젝트는 새로운 기술을 직접 적용해보고 문제를 해결해 나가는 과정에서 많은 것을 배우고, 한 단계 성장할 수 있었던 의미 있는 경험이었습니다.
-Pull Request +🐇 김윤경
-### 1. Pull Request +단순한 기능 개발을 넘어 기획부터 설계, 개발까지 전 과정을 경험하면서 서비스가 만들어지는 흐름을 깊게 이해할 수 있는 시간이었다. +각 도메인 간 상품의 이동과 돈의 흐름을 연결하여 하나의 시스템으로 바라볼 수 있었던 점이 가장 의미 있었다. 우리가 만든 ERP 시스템을 통해 비즈니스의 전반적인 흐름을 한눈에 파악할 수 있었던 것은 매우 값진 경험이었다. +멘토님의 리뷰를 통해 부족한 점을 객관적으로 돌아볼 수 있었고, 데일리 스크럼을 통해 팀원들의 진행 상황을 공유하며 협업의 중요성도 체감할 수 있었다. +많은 공을 들인 만큼 아쉬운 부분도 있었다. 초기 기획 단계에서 요구사항을 더 구체적으로 정의하지 못했던 점과, 서로 맡은 도메인 개발에 집중하다보니 소통이 부족했던 순간들이 아쉬웠다. +이 경험을 통해 꾸준한 커뮤니케이션이 얼마나 중요한지 깨달았고, 앞으로는 이를 적극적으로 반영하여 개발을 하고싶다. -develop & main branch로 merge할 때에는 pull request가 필요합니다. pull request의 내용에는 변경된 사항에 대한 설명을 명시합니다. +
+
-### 2. Pull Request Naming Rule +
+🦘 유찬연 +
-`[] # ` 의 양식을 준수하되, prefix는 commit message convention을 따릅니다. +지난 시간들동안 배운 것들을 토대로 파이널 프로젝트를 진행했다. 많은 팀 프로젝트에서 매번 중요성을 느끼게 된 것도 있었고, 반복됨에 따라 중요성을 새롭게 느끼게 된 것도 있었다. +매번 중요성을 느끼게 된 것은 역시 소통이었다. 소통을 통해 우리가 하고자 하는 것이 같은 것인지를 확인하는 것이 중요했고, 구현이 되는 것에서도 통일되어야 했다. +지난 모든 프로젝트들에서 이 소통이라는 것이 매번 조금씩은 아쉬움이 느껴졌었다. 다음 프로젝트에서는 더 잘할 수 있겠다고 생각했지만, 또 다른 부분에서 소통이 미흡함을 느껴 아쉬움을 느꼈다. +프로젝트가 반복됨에 따라 중요도를 알게 된 것은 구조를 얼마나 견고하게 만드는 것인가 였다. 구조가 견고하지 못하면 서비스 로직에도 구멍이 많이 생기게 되고 작업을 수행할 때도 로직이 더 길어지는 등의 불편함이 있었다. +그래도 미흡한 점만 있지는 않았다. 개발을 시작한 뒤로 이정도까지 몰두해서 개발을 진행한 경험이 처음이었고, 프로젝트를 진행함에 따라 개발을 어떤 식으로 진행해야 하는지 지금 당장 처한 상황에서 내가 할 수 있는 일이 무엇인지 등을 깨닫게 되었다. +이러한 성장을 토대로 이후 진행하게 될 많은 프로젝트들에서 이런 미흡한 점들을 고려해 더욱 완성도 있는 팀 프로젝트를 만들고 싶다는 생각이 들었다. 그리 길지 않은 시간이었지만 많은 것들을 배웠고 느끼며 성장하는 값진 시간이었다. -### 3. Etc +
+
-[feat] #3 약속 잡기 API 구현 -
-[chore] #5 spring data JPA 의존성 추가 +
+🐜 조윤호 +
+어느 정도 익혔다고 생각하고 파이널 프로젝트에 적용했지만, 예상보다 훨씬 어려웠다. 엔티티, 기능, 테스트를 반복적으로 수정하며 “더 효율적인 방법이 있었을 텐데”라는 아쉬움이 들었다. +특히 초기 설계의 중요성을 크게 느꼈다. 기반이 탄탄하지 않으면 이후에 큰 수정이 반복된다는 것을 직접 경험했다. +비록 쉽지 않은 과정이었지만, 다양한 시행착오를 통해 한 단계 성장할 수 있었던 의미 있는 경험이었다.
- -
- - -### ☀️ Test Code Convention - ---- - -### 1. 테스트 네이밍 컨벤션 -- 메서드명 패턴: MethodName_Given상황_Should기대결과 -``` -@Test -void createOrder_GivenValidProduct_ShouldReturnOrderId() { - // given - Product product = new Product("떡볶이", 5000); - - // when - Long orderId = orderService.createOrder(product); - - // then - assertThat(orderId).isNotNull(); -} -``` - -### 2. 테스트 구조 -1. Arrange (given): 테스트 데이터와 Mock 설정 -2. Act (when): 테스트 대상 메소드 실행 -3. Assert (then): 결과 검증 -- 기존 사용하던 방식과 동일 -- 모든 테스트 메소드는 해당 구조를 따르도록 작성 - -### 3. 패키지 구조 -테스트 구조는 원본 클래스와 동일한 패키지 구조를 유지 -``` -src/main/java/com/example/order/OrderService.java -src/test/java/com/example/order/OrderServiceTest.java -``` -- 서비스 코드가 분리되면 테스트 코드도 분리되어야 함 - - -### 4. 테스트 코드 작성 대상 -- Controller - - @WebMvcTest 사용 - - Mock 대상: Service - - 테스트 목적: HTTP 통신, 유효성 검증 -- Service - - @SpringBootTest 사용 - - Mock 대상: 없음. 실제 DB 사용 - - 테스트 목적: 비즈니스 로직, 트랜잭션 검증 -- Repository - - @DataJpaTest 사용 - - Mock 대상: 없음 - - 테스트 목적: 쿼리 메소드, 데이터 접근 검증 - -
-
-
diff --git a/TestSettlementLogic.java b/TestSettlementLogic.java new file mode 100644 index 000000000..488c73d41 --- /dev/null +++ b/TestSettlementLogic.java @@ -0,0 +1,41 @@ +import java.sql.*; +import java.math.BigDecimal; +import java.math.RoundingMode; + +public class TestSettlementLogic { + public static void main(String[] args) throws Exception { + String url = "jdbc:mariadb://beyond21.iptime.org:7001/chaing"; + String user = "root"; + String pass = "root"; + + Class.forName("org.mariadb.jdbc.Driver"); + + try (Connection conn = DriverManager.getConnection(url, user, pass)) { + String dateStr = "2026-03-06"; + System.out.println("=== Testing Settlement Logic for " + dateStr + " ==="); + + // 1. Sales Aggregation + String salesSql = "SELECT si.unit_price, si.quantity " + + "FROM sales_item si " + + "JOIN sales s ON si.sales_id = s.sales_id " + + "WHERE s.franchise_id = 1 AND s.deleted_at IS NULL " + + "AND s.is_canceled = false " + + "AND CAST(s.created_at AS DATE) = ?"; + + BigDecimal totalSale = BigDecimal.ZERO; + try (PreparedStatement stmt = conn.prepareStatement(salesSql)) { + stmt.setString(1, dateStr); + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + totalSale = totalSale.add(rs.getBigDecimal("unit_price").multiply(rs.getBigDecimal("quantity"))); + } + } + System.out.println("Total Sale: " + totalSale); + + // 2. Commission Fee Calculation + BigDecimal commissionFee = totalSale.multiply(new BigDecimal("0.033")) + .setScale(0, RoundingMode.HALF_UP); + System.out.println("Calculated Commission: " + commissionFee); + } + } +} diff --git a/VerifyCommission.java b/VerifyCommission.java new file mode 100644 index 000000000..41d9cf7cc --- /dev/null +++ b/VerifyCommission.java @@ -0,0 +1,30 @@ +import java.math.BigDecimal; +import java.math.RoundingMode; + +public class VerifyCommission { + public static void main(String[] args) { + BigDecimal totalSale = new BigDecimal("44000"); + BigDecimal commissionRate = new BigDecimal("0.033"); + + BigDecimal commissionFee = totalSale.multiply(commissionRate) + .setScale(0, RoundingMode.HALF_UP); + + System.out.println("Total Sale: " + totalSale); + System.out.println("Commission Rate: 3.3%"); + System.out.println("Calculated Commission: " + commissionFee); + + // Expected: 44000 * 0.033 = 1452 + if (commissionFee.compareTo(new BigDecimal("1452")) == 0) { + System.out.println("SUCCESS: Commission calculation is correct."); + } else { + System.out.println("FAILURE: Commission calculation is incorrect."); + } + + // Test with a non-integer result + BigDecimal totalSale2 = new BigDecimal("10000"); + BigDecimal commissionFee2 = totalSale2.multiply(commissionRate).setScale(0, RoundingMode.HALF_UP); + System.out.println("\nTotal Sale: " + totalSale2); + System.out.println("Calculated Commission (rounded): " + commissionFee2); + // 10000 * 0.033 = 330.00 + } +} diff --git a/VerifyDetailedOrders.java b/VerifyDetailedOrders.java new file mode 100644 index 000000000..8e0af1730 --- /dev/null +++ b/VerifyDetailedOrders.java @@ -0,0 +1,61 @@ +import java.sql.*; +import java.math.BigDecimal; + +public class VerifyDetailedOrders { + public static void main(String[] args) throws Exception { + String url = "jdbc:mariadb://beyond21.iptime.org:7001/chaing"; + String user = "root"; + String pass = "root"; + + Class.forName("org.mariadb.jdbc.Driver"); + + try (Connection conn = DriverManager.getConnection(url, user, pass)) { + String dateStr = "2026-03-13"; + System.out.println("=== Verifying Detailed Order Aggregation for " + dateStr + " ==="); + + // 1. Find all order IDs for the date + String orderSql = "SELECT franchise_order_id, order_code, order_status FROM franchise_order " + + "WHERE franchise_id = 1 AND CAST(created_at AS DATE) = ?"; + + BigDecimal totalOrderAmount = BigDecimal.ZERO; + int orderCount = 0; + int totalItemCount = 0; + + try (PreparedStatement stmt = conn.prepareStatement(orderSql)) { + stmt.setString(1, dateStr); + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + long orderId = rs.getLong("franchise_order_id"); + String code = rs.getString("order_code"); + String status = rs.getString("order_status"); + orderCount++; + + System.out.println("\n[Order: " + code + "] Status: " + status); + + // 2. Fetch items for this specific order + String itemSql = "SELECT product_id, unit_price, quantity, total_price FROM franchise_order_item " + + "WHERE franchise_order_id = ? AND deleted_at IS NULL"; + + try (PreparedStatement itemStmt = conn.prepareStatement(itemSql)) { + itemStmt.setLong(1, orderId); + ResultSet itemRs = itemStmt.executeQuery(); + while (itemRs.next()) { + BigDecimal itemTotal = itemRs.getBigDecimal("total_price"); + long productId = itemRs.getLong("product_id"); + int qty = itemRs.getInt("quantity"); + + System.out.printf(" - Product ID: %d (Qty: %d) | Total: %s%n", productId, qty, itemTotal); + totalOrderAmount = totalOrderAmount.add(itemTotal); + totalItemCount++; + } + } + } + } + + System.out.println("\n--- Aggregation Summary ---"); + System.out.println("Total Orders: " + orderCount); + System.out.println("Total Items Collected: " + totalItemCount); + System.out.println("Calculated Daily Order Amount: " + totalOrderAmount); + } + } +} diff --git a/VerifyReturnLogic.java b/VerifyReturnLogic.java new file mode 100644 index 000000000..2a7e01445 --- /dev/null +++ b/VerifyReturnLogic.java @@ -0,0 +1,69 @@ +import java.sql.*; +import java.math.BigDecimal; +import java.util.*; + +public class VerifyReturnLogic { + public static void main(String[] args) throws Exception { + String url = "jdbc:mariadb://beyond21.iptime.org:7001/chaing"; + String user = "root"; + String pass = "root"; + + Class.forName("org.mariadb.jdbc.Driver"); + + try (Connection conn = DriverManager.getConnection(url, user, pass)) { + System.out.println("=== Return Logic Simulation (Match UI Calculation) ==="); + + String sql = "SELECT r.return_id, r.return_code, r.return_type, r.return_status " + + "FROM returns r " + + "WHERE r.created_at >= '2026-03-07 00:00:00' AND r.created_at < '2026-03-08 00:00:00' " + + "AND r.deleted_at IS NULL"; + + BigDecimal totalRefund = BigDecimal.ZERO; + BigDecimal totalLoss = BigDecimal.ZERO; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + long returnId = rs.getLong("return_id"); + String code = rs.getString("return_code"); + String type = rs.getString("return_type"); + + BigDecimal currentReturnTotal = BigDecimal.ZERO; + + // Simulation of item-based calculation + String itemSql = "SELECT foi.franchise_order_item_id, foi.unit_price, p.name " + + "FROM return_item ri " + + "JOIN franchise_order_item foi ON ri.franchise_order_item_id = foi.franchise_order_item_id " + + + "JOIN product p ON foi.product_id = p.product_id " + + "WHERE ri.return_id = ?"; + + try (PreparedStatement itemStmt = conn.prepareStatement(itemSql)) { + itemStmt.setLong(1, returnId); + ResultSet itemRs = itemStmt.executeQuery(); + while (itemRs.next()) { + BigDecimal price = itemRs.getBigDecimal("unit_price"); + currentReturnTotal = currentReturnTotal.add(price); + System.out.printf(" - Item ID: %d | Product: %s | Price: %s%n", + itemRs.getLong("franchise_order_item_id"), + itemRs.getString("name"), + price); + } + } + + System.out.printf("[%s] Type: %s | Calculated Amount: %s%n", code, type, currentReturnTotal); + + if ("PRODUCT_DEFECT".equals(type)) { + totalRefund = totalRefund.add(currentReturnTotal); + } else if ("MISORDER".equals(type)) { + totalLoss = totalLoss.add(currentReturnTotal); + } + } + } + + System.out.println("\n--- Final Aggregation Result ---"); + System.out.println("Total Refund (Product Defect): " + totalRefund); + System.out.println("Total Loss (Misorder): " + totalLoss); + } + } +} diff --git a/module-app/app-api/src/main/java/com/chaing/api/controller/franchise/FranchiseInventoryController.java b/module-app/app-api/src/main/java/com/chaing/api/controller/franchise/FranchiseInventoryController.java index 7ca80e27d..f376713d3 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/controller/franchise/FranchiseInventoryController.java +++ b/module-app/app-api/src/main/java/com/chaing/api/controller/franchise/FranchiseInventoryController.java @@ -37,7 +37,6 @@ import java.security.Principal; import java.util.List; -import java.util.Map; @RestController @Tag(name = "FranchiseInventory API", description = "가맹점 재고 관련 API") @@ -136,16 +135,6 @@ public ResponseEntity> resetSafetyStock( return ResponseEntity.ok(ApiResponse.success(null)); } - @Operation(summary = "안전재고 설정 비밀번호 확인", description = "관리자 비밀번호가 실제 사용자 비밀번호와 일치하는지 확인합니다.") - @PostMapping("/verify-password") - public ResponseEntity> verifyPassword( - @AuthenticationPrincipal UserPrincipal principal, - @RequestBody Map request) { - return ResponseEntity.ok(ApiResponse.success( - franchiseInventoryFacade.verifyAdminPassword(principal.getId(), - request.get("password")))); - } - @Operation(summary = "판매 제품 목록 조회", description = "스캔된 판매 목록이 화면에 출력된다.") @GetMapping("/scanned-sales-list") public ResponseEntity> getScannedSalesList( diff --git a/module-app/app-api/src/main/java/com/chaing/api/controller/franchise/FranchiseOrderController.java b/module-app/app-api/src/main/java/com/chaing/api/controller/franchise/FranchiseOrderController.java index 64ac96104..1468819a7 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/controller/franchise/FranchiseOrderController.java +++ b/module-app/app-api/src/main/java/com/chaing/api/controller/franchise/FranchiseOrderController.java @@ -7,6 +7,7 @@ import com.chaing.api.security.principal.UserPrincipal; import com.chaing.core.dto.ApiResponse; import com.chaing.domain.orders.dto.response.FranchiseOrderCancelResponse; +import com.chaing.domain.orders.dto.response.FranchiseOrderCreateInfoResponse; import com.chaing.domain.orders.dto.response.FranchiseOrderCreateResponse; import com.chaing.domain.orders.dto.response.FranchiseOrderDetailResponse; import com.chaing.domain.orders.dto.response.FranchiseOrderUpdateResponse; @@ -98,4 +99,14 @@ public ResponseEntity> createOrder( return ResponseEntity.ok(ApiResponse.success(franchiseOrderFacade.createOrder(userId, request))); } + + @Operation(summary = "발주 생성 창 정보 반환", description = "수령인 이름, 전화번호, 수령 주소 반환") + @GetMapping("/creation/info") + public ResponseEntity> getUserInfo( + @AuthenticationPrincipal UserPrincipal userPrincipal + ) { + Long userId = userPrincipal.getId(); + + return ResponseEntity.ok(ApiResponse.success(franchiseOrderFacade.getUserInfo(userId))); + } } diff --git a/module-app/app-api/src/main/java/com/chaing/api/controller/franchise/FranchiseSettlementController.java b/module-app/app-api/src/main/java/com/chaing/api/controller/franchise/FranchiseSettlementController.java index 2f7aa6799..d3a0cbd31 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/controller/franchise/FranchiseSettlementController.java +++ b/module-app/app-api/src/main/java/com/chaing/api/controller/franchise/FranchiseSettlementController.java @@ -48,20 +48,22 @@ public ResponseEntity> getDailySummary( @GetMapping("/daily/sales-items") public ResponseEntity> getDailySalesItems( @RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(value = "limit", required = false) Integer limit, @AuthenticationPrincipal UserPrincipal principal) { Long franchiseId = principal.getBusinessUnitId(); return ResponseEntity.ok(ApiResponse.success( - facade.getDailySalesItems(franchiseId, date, 5))); // top5 + facade.getDailySalesItems(franchiseId, date, limit))); } @Operation(summary = "일별 발주 내역 조회", description = "발주 내역 리스트(상품명/수량/단가/총금액)") @GetMapping("/daily/order-items") public ResponseEntity> getDailyOrdersItems( @RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(value = "limit", required = false) Integer limit, @AuthenticationPrincipal UserPrincipal principal) { Long franchiseId = principal.getBusinessUnitId(); return ResponseEntity.ok(ApiResponse.success( - facade.getDailyOrderItems(franchiseId, date, 5))); + facade.getDailyOrderItems(franchiseId, date, limit))); } // 월별 @@ -90,25 +92,6 @@ public ResponseEntity> getMonthlySales( facade.getMonthlySalesItems(franchiseId, month, limit))); } - @Operation(summary = "월별 일자 매출 추이 조회 그래프", description = "기간 선택 후 매출 추이 그래프 조회") - @GetMapping("/monthly/daily-sales-graph") - public ResponseEntity> getMonthlyDailySalesGraph( - @RequestParam("month") @DateTimeFormat(pattern = "yyyy-MM") YearMonth month, - @RequestParam("start") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate start, - @RequestParam("end") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate end, - @AuthenticationPrincipal UserPrincipal principal) { - if (start.isAfter(end)) { - throw new SettlementException(SettlementErrorCode.INVALID_DATE_RANGE); - } - if (!YearMonth.from(start).equals(month) || !YearMonth.from(end).equals(month)) { - throw new SettlementException(SettlementErrorCode.INVALID_PARAMETER); - } - Long franchiseId = principal.getBusinessUnitId(); - // TODO: Swagger 테스트용 임시 응답. 추후 서비스 로직 연동 예정 - return ResponseEntity.ok(ApiResponse.success( - facade.getMonthlyDailyGraph(franchiseId, start, end))); - } - @Operation(summary = "월별 발주 내역 상위 조회", description = "월별 발주 상품별 전체조회, limit있으면 수량기준 상위 5개 조회") @GetMapping("/monthly/order-items") public ResponseEntity> getMonthlyOrders( @@ -123,6 +106,16 @@ public ResponseEntity> getMonthlyOrders( facade.getMonthlyOrderItems(franchiseId, month, limit))); } + @Operation(summary = "월별 매출 추이 조회(그래프)", description = "월별 일자별 매출 합계 리스트 조회") + @GetMapping("/monthly/sales-graph") + public ResponseEntity> getMonthlySalesTrend( + @RequestParam("month") @DateTimeFormat(pattern = "yyyy-MM") YearMonth month, + @AuthenticationPrincipal UserPrincipal principal) { + Long franchiseId = principal.getBusinessUnitId(); + return ResponseEntity.ok(ApiResponse.success( + facade.getMonthlySalesTrend(franchiseId, month))); + } + // 전표 상세 목록 조회, 일/월 공통 @Operation(summary = "전표 상세 목록 조회(일/월 공통)", description = """ 전표 페이지1개 period, date, month, type으로 목록 필터링 diff --git a/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQInventoryController.java b/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQInventoryController.java index 31a896cdb..d5bb2ba01 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQInventoryController.java +++ b/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQInventoryController.java @@ -180,12 +180,4 @@ public ResponseEntity> resetSafetyStock(@PathVariable Long pro return ResponseEntity.ok(ApiResponse.success(null)); } - @Operation(summary = "안전재고 설정 비밀번호 확인", description = "관리자 비밀번호가 실제 사용자 비밀번호와 일치하는지 확인합니다.") - @PostMapping("/verify-password") - public ResponseEntity> verifyPassword( - @AuthenticationPrincipal UserPrincipal principal, - @RequestBody Map request) { - return ResponseEntity.ok(ApiResponse.success( - hqInventoryFacade.verifyAdminPassword(principal.getId(), request.get("password")))); - } } diff --git a/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQInventoryLogController.java b/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQInventoryLogController.java index d3f3bbe20..ec1546cd1 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQInventoryLogController.java +++ b/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQInventoryLogController.java @@ -1,15 +1,10 @@ package com.chaing.api.controller.hq; -import com.chaing.api.dto.hq.inventorylogs.request.InventoryOrderLogTestRequest; import com.chaing.api.dto.hq.inventorylogs.request.InventoryReturnLogTestRequest; -import com.chaing.api.security.principal.UserPrincipal; -import com.chaing.domain.inventorylogs.enums.ActorType; -import jakarta.validation.Valid; -import org.springframework.data.domain.Pageable; -import org.springframework.data.web.PageableDefault; - import com.chaing.api.facade.hq.HQInventoryLogFacade; import com.chaing.api.facade.inventorylogs.InventoryLogFacade; +import com.chaing.api.security.principal.UserPrincipal; +import com.chaing.core.enums.LogType; import com.chaing.core.dto.ApiResponse; import com.chaing.domain.inventorylogs.dto.request.FactoryLogRequest; import com.chaing.domain.inventorylogs.dto.request.FranchiseLogRequest; @@ -18,9 +13,13 @@ import com.chaing.domain.inventorylogs.dto.response.FactoryInventoryLogListResponse; import com.chaing.domain.inventorylogs.dto.response.FranchiseInventoryLogListResponse; import com.chaing.domain.inventorylogs.dto.response.InventoryLogListResponse; +import com.chaing.domain.inventorylogs.enums.ActorType; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -123,8 +122,11 @@ public ResponseEntity> findFranch @GetMapping("/boxes") public ResponseEntity>> getBoxes( @RequestParam String transactionCode, - @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date) { - return ResponseEntity.ok(ApiResponse.success(inventoryLogFacade.getBoxCodes(transactionCode, date))); + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(required = false) String productName, + @RequestParam(required = false) LogType logType) { + return ResponseEntity.ok(ApiResponse.success( + inventoryLogFacade.getBoxCodes(transactionCode, date, productName, logType))); } @Operation(summary = "가맹점 판매 환불 이력 조회", description = "본사에서 가맹점 판매 환불 이력을 확인합니다.") @@ -167,20 +169,6 @@ public ResponseEntity> findFactoryI request, pageable))); } - @Operation(summary = "입출고 테스트", description = "입출고 로그를 테스트합니다.") - @PostMapping("/order") - public ResponseEntity> testOrderLog( - @Valid @RequestBody InventoryOrderLogTestRequest request) { - inventoryLogFacade.recordOrderLogs( - request.orderId(), - request.orderType(), // "FRANCHISE" | "HQ" - request.toId(), - request.logType(), - request.actorType(), // "FACTORY" | "HQ" | "FRANCHISE" - request.actorId()); - return ResponseEntity.ok(ApiResponse.success("ORDER_LOG_CREATED")); - } - @Operation(summary = "반품 입출고 테스트", description = "반품 입출고 로그를 테스트합니다.") @PostMapping("/return") public ResponseEntity> testReturnLog( diff --git a/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQOrderController.java b/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQOrderController.java index a75414227..e6e30792e 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQOrderController.java +++ b/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQOrderController.java @@ -13,6 +13,7 @@ import com.chaing.domain.orders.dto.response.HQOrderCancelResponse; import com.chaing.domain.orders.dto.response.HQOrderCreateResponse; import com.chaing.domain.orders.dto.response.HQOrderDetailResponse; +import com.chaing.domain.orders.dto.response.HQOrderPossibleResponse; import com.chaing.domain.orders.dto.response.HQOrderResponse; import com.chaing.domain.orders.dto.response.HQOrderStatusUpdateResponse; import com.chaing.domain.orders.dto.response.HQOrderUpdateResponse; @@ -37,6 +38,7 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; +import java.util.Map; @RestController @Tag(name = "HQ Order API", description = "본사 발주 관련 API") @@ -137,4 +139,13 @@ public ResponseEntity>> isPossible( + @RequestBody List orderCodes + ) { + return ResponseEntity.ok(ApiResponse.success(hqOrderFacade.isPossible(orderCodes))); + } } diff --git a/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQSettlementAdjustmentController.java b/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQSettlementAdjustmentController.java index 3295d7d0d..5bf39e026 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQSettlementAdjustmentController.java +++ b/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQSettlementAdjustmentController.java @@ -59,8 +59,9 @@ public ResponseEntity>> getAdjustmentTypes() { """) @PostMapping public ResponseEntity> createAdjustment( - @Valid @RequestBody HQSettlementAdjustmentVoucherRequest request) { - adjustmentFacade.createAdjustment(request); + @Valid @RequestBody HQSettlementAdjustmentVoucherRequest request, + @org.springframework.security.core.annotation.AuthenticationPrincipal com.chaing.api.security.principal.UserPrincipal principal) { + adjustmentFacade.createAdjustment(request, principal); return ResponseEntity.ok(ApiResponse.success(null)); } diff --git a/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQSettlementController.java b/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQSettlementController.java index 16f2a5d76..14054aae8 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQSettlementController.java +++ b/module-app/app-api/src/main/java/com/chaing/api/controller/hq/HQSettlementController.java @@ -2,6 +2,9 @@ import com.chaing.api.dto.hq.settlement.request.*; import com.chaing.api.dto.hq.settlement.response.*; +import com.chaing.api.dto.franchise.settlement.response.FranchiseSalesItemResponse; +import com.chaing.api.dto.franchise.settlement.response.FranchiseOrderItemResponse; +import com.chaing.api.dto.franchise.settlement.response.FranchiseDailyGraphResponse; import com.chaing.api.dto.franchise.settlement.response.FranchiseSettlementSummaryResponse; import com.chaing.api.dto.franchise.settlement.response.FranchiseVoucherResponse; import com.chaing.api.facade.hq.HQSettlementFacade; @@ -229,4 +232,49 @@ public ResponseEntity> getMonthlyExcel( return ResponseEntity.ok(ApiResponse.success(url)); } + // 가맹점 상세 연동용 추가 API + + @Operation(summary = "본사 가맹점 일별 매출 항목 조회", description = "특정 가맹점의 일별 매출 상품 목록을 조회합니다.") + @GetMapping("/daily/franchises/{franchiseId}/sales-items") + public ResponseEntity>> getDailyFranchiseSalesItems( + @PathVariable("franchiseId") Long franchiseId, + @RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(value = "limit", required = false) Integer limit) { + return ResponseEntity.ok(ApiResponse.success(hqFacade.getDailySalesItems(franchiseId, date, limit))); + } + + @Operation(summary = "본사 가맹점 일별 발주 항목 조회", description = "특정 가맹점의 일별 발주 품목 목록을 조회합니다.") + @GetMapping("/daily/franchises/{franchiseId}/order-items") + public ResponseEntity>> getDailyFranchiseOrderItems( + @PathVariable("franchiseId") Long franchiseId, + @RequestParam("date") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate date, + @RequestParam(value = "limit", required = false) Integer limit) { + return ResponseEntity.ok(ApiResponse.success(hqFacade.getDailyOrderItems(franchiseId, date, limit))); + } + + @Operation(summary = "본사 가맹점 월별 매출 항목 조회", description = "특정 가맹점의 월별 매출 상품 목록을 조회합니다.") + @GetMapping("/monthly/franchises/{franchiseId}/sales-items") + public ResponseEntity>> getMonthlyFranchiseSalesItems( + @PathVariable("franchiseId") Long franchiseId, + @RequestParam("month") @DateTimeFormat(pattern = "yyyy-MM") YearMonth month, + @RequestParam(value = "limit", required = false) Integer limit) { + return ResponseEntity.ok(ApiResponse.success(hqFacade.getMonthlySalesItems(franchiseId, month, limit))); + } + + @Operation(summary = "본사 가맹점 월별 발주 항목 조회", description = "특정 가맹점의 월별 발주 품목 목록을 조회합니다.") + @GetMapping("/monthly/franchises/{franchiseId}/order-items") + public ResponseEntity>> getMonthlyFranchiseOrderItems( + @PathVariable("franchiseId") Long franchiseId, + @RequestParam("month") @DateTimeFormat(pattern = "yyyy-MM") YearMonth month, + @RequestParam(value = "limit", required = false) Integer limit) { + return ResponseEntity.ok(ApiResponse.success(hqFacade.getMonthlyOrderItems(franchiseId, month, limit))); + } + + @Operation(summary = "본사 가맹점 월변 매출 추이 조회(그래프)", description = "특정 가맹점의 월별 일자별 매출 합계 리스트를 조회합니다.") + @GetMapping("/monthly/franchises/{franchiseId}/sales-graph") + public ResponseEntity>> getMonthlyFranchiseSalesTrend( + @PathVariable("franchiseId") Long franchiseId, + @RequestParam("month") @DateTimeFormat(pattern = "yyyy-MM") YearMonth month) { + return ResponseEntity.ok(ApiResponse.success(hqFacade.getMonthlySalesTrend(franchiseId, month))); + } } diff --git a/module-app/app-api/src/main/java/com/chaing/api/controller/inbound/InboundController.java b/module-app/app-api/src/main/java/com/chaing/api/controller/inbound/InboundController.java index 8146ca0de..68b182f73 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/controller/inbound/InboundController.java +++ b/module-app/app-api/src/main/java/com/chaing/api/controller/inbound/InboundController.java @@ -6,8 +6,10 @@ import com.chaing.api.dto.inbound.response.InboundBoxSummaryResponse; import com.chaing.api.dto.inbound.response.InboundDetailResponse; import com.chaing.api.facade.inbound.InboundFacade; +import com.chaing.api.facade.inventorylogs.InventoryLogFacade; import com.chaing.api.security.principal.UserPrincipal; import com.chaing.core.dto.ApiResponse; +import com.chaing.core.enums.LogType; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -32,6 +34,7 @@ public class InboundController { private final InboundFacade inboundFacade; + private final InventoryLogFacade logFacade; // 입고 스캔(공장) @PreAuthorize("hasRole('FACTORY')") @@ -96,7 +99,9 @@ public ResponseEntity> updateInboundStatus( @Valid @RequestBody InboundConfirmRequest request, @AuthenticationPrincipal UserPrincipal userPrincipal ){ + LogType status = LogType.INBOUND; inboundFacade.confirmInbound(request, userPrincipal.getRole()); + logFacade.recordOrderLogs(request.serialCodes(), userPrincipal, status); return ResponseEntity.ok(ApiResponse.success(null)); } diff --git a/module-app/app-api/src/main/java/com/chaing/api/controller/outbound/OutboundController.java b/module-app/app-api/src/main/java/com/chaing/api/controller/outbound/OutboundController.java index cdafe3dfc..30d2c3e4d 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/controller/outbound/OutboundController.java +++ b/module-app/app-api/src/main/java/com/chaing/api/controller/outbound/OutboundController.java @@ -3,10 +3,13 @@ import com.chaing.api.dto.outbound.request.OutboundAssignRequest; import com.chaing.api.dto.outbound.request.OutboundCancelRequest; import com.chaing.api.dto.outbound.request.OutboundItemRequest; +import com.chaing.api.dto.outbound.request.OutboundStatusUpdateRequest; import com.chaing.api.dto.outbound.request.OutboundUpdateRequest; import com.chaing.api.dto.outbound.response.OutboundBoxSummaryResponse; import com.chaing.api.dto.outbound.response.OutboundItemResponse; +import com.chaing.api.facade.inventorylogs.InventoryLogFacade; import com.chaing.api.facade.outbound.OutboundFacade; +import com.chaing.api.security.principal.UserPrincipal; import com.chaing.core.dto.ApiResponse; import com.chaing.core.enums.LogType; import io.swagger.v3.oas.annotations.Operation; @@ -15,6 +18,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -31,6 +35,7 @@ public class OutboundController { private final OutboundFacade outboundFacade; + private final InventoryLogFacade logFacade; // 출고 제품 스캔 및 피킹 대기 상태 변경 @PatchMapping("/scans") @@ -39,7 +44,7 @@ public ResponseEntity> scanOutbound( @Valid @RequestBody OutboundUpdateRequest request ) { LogType currentStatus = LogType.AVAILABLE; - outboundFacade.updateOutboundStatus(request, currentStatus); + outboundFacade.updateOutboundStatus(request.toSerialCodeList(), currentStatus); return ResponseEntity.ok(ApiResponse.success(null)); } @@ -49,7 +54,7 @@ public ResponseEntity> scanOutbound( public ResponseEntity> assignBox( @Valid @RequestBody OutboundAssignRequest request ) { - outboundFacade.assignBoxToInventories(request.boxCode(), request.serialCodes()); + outboundFacade.assignBoxToInventories(request.boxCode(), request.orderItemId(), request.serialCodes()); return ResponseEntity.ok(ApiResponse.success(null)); } @@ -57,10 +62,10 @@ public ResponseEntity> assignBox( @PatchMapping("/pickings") @Operation(summary = "피킹 확정", description = "피킹 대기 상태의 제품의 피킹을 확정합니다.") public ResponseEntity> pickOutbound( - @Valid @RequestBody OutboundUpdateRequest request + @Valid @RequestBody OutboundStatusUpdateRequest request ){ LogType currentStatus = LogType.PICKING_WAIT; - outboundFacade.updateOutboundStatus(request, currentStatus); + outboundFacade.updateOutboundStatus(request.serialCodes(), currentStatus); return ResponseEntity.ok(ApiResponse.success(null)); } @@ -68,10 +73,13 @@ public ResponseEntity> pickOutbound( @PatchMapping("/confirms") @Operation(summary = "출고 확정", description = "피킹 상태 제품의 출고를 확정합니다.") public ResponseEntity> confirmOutbound( - @Valid @RequestBody OutboundUpdateRequest request + @Valid @RequestBody OutboundStatusUpdateRequest request, + @AuthenticationPrincipal UserPrincipal userPrincipal ) { LogType currentStatus = LogType.PICKING; - outboundFacade.updateOutboundStatus(request, currentStatus); + LogType status = LogType.OUTBOUND; + outboundFacade.updateOutboundStatus(request.serialCodes(), currentStatus); + logFacade.recordOrderLogs(request.serialCodes(), userPrincipal, status); return ResponseEntity.ok(ApiResponse.success(null)); } diff --git a/module-app/app-api/src/main/java/com/chaing/api/dto/franchise/settlement/response/FranchiseSettlementSummaryResponse.java b/module-app/app-api/src/main/java/com/chaing/api/dto/franchise/settlement/response/FranchiseSettlementSummaryResponse.java index 686c9d927..aa2747ca1 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/dto/franchise/settlement/response/FranchiseSettlementSummaryResponse.java +++ b/module-app/app-api/src/main/java/com/chaing/api/dto/franchise/settlement/response/FranchiseSettlementSummaryResponse.java @@ -3,6 +3,7 @@ import java.math.BigDecimal; public record FranchiseSettlementSummaryResponse( + String franchiseName, // 가맹점명 BigDecimal finalAmount, // 최종 정산 금액 BigDecimal totalSaleAmount, // 총 매출 BigDecimal refundAmount, // 반품 환급 diff --git a/module-app/app-api/src/main/java/com/chaing/api/dto/hq/inventorylogs/request/InventoryOrderLogTestRequest.java b/module-app/app-api/src/main/java/com/chaing/api/dto/hq/inventorylogs/request/InventoryOrderLogTestRequest.java deleted file mode 100644 index 424c2c326..000000000 --- a/module-app/app-api/src/main/java/com/chaing/api/dto/hq/inventorylogs/request/InventoryOrderLogTestRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.chaing.api.dto.hq.inventorylogs.request; - - -import com.chaing.core.enums.LogType; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; - -public record InventoryOrderLogTestRequest( - @NotNull Long orderId, - @NotBlank String orderType, - @NotNull Long toId, - @NotNull LogType logType, - @NotBlank String actorType, - @NotNull Long actorId -) {} diff --git a/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/request/HQSettlementAdjustmentVoucherRequest.java b/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/request/HQSettlementAdjustmentVoucherRequest.java index e4fb5fb50..44e403a23 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/request/HQSettlementAdjustmentVoucherRequest.java +++ b/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/request/HQSettlementAdjustmentVoucherRequest.java @@ -21,6 +21,7 @@ public record HQSettlementAdjustmentVoucherRequest( @Schema(description = "발생일", example = "2026-02-26", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull + @com.fasterxml.jackson.annotation.JsonFormat(pattern = "yyyy-MM-dd") @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate occurredAt, @@ -29,16 +30,20 @@ public record HQSettlementAdjustmentVoucherRequest( @Min(0) Long amount, - @Schema(description = "차감 처리(마이너스) 체크 여부", example = "false", defaultValue = "false") - Boolean isMinus, + @Schema(description = "증가/차감 여부", example = "INCREASE", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull + com.chaing.domain.settlements.enums.AdjustmentDirection direction, @Schema(description = "내역(조정 사유)", example = "본사 프로모션 보전금", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank - String reason + String reason, + + @Schema(description = "정산 반영월", example = "2026-04", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull + @com.fasterxml.jackson.annotation.JsonFormat(pattern = "yyyy-MM") + java.time.YearMonth settlementMonth, + + @Schema(description = "반품 사유 (전표 유형이 RETURN일 때 필수)", example = "PRODUCT_DEFECT") + com.chaing.domain.returns.enums.ReturnType returnType ) { - public HQSettlementAdjustmentVoucherRequest { - if (isMinus == null) { - isMinus = false; - } - } } diff --git a/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/response/HQAdjustmentResponse.java b/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/response/HQAdjustmentResponse.java index a41b77d8f..499e50d6b 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/response/HQAdjustmentResponse.java +++ b/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/response/HQAdjustmentResponse.java @@ -23,21 +23,30 @@ public record HQAdjustmentResponse( @Schema(description = "조정 금액", example = "50000") Long amount, - @Schema(description = "차감 여부(true면 마이너스 처리)", example = "false") - Boolean isMinus, + @Schema(description = "처리 방식(증가/차감)", example = "INCREASE") + com.chaing.domain.settlements.enums.AdjustmentDirection direction, @Schema(description = "조정 사유", example = "본사 이벤트 지원금") - String reason) { + String reason, + + @Schema(description = "정산 반영월", example = "2026-04") + String settlementMonth, + + @Schema(description = "반품 사유", example = "PRODUCT_DEFECT") + com.chaing.domain.returns.enums.ReturnType returnType) { public static HQAdjustmentResponse of(Long adjustmentId, String franchiseName, VoucherType type, - LocalDate occurredAt, Long amount, Boolean isMinus, String reason) { + LocalDate occurredAt, Long amount, com.chaing.domain.settlements.enums.AdjustmentDirection direction, + String reason, String settlementMonth, com.chaing.domain.returns.enums.ReturnType returnType) { return HQAdjustmentResponse.builder() .adjustmentId(adjustmentId) .franchiseName(franchiseName) .type(type) .occurredAt(occurredAt) .amount(amount) - .isMinus(isMinus) + .direction(direction) .reason(reason) + .settlementMonth(settlementMonth) + .returnType(returnType) .build(); } } diff --git a/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/response/HQFranchiseSettlementResponse.java b/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/response/HQFranchiseSettlementResponse.java index ee2c75239..2c81130de 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/response/HQFranchiseSettlementResponse.java +++ b/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/response/HQFranchiseSettlementResponse.java @@ -24,7 +24,7 @@ public record HQFranchiseSettlementResponse( @Schema(description = "반품 환급", example = "100000") BigDecimal refundAmount, @Schema(description = "손실액", example = "50000") BigDecimal lossAmount, - + @Schema(description = "조정액", example = "10000") BigDecimal adjustmentAmount, @Schema(description = "최종 정산 금액", example = "4500000") BigDecimal finalAmount, @Schema(description = "정산 상태", example = "CONFIRMED") SettlementStatus status, @@ -33,7 +33,7 @@ public record HQFranchiseSettlementResponse( public static HQFranchiseSettlementResponse of(Long franchiseId, String franchiseName, BigDecimal totalSaleAmount, BigDecimal orderAmount, BigDecimal deliveryFee, BigDecimal commissionFee, - BigDecimal refundAmount, BigDecimal lossAmount, BigDecimal finalAmount, SettlementStatus status, + BigDecimal refundAmount, BigDecimal lossAmount, BigDecimal adjustmentAmount, BigDecimal finalAmount, SettlementStatus status, LocalDate settlementDate) { return HQFranchiseSettlementResponse.builder() .franchiseId(franchiseId) @@ -44,6 +44,7 @@ public static HQFranchiseSettlementResponse of(Long franchiseId, String franchis .commissionFee(commissionFee) .refundAmount(refundAmount) .lossAmount(lossAmount) + .adjustmentAmount(adjustmentAmount) .finalAmount(finalAmount) .status(status) .settlementDate(settlementDate) diff --git a/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/response/HQSettlementSummaryResponse.java b/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/response/HQSettlementSummaryResponse.java index 754cedeea..8c308debf 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/response/HQSettlementSummaryResponse.java +++ b/module-app/app-api/src/main/java/com/chaing/api/dto/hq/settlement/response/HQSettlementSummaryResponse.java @@ -18,12 +18,12 @@ public record HQSettlementSummaryResponse( @Schema(description = "배달비 수익", example = "5000000") BigDecimal deliveryFee, @Schema(description = "반품 차감액", example = "2000000") BigDecimal refundAmount, - - @Schema(description = "본사 손실", example = "500000") BigDecimal lossAmount) { + @Schema(description = "본사 손실", example = "500000") BigDecimal lossAmount, + @Schema(description = "기타 조정", example = "100000") BigDecimal adjustmentAmount) { public static HQSettlementSummaryResponse of(BigDecimal finalAmount, BigDecimal orderAmount, BigDecimal saleAmount, BigDecimal commissionFee, BigDecimal deliveryFee, BigDecimal refundAmount, - BigDecimal lossAmount) { + BigDecimal lossAmount, BigDecimal adjustmentAmount) { return HQSettlementSummaryResponse.builder() .finalAmount(finalAmount) .orderAmount(orderAmount) @@ -32,6 +32,7 @@ public static HQSettlementSummaryResponse of(BigDecimal finalAmount, BigDecimal .deliveryFee(deliveryFee) .refundAmount(refundAmount) .lossAmount(lossAmount) + .adjustmentAmount(adjustmentAmount) .build(); } } diff --git a/module-app/app-api/src/main/java/com/chaing/api/dto/hq/user/request/UserSearchRequest.java b/module-app/app-api/src/main/java/com/chaing/api/dto/hq/user/request/UserSearchRequest.java index 699d7066a..2d2a6c6dd 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/dto/hq/user/request/UserSearchRequest.java +++ b/module-app/app-api/src/main/java/com/chaing/api/dto/hq/user/request/UserSearchRequest.java @@ -5,6 +5,8 @@ import com.chaing.domain.users.enums.UserRole; import com.chaing.domain.users.enums.UserStatus; +import java.util.List; + public record UserSearchRequest( String loginId, @@ -13,9 +15,10 @@ public record UserSearchRequest( UserRole role, UserPosition position, UserStatus status, - Long businessUnitId + Long businessUnitId, + String orgName ) { - public UserSearchCondition toCondition() { + public UserSearchCondition toCondition(java.util.List hqIds, java.util.List franchiseIds, List factoryIds) { return new UserSearchCondition( loginId, username, @@ -23,7 +26,10 @@ public UserSearchCondition toCondition() { role, position, status, - businessUnitId + businessUnitId, + hqIds, + franchiseIds, + factoryIds ); } } diff --git a/module-app/app-api/src/main/java/com/chaing/api/dto/outbound/request/OutboundAssignRequest.java b/module-app/app-api/src/main/java/com/chaing/api/dto/outbound/request/OutboundAssignRequest.java index 40b2363e4..b3c2b3c50 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/dto/outbound/request/OutboundAssignRequest.java +++ b/module-app/app-api/src/main/java/com/chaing/api/dto/outbound/request/OutboundAssignRequest.java @@ -2,11 +2,13 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; import java.util.List; public record OutboundAssignRequest( @NotBlank String boxCode, + @NotNull Long orderItemId, @NotEmpty List<@NotBlank String> serialCodes ) { } diff --git a/module-app/app-api/src/main/java/com/chaing/api/dto/outbound/request/OutboundStatusUpdateRequest.java b/module-app/app-api/src/main/java/com/chaing/api/dto/outbound/request/OutboundStatusUpdateRequest.java new file mode 100644 index 000000000..2c9681399 --- /dev/null +++ b/module-app/app-api/src/main/java/com/chaing/api/dto/outbound/request/OutboundStatusUpdateRequest.java @@ -0,0 +1,11 @@ +package com.chaing.api.dto.outbound.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; + +import java.util.List; + +public record OutboundStatusUpdateRequest( + @NotEmpty List<@NotBlank String> serialCodes +) { +} diff --git a/module-app/app-api/src/main/java/com/chaing/api/dto/outbound/request/OutboundUpdateRequest.java b/module-app/app-api/src/main/java/com/chaing/api/dto/outbound/request/OutboundUpdateRequest.java index 4f2225e78..d548f581b 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/dto/outbound/request/OutboundUpdateRequest.java +++ b/module-app/app-api/src/main/java/com/chaing/api/dto/outbound/request/OutboundUpdateRequest.java @@ -1,12 +1,20 @@ package com.chaing.api.dto.outbound.request; +import com.chaing.domain.inventories.exception.InventoriesErrorCode; +import com.chaing.domain.inventories.exception.InventoriesException; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.time.LocalDate; import java.util.List; public record OutboundUpdateRequest( - @NotEmpty(message = "선택된 제품이 존재하지 않습니다.") - List<@NotBlank String> serialCodes + @NotBlank String serialCode, + @NotNull Long productId, + @NotNull LocalDate manufactureDate ) { + public List toSerialCodeList() { + return List.of(this.serialCode); + } } diff --git a/module-app/app-api/src/main/java/com/chaing/api/exception/GlobalExceptionHandler.java b/module-app/app-api/src/main/java/com/chaing/api/exception/GlobalExceptionHandler.java index 791cbf75a..32aac69b8 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/exception/GlobalExceptionHandler.java +++ b/module-app/app-api/src/main/java/com/chaing/api/exception/GlobalExceptionHandler.java @@ -23,6 +23,24 @@ public ResponseEntity handleGlobalException(GlobalException e) { .body(ErrorResponse.from(e.getErrorCode())); } + @ExceptionHandler(org.springframework.web.bind.MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(org.springframework.web.bind.MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(fieldError -> String.format("[%s]: %s", fieldError.getField(), fieldError.getDefaultMessage())) + .collect(java.util.stream.Collectors.joining(", ")); + + log.warn("Validation failed: {}", message); + + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(ErrorResponse.builder() + .timestamp(LocalDateTime.now()) + .status(400) + .code("INVALID_INPUT") + .message(message) + .build()); + } + @ExceptionHandler({OptimisticLockException.class, ObjectOptimisticLockingFailureException.class}) public ResponseEntity handleOptimisticLock(Exception e) { log.warn("Optimistic Lock Conflict occurred: {}", e.getMessage(), e); diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/factory/HQOrderFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/factory/HQOrderFacade.java index fb973b091..5ed956159 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/factory/HQOrderFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/factory/HQOrderFacade.java @@ -8,6 +8,7 @@ import com.chaing.domain.orders.dto.command.FranchiseOrderCommand; import com.chaing.domain.orders.dto.command.HQOrderCancelCommand; import com.chaing.domain.orders.dto.response.HQOrderItemProjection; +import com.chaing.domain.orders.dto.response.HQOrderPossibleResponse; import com.chaing.domain.orders.dto.response.HQRequestedOrderItemProjection; import com.chaing.domain.orders.dto.command.FranchiseOrderDetailCommand; import com.chaing.domain.orders.dto.command.FranchiseOrderItemCommand; @@ -17,7 +18,6 @@ import com.chaing.core.dto.info.ProductInfo; import com.chaing.domain.orders.dto.info.HQOrderCommand; import com.chaing.domain.orders.dto.info.HQOrderItemCommand; -import com.chaing.domain.orders.dto.request.HQOrderItemCreateCommand; import com.chaing.domain.orders.dto.request.HQOrderUpdateRequest; import com.chaing.domain.orders.dto.request.HQOrderUpdateStatusRequest; import com.chaing.domain.orders.dto.response.FranchiseOrderStatusShippingPendingResponse; @@ -55,12 +55,13 @@ import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -import java.math.BigDecimal; import java.time.Duration; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.stream.Collectors; @@ -186,9 +187,6 @@ public List getAllOrders() { .username(username) .phoneNumber(phoneNumber) .requestedDate(order.requestedDate()) - .manufacturedDate(order.manufacturedDate()) - .storedDate(order.storedDate()) - .productCode(productInfo.productCode()) .build(); }); }) @@ -197,7 +195,7 @@ public List getAllOrders() { return result; } - // 발주 페이지네이션 조회 (아이템 행 단위) + // 발주 페이지네이션 조회 (주문 단위) public Page getAllOrdersPaged(Pageable pageable) { String cacheKey = "ord:hq:page:%s".formatted(pageableKey(pageable)); Page cached = readPageCache(cacheKey, HQOrderResponse.class, pageable); @@ -221,23 +219,15 @@ public Page getAllOrdersPaged(Pageable pageable) { .collect(Collectors.toMap(Function.identity(), userManagementService::getPhoneNumberByUserId)); - // productId → productCode 매핑 - List productIds = page.getContent().stream() - .map(HQOrderItemProjection::productId).distinct().toList(); - Map productInfoMap = productService.getProductInfos(productIds); - List content = page.getContent().stream() .map(p -> HQOrderResponse.builder() .orderCode(p.orderCode()) .status(p.status()) - .quantity(p.quantity()) + .quantity(p.totalQuantity()) .username(usernameByUserId.get(p.userId())) .phoneNumber(phoneNumberByUserId.get(p.userId())) .requestedDate(p.requestedDate()) - .manufacturedDate(p.manufacturedDate()) - .storedDate(p.storedDate()) - .productCode(productInfoMap.containsKey(p.productId()) - ? productInfoMap.get(p.productId()).productCode() : null) + .totalPrice(p.totalAmount()) .build()) .toList(); @@ -761,4 +751,75 @@ private void writePageCache(String key, Page page) { } catch (Exception ignored) { } } + + // 가맹점 발주 접수 가불 판단 + public List isPossible(List orderCodes) { + // 캐싱 적용 + + // Map + Map orderByOrderId = franchiseOrderService.getOrdersByOrderCode(orderCodes); + log.info("orderByOrderId={}", orderByOrderId); + + // List + List orderIds = orderByOrderId.keySet().stream().toList(); + + // Map> + Map> orderItemsByOrderId = franchiseOrderService.getOrderItemsByOrderIds(orderIds); + log.info("orderItemsByOrderId={}", orderItemsByOrderId); + + // List + List productIds = orderItemsByOrderId.values().stream() + .flatMap(List::stream) + .map(FranchiseOrderItemCommand::productId) + .toList(); + + // Map + Map facInventoryQuantityByProductId = inventoryService.getFactoryInventoryByProductId(productIds); + + // 반환 + return orderItemsByOrderId.entrySet().stream() + .map(entry -> { + Long orderId = entry.getKey(); + FranchiseOrderDetailCommand orderDetailCommand = orderByOrderId.get(orderId); + List items = entry.getValue(); + log.info("orderDetailCommand={}", orderDetailCommand); + log.info("items={}", items); + + if (orderDetailCommand == null) { + throw new OrderException(OrderErrorCode.ORDER_NOT_FOUND); + } + + if (items == null || items.isEmpty()) { + throw new OrderException(OrderErrorCode.ORDER_ITEM_NOT_FOUND); + } + + // Map + Map orderQuantityByProductId = items.stream() + .collect(Collectors.toMap( + FranchiseOrderItemCommand::productId, + FranchiseOrderItemCommand::quantity + )); + + AtomicReference isPossible = new AtomicReference<>(true); + + orderQuantityByProductId.forEach((productId, orderQuantity) -> { + Long inventoryQuantity = facInventoryQuantityByProductId.get(productId); + + if (orderQuantity == null || inventoryQuantity == null) { + throw new OrderException(OrderErrorCode.ORDER_NOT_FOUND); + } + log.info("orderQuantity={}, inventoryQuantity={}", orderQuantity, inventoryQuantity); + + if (orderQuantity > inventoryQuantity) { + isPossible.set(false); + } + }); + + return HQOrderPossibleResponse.builder() + .orderCode(orderDetailCommand.orderCode()) + .isPossible(isPossible.get()) + .build(); + }) + .toList(); + } } diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseInventoryFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseInventoryFacade.java index b059efb27..58df63e74 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseInventoryFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseInventoryFacade.java @@ -6,6 +6,8 @@ import com.chaing.api.facade.notification.NotificationFacade; import com.chaing.core.dto.info.ProductInfo; import com.chaing.core.enums.LogType; +import com.chaing.domain.businessunits.entity.Franchise; +import com.chaing.domain.businessunits.repository.FranchiseRepository; import com.chaing.domain.inventories.dto.request.DisposalRequest; import com.chaing.domain.inventories.dto.request.FranchiseInventoryItemsRequest; import com.chaing.domain.inventories.dto.request.InventoryBatchRequest; @@ -57,6 +59,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.UUID; @RequiredArgsConstructor @Service @@ -71,6 +74,7 @@ public class FranchiseInventoryFacade { private final ProductService productService; private final InventoryLogService inventoryLogService; private final UserManagementService userManagementService; + private final FranchiseRepository franchiseRepository; private final RedisCacheHelper redisCacheHelper; private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; @@ -271,6 +275,7 @@ public List convert(InventoryBatchRequest request) { @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) public Void disposalInventory(DisposalRequest request, Long locationId) { String actorTypeRaw = request.actorType().toUpperCase(); + String disposalTransactionCode = "DISP-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); List logs = new ArrayList<>(); List expandedIds = inventoryService.expandInventoryIdsByBoxCode( @@ -295,7 +300,7 @@ public Void disposalInventory(DisposalRequest request, Long locationId) { inv.getProductId(), pInfo != null ? pInfo.productName() : "Unknown", inv.getBoxCode(), - null, + disposalTransactionCode, LogType.DISPOSAL, 1, LocationType.FRANCHISE, @@ -335,10 +340,6 @@ public void resetSafetyStock(Long franchiseId, Long productId) { publishFranchiseStockAlert(franchiseId); } - public boolean verifyAdminPassword(Long userId, String password) { - return userManagementService.verifyPassword(userId, password); - } - private String nullToDash(String value) { return value == null ? "-" : value; } @@ -354,7 +355,7 @@ private void publishFranchiseStockAlert(Long franchiseId) { int lowStockCount = safetyAlerts.size(); int expirationCount = expirationAlerts.size(); - String message = "[재고 알림] 가맹점(" + franchiseId + ") - 안전재고 부족 " + String message = "[재고 알림] 가맹점(" + resolveFranchiseName(franchiseId) + ") - 안전재고 부족 " + lowStockCount + "건, 유통기한 임박 " + expirationCount + "건"; for (Long userId : recipientUserIds) { @@ -374,6 +375,16 @@ private void publishFranchiseStockAlert(Long franchiseId) { } } + private String resolveFranchiseName(Long franchiseId) { + if (franchiseId == null) { + return "-"; + } + + return franchiseRepository.findByFranchiseIdAndDeletedAtIsNull(franchiseId) + .map(Franchise::getName) + .orElse("ID:" + franchiseId); + } + private List readListCache(String key, TypeReference> typeRef) { try { String cached = redisTemplate.opsForValue().get(key); diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseOrderFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseOrderFacade.java index 95ed6516a..d08c7cb66 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseOrderFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseOrderFacade.java @@ -13,6 +13,7 @@ import com.chaing.domain.orders.dto.request.FranchiseOrderCreateRequest; import com.chaing.core.dto.request.FranchiseOrderUpdateRequest; import com.chaing.domain.orders.dto.response.FranchiseOrderCancelResponse; +import com.chaing.domain.orders.dto.response.FranchiseOrderCreateInfoResponse; import com.chaing.domain.orders.dto.response.FranchiseOrderCreateResponse; import com.chaing.domain.orders.dto.response.FranchiseOrderDetailResponse; import com.chaing.domain.orders.dto.response.FranchiseOrderItemDetailResponse; @@ -410,7 +411,7 @@ public FranchiseOrderCreateResponse createOrder(Long userId, FranchiseOrderCreat Long franchiseId = userManagementService.getFranchiseIdByUserId(userId); // franchiseCode - String franchiseCode = franchiseService.getById(franchiseId).businessNumber(); + String franchiseCode = franchiseService.getById(franchiseId).code(); log.info("franchiseId: {}", franchiseId); log.info("userId: {}", userId); @@ -508,4 +509,25 @@ private void writePageCache(String key, Page page) { } catch (Exception ignored) { } } + + // 가맹점주 이름, 전화번호, 주소 반환 + public FranchiseOrderCreateInfoResponse getUserInfo(Long userId) { + // franchiseId + Long franchiseId = userManagementService.getFranchiseIdByUserId(userId); + + // username + String username = userManagementService.getUsernameByUserId(userId); + + // phoneNumber + String phoneNumber = userManagementService.getPhoneNumberByUserId(userId); + + // address + String address = franchiseService.getById(franchiseId).address(); + + return FranchiseOrderCreateInfoResponse.builder() + .username(username) + .phoneNumber(phoneNumber) + .address(address) + .build(); + } } \ No newline at end of file diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseReturnFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseReturnFacade.java index 6cd977545..411604fd4 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseReturnFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseReturnFacade.java @@ -48,6 +48,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.util.Collections; import java.math.BigDecimal; import java.time.Duration; @@ -88,6 +89,10 @@ public List getAllReturns(Long userId) { Map returns = franchiseReturnService.getAllReturns(franchiseId); log.info("returns: {}", returns); + if (returns.isEmpty()) { + return Collections.emptyList(); + } + // List List returnIds = returns.keySet().stream().toList(); diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseSettlementFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseSettlementFacade.java index 964b9f8d4..5f90f56cc 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseSettlementFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/franchise/FranchiseSettlementFacade.java @@ -1,6 +1,6 @@ package com.chaing.api.facade.franchise; -import com.chaing.api.dto.franchise.settlement.response.FranchiseDailyGraphResponse; + import com.chaing.api.dto.franchise.settlement.response.FranchiseOrderItemResponse; import com.chaing.api.dto.franchise.settlement.response.FranchiseSalesItemResponse; import com.chaing.api.dto.franchise.settlement.response.FranchiseSettlementSummaryResponse; @@ -10,10 +10,8 @@ import com.chaing.domain.orders.enums.FranchiseOrderStatus; import com.chaing.domain.orders.repository.FranchiseOrderItemRepository; import com.chaing.domain.orders.repository.FranchiseOrderRepository; -import com.chaing.domain.sales.entity.Sales; import com.chaing.domain.sales.entity.SalesItem; import com.chaing.domain.sales.repository.FranchiseSalesItemRepository; -import com.chaing.domain.sales.repository.FranchiseSalesRepository; import com.chaing.domain.settlements.entity.DailyReceiptLine; import com.chaing.domain.settlements.entity.DailySettlementReceipt; import com.chaing.domain.settlements.entity.MonthlySettlement; @@ -22,17 +20,34 @@ import com.chaing.domain.settlements.enums.PeriodType; import com.chaing.domain.settlements.enums.SettlementStatus; import com.chaing.domain.settlements.enums.VoucherType; +import com.chaing.domain.returns.entity.Returns; +import com.chaing.domain.returns.enums.ReturnStatus; +import com.chaing.domain.returns.enums.ReturnType; +import com.chaing.domain.returns.entity.ReturnItem; +import com.chaing.domain.returns.repository.FranchiseReturnItemRepository; +import com.chaing.domain.returns.repository.FranchiseReturnRepository; import com.chaing.domain.settlements.service.DailySettlementService; import com.chaing.domain.settlements.service.MonthlySettlementService; +import com.chaing.domain.businessunits.repository.FranchiseRepository; +import com.chaing.domain.businessunits.entity.Franchise; import com.chaing.domain.settlements.service.SettlementDocumentService; import com.chaing.domain.settlements.exception.SettlementErrorCode; import com.chaing.domain.settlements.exception.SettlementException; +import com.chaing.domain.settlements.repository.interfaces.SettlementAdjustmentRepository; import com.chaing.domain.settlements.repository.interfaces.SettlementVoucherRepository; import com.chaing.domain.settlements.entity.SettlementVoucher; +import com.chaing.domain.products.entity.Product; +import com.chaing.domain.products.repository.ProductRepository; +import com.chaing.domain.settlements.entity.SettlementDocument; import com.chaing.domain.settlements.service.SettlementFileService; import com.chaing.core.enums.BucketName; import com.chaing.core.service.MinioService; -import com.chaing.domain.settlements.entity.SettlementDocument; +import com.chaing.domain.transports.dto.DeliveryFeeInfo; +import com.chaing.domain.transports.dto.OrderInfo; +import com.chaing.domain.transports.entity.Transit; +import com.chaing.domain.transports.enums.DeliverStatus; +import com.chaing.domain.transports.repository.TransitRepository; +import com.chaing.domain.transports.service.InternalTransportService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -42,13 +57,14 @@ import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; import java.time.YearMonth; import java.util.ArrayList; -import java.util.Comparator; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.stream.Collectors; +import java.time.LocalTime; +import com.chaing.api.dto.franchise.settlement.response.FranchiseDailyGraphResponse; @Slf4j @Service @@ -63,36 +79,75 @@ public class FranchiseSettlementFacade { private final SettlementVoucherRepository voucherRepository; // 다른 도메인 Repository (메서드 추가 요청 후 사용) - private final FranchiseSalesRepository salesRepository; private final FranchiseSalesItemRepository salesItemRepository; private final FranchiseOrderRepository orderRepository; private final FranchiseOrderItemRepository orderItemRepository; + private final FranchiseReturnRepository returnRepository; + private final FranchiseReturnItemRepository returnItemRepository; + private final ProductRepository productRepository; + + // 배송 정보 조회를 위한 Repository 및 Service 주입 + private final TransitRepository transitRepository; + private final InternalTransportService transportService; + private final FranchiseRepository franchiseRepository; + private final SettlementAdjustmentRepository settlementAdjustmentRepository; // 일별 정산 요약 @Transactional(readOnly = true) public FranchiseSettlementSummaryResponse getDailySummary(Long franchiseId, LocalDate date) { - Optional receipt = dailyService.findByFranchiseAndDate(franchiseId, date); - // 데이터가 없으면 실시간 가집계 시도 - return toSummary(receipt.orElseGet(() -> aggregateDailySettlement(franchiseId, date))); + DailySettlementReceipt receipt = getInternalDailyAggregation(franchiseId, date).receipt(); + String name = franchiseRepository.findById(franchiseId).map(Franchise::getName).orElse("Unknown"); + return toSummary(name, receipt); + } + + // 내부 합산용 일별 정산 조회 (실시간 또는 DB) + private AggregationResult getInternalDailyAggregation(Long franchiseId, LocalDate date) { + // 당일 요청인 경우, DB에 저장된 예전 기록(있을 경우)보다 실시간 집계를 우선함 + if (date.equals(LocalDate.now())) { + log.info("[DEBUG] Requesting today's settlement for franchiseId: {}. Performing real-time aggregation.", + franchiseId); + return aggregateDailySettlement(franchiseId, date); + } + + DailySettlementReceipt receipt = dailyService.findByFranchiseAndDate(franchiseId, date).orElse(null); + + if (receipt == null) { + log.info("[DEBUG] Daily receipt not found in DB for franchiseId: {}, date: {}. Aggregating on the fly.", + franchiseId, date); + return aggregateDailySettlement(franchiseId, date); + } + + // 이미 DB에 기록이 있더라도 매출액이나 발주금액이 0이거나, 수수료가 집계되지 않은 경우 실시간으로 다시 집계함 + if (receipt.getTotalSaleAmount().compareTo(BigDecimal.ZERO) == 0 || + (receipt.getTotalSaleAmount().compareTo(BigDecimal.ZERO) > 0 + && receipt.getCommissionFee().compareTo(BigDecimal.ZERO) == 0)) { + return aggregateDailySettlement(franchiseId, date); + } + + // DB 기록이 있으면 해당 기록과 연관된 전표 라인들을 로드하여 반환 + return new AggregationResult(receipt, dailyService.getAllReceiptLines(receipt.getDailyReceiptId())); } // 일별 매출 top5, 전체 @Transactional(readOnly = true) public List getDailySalesItems( Long franchiseId, LocalDate date, Integer limit) { - // Sales에서 해당 날짜 판매 목록 가져오기 - List salesList = salesRepository - .findAllByFranchiseIdAndIsCanceledFalseAndCreatedAtBetween( - franchiseId, - date.atStartOfDay(), - date.atTime(23, 59, 59)); - // SalesItem 가져오기 - List salesIds = salesList.stream() - .map(Sales::getSalesId).toList(); - if (salesIds.isEmpty()) { - return List.of(); - } - List items = salesItemRepository.findAllBySalesSalesIdIn(salesIds); + log.info("[DEBUG] getDailySalesItems - franchiseId: {}, date: {}", + franchiseId, date); + + LocalDateTime start = date.atStartOfDay(); + LocalDateTime end = date.atTime(LocalTime.MAX); + + // DB 레벨 필터링 사용 + List items = salesItemRepository.findAllBySalesFranchiseIdAndCreatedAtBetween(franchiseId, start, end) + .stream() + .filter(item -> { + Boolean canceled = item.getSales().getIsCanceled(); + return canceled == null || !canceled; + }) + .toList(); + + log.info("[DEBUG] getDailySalesItems - found items count: {}", items.size()); // 상품별 집계 (상품명 기준 그룹핑) return aggregateSalesItems(items, limit); @@ -102,19 +157,22 @@ public List getDailySalesItems( @Transactional(readOnly = true) public List getDailyOrderItems( Long franchiseId, LocalDate date, Integer limit) { - // Orders에서 해당 날짜 발주 목록 (ACCEPTED만) - List orders = orderRepository - .findAllByFranchiseIdAndOrderStatusAndCreatedAtBetween( - franchiseId, - FranchiseOrderStatus.ACCEPTED, - date.atStartOfDay(), - date.atTime(23, 59, 59)); + List validStatuses = List.of( + FranchiseOrderStatus.PENDING, + FranchiseOrderStatus.ACCEPTED, + FranchiseOrderStatus.PARTIAL, + FranchiseOrderStatus.SHIPPING_PENDING, + FranchiseOrderStatus.SHIPPING, + FranchiseOrderStatus.COMPLETED); + + // DB 레벨 조회가 아닌, 전체 데이터를 가져와 toLocalDate()로 필터링 (정합성 보장) + LocalDateTime start = date.atStartOfDay(); + LocalDateTime end = date.atTime(LocalTime.MAX); + List orders = orderRepository.findAllByFranchiseIdAndOrderStatusInAndCreatedAtBetween( + franchiseId, validStatuses, start, end); // OrderItem 가져오기 List orderIds = orders.stream() .map(FranchiseOrder::getFranchiseOrderId).toList(); - if (orderIds.isEmpty()) { - return List.of(); - } List items = orderItemRepository .findAllByFranchiseOrderFranchiseOrderIdIn(orderIds); // 상품별 집계 @@ -124,41 +182,80 @@ public List getDailyOrderItems( // 월별 정산 요약 @Transactional(readOnly = true) public FranchiseSettlementSummaryResponse getMonthlySummary(Long franchiseId, YearMonth month) { - Optional settlementOpt = monthlyService.findByFranchiseAndMonth(franchiseId, month); - MonthlySettlement s = settlementOpt.orElseGet(() -> { - // 데이터가 없으면 일별 정산 기반 가집계 - List dailyReceipts = dailyService.getAllByFranchiseAndDateRange( - franchiseId, month.atDay(1), month.atEndOfMonth()); - return aggregateMonthlySettlement(franchiseId, month, dailyReceipts); - }); - return new FranchiseSettlementSummaryResponse( - s.getFinalSettlementAmount(), - s.getTotalSaleAmount(), - s.getRefundAmount(), - s.getOrderAmount(), - s.getDeliveryFee(), - s.getLossAmount(), - s.getCommissionFee(), - s.getAdjustmentAmount()); + java.util.Optional settlementOpt = monthlyService.findByFranchiseAndMonth( + franchiseId, + month); + + if (settlementOpt.isPresent()) { + MonthlySettlement s = settlementOpt.get(); + String name = franchiseRepository.findById(franchiseId).map(Franchise::getName).orElse("Unknown"); + return new FranchiseSettlementSummaryResponse( + name, + s.getFinalSettlementAmount(), + s.getTotalSaleAmount(), + s.getRefundAmount(), + s.getOrderAmount(), + s.getDeliveryFee(), + s.getLossAmount(), + s.getCommissionFee(), + s.getAdjustmentAmount()); + } + + log.info("[DEBUG] Monthly settlement not found for franchiseId: {}, month: {}. Aggregating on the fly.", + franchiseId, month); + // 해당 월의 모든 날짜(1일~말일 또는 오늘까지)에 대해 일별 정산 가집계 수행 + LocalDate start = month.atDay(1); + LocalDate today = LocalDate.now(); + YearMonth currentMonth = YearMonth.now(); + LocalDate end; + + if (month.equals(currentMonth)) { + end = today; + } else { + end = month.atEndOfMonth(); + } + + List dailyReceipts = new ArrayList<>(); + for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) { + try { + // getDailySummary 내부 로직과 유사하게 가져오되, Receipt 객체로 합산 + dailyReceipts.add(getInternalDailyAggregation(franchiseId, date).receipt()); + } catch (Exception ex) { + log.warn("[DEBUG] Skip aggregation for date {} due to: {}", date, + ex.getMessage()); + } + } + + if (dailyReceipts.isEmpty()) { + // 데이터가 아예 없는 경우 기본 응답 (0원) 반환하거나 예외 처리 + String name = franchiseRepository.findById(franchiseId).map(Franchise::getName).orElse("Unknown"); + return new FranchiseSettlementSummaryResponse( + name, + BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, + BigDecimal.ZERO, BigDecimal.ZERO, BigDecimal.ZERO, + BigDecimal.ZERO, BigDecimal.ZERO); + } + + String name = franchiseRepository.findById(franchiseId).map(Franchise::getName).orElse("Unknown"); + return toSummary(name, aggregateMonthlySettlement(franchiseId, month, dailyReceipts)); } // 월별 매출 현황 top5, 전체 @Transactional(readOnly = true) public List getMonthlySalesItems( Long franchiseId, YearMonth month, Integer limit) { - LocalDate start = month.atDay(1); - LocalDate end = month.atEndOfMonth(); - List salesList = salesRepository - .findAllByFranchiseIdAndIsCanceledFalseAndCreatedAtBetween( - franchiseId, - start.atStartOfDay(), - end.atTime(23, 59, 59)); - List salesIds = salesList.stream() - .map(Sales::getSalesId).toList(); - if (salesIds.isEmpty()) { - return List.of(); - } - List items = salesItemRepository.findAllBySalesSalesIdIn(salesIds); + LocalDateTime start = month.atDay(1).atStartOfDay(); + LocalDateTime end = month.atEndOfMonth().atTime(LocalTime.MAX); + + // DB 레벨 필터링 사용 + List items = salesItemRepository.findAllBySalesFranchiseIdAndCreatedAtBetween(franchiseId, start, end) + .stream() + .filter(item -> { + Boolean canceled = item.getSales().getIsCanceled(); + return canceled == null || !canceled; + }) + .toList(); + return aggregateSalesItems(items, limit); } @@ -168,50 +265,88 @@ public List getMonthlyOrderItems( Long franchiseId, YearMonth month, Integer limit) { LocalDate start = month.atDay(1); LocalDate end = month.atEndOfMonth(); + List validStatuses = List.of( + FranchiseOrderStatus.PENDING, + FranchiseOrderStatus.ACCEPTED, + FranchiseOrderStatus.PARTIAL, + FranchiseOrderStatus.SHIPPING_PENDING, + FranchiseOrderStatus.SHIPPING, + FranchiseOrderStatus.COMPLETED); + List orders = orderRepository - .findAllByFranchiseIdAndOrderStatusAndCreatedAtBetween( + .findAllByFranchiseIdAndOrderStatusInAndCreatedAtBetween( franchiseId, - FranchiseOrderStatus.ACCEPTED, + validStatuses, start.atStartOfDay(), - end.atTime(23, 59, 59)); + end.atTime(LocalTime.MAX)); List orderIds = orders.stream() .map(FranchiseOrder::getFranchiseOrderId).toList(); - if (orderIds.isEmpty()) { - return List.of(); - } List items = orderItemRepository .findAllByFranchiseOrderFranchiseOrderIdIn(orderIds); return aggregateOrderItems(items, limit); } - // 월별 일자 매출 추이 그래프 - @Transactional(readOnly = true) - public List getMonthlyDailyGraph( - Long franchiseId, LocalDate start, LocalDate end) { - List receipts = dailyService.getAllByFranchiseAndDateRange(franchiseId, start, - end); - return receipts.stream() - .map(r -> new FranchiseDailyGraphResponse( - r.getSettlementDate(), - r.getTotalSaleAmount())) - .sorted(Comparator.comparing(FranchiseDailyGraphResponse::date)) - .toList(); - } - // 전ㅍ 상세 목록 (일, 월) @Transactional(readOnly = true) public Page getVouchers( Long franchiseId, PeriodType period, LocalDate date, YearMonth month, VoucherType type, Pageable pageable) { + log.info("[DEBUG] getVouchers - franchiseId={}, period={}, date={}, month={}", + franchiseId, period, date, month); if (period == PeriodType.DAILY) { - DailySettlementReceipt receipt = dailyService.getByFranchiseAndDate(franchiseId, date); - Page lines = dailyService.getReceiptLines(receipt.getDailyReceiptId(), type, - pageable); - return lines.map(this::toVoucherResponse); + DailySettlementReceipt receipt = dailyService.findByFranchiseAndDate(franchiseId, date) + .orElse(null); + + // 과거 데이터인 경우 DB에서 조회 + if (receipt != null && !date.equals(LocalDate.now())) { + Page lines = dailyService.getReceiptLines( + receipt.getDailyReceiptId(), + type, + pageable); + return lines.map(this::toVoucherResponse); + } + // 오늘 데이터거나 DB에 없으면 실시간 가집계 + return getProvisionalVouchers(franchiseId, date, type, pageable); } else { - // MONTHLY → SettlementVoucher 조회 - MonthlySettlement settlement = monthlyService.getByFranchiseAndMonth(franchiseId, month); + // MONTHLY → 당월인 경우 실시간 합산, 과거인 경우 SettlementVoucher 조회 + YearMonth currentMonth = YearMonth.now(); + if (month.equals(currentMonth)) { + log.info("[DEBUG] getVouchers (MONTHLY) - Current month detected. Aggregating range-based vouchers."); + LocalDate start = month.atDay(1); + LocalDate end = LocalDate.now(); + + List allVouchers = aggregateVouchersInRange( + franchiseId, start, end, type); + + // 최신순 정렬 + allVouchers.sort((a, b) -> { + if (a.occurredAt() == null || b.occurredAt() == null) + return 0; + return b.occurredAt().compareTo(a.occurredAt()); + }); + + // 페이징 + int startIdx = (int) pageable.getOffset(); + int endIdx = Math.min((startIdx + pageable.getPageSize()), allVouchers.size()); + + if (startIdx >= allVouchers.size()) { + return new org.springframework.data.domain.PageImpl<>(new ArrayList<>(), + pageable, + allVouchers.size()); + } + return new org.springframework.data.domain.PageImpl<>( + allVouchers.subList(startIdx, endIdx), pageable, allVouchers.size()); + } + + // 과거 데이터 (DB 조회) + java.util.Optional settlementOpt = monthlyService.findByFranchiseAndMonth( + franchiseId, + month); + if (settlementOpt.isEmpty()) { + return Page.empty(pageable); + } + MonthlySettlement settlement = settlementOpt.get(); Page vouchers; if (type != null) { vouchers = voucherRepository.findAllByMonthlySettlementIdAndVoucherType( @@ -223,12 +358,192 @@ public Page getVouchers( return vouchers.map(this::toVoucherResponse); } } + + /** + * 특정 기간 동안의 전표 내역을 통합 집계함 (실시간) + */ + private List aggregateVouchersInRange( + Long franchiseId, LocalDate start, LocalDate end, VoucherType filterType) { + List vouchers = new ArrayList<>(); + + // 1. 매출 (SALES) + if (filterType == null || filterType == VoucherType.SALES) { + List salesItems = salesItemRepository.findAllBySalesFranchiseIdAndCreatedAtBetween( + franchiseId, start.atStartOfDay(), end.atTime(LocalTime.MAX)) + .stream() + .filter(item -> { + Boolean canceled = item.getSales().getIsCanceled(); + return canceled == null || !canceled; + }) + .toList(); + for (SalesItem item : salesItems) { + vouchers.add(new FranchiseVoucherResponse( + item.getSales().getSalesCode(), + VoucherType.SALES, + item.getProductName() + " (판매)", + item.getQuantity(), + item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())), + item.getCreatedAt())); + } + } + + // 2. 발주 (ORDER) + if (filterType == null || filterType == VoucherType.ORDER) { + List validStatuses = List.of( + FranchiseOrderStatus.PENDING, FranchiseOrderStatus.ACCEPTED, + FranchiseOrderStatus.PARTIAL, FranchiseOrderStatus.SHIPPING_PENDING, + FranchiseOrderStatus.SHIPPING, FranchiseOrderStatus.COMPLETED); + + List orders = orderRepository.findAllByFranchiseIdAndOrderStatusInAndCreatedAtBetween( + franchiseId, validStatuses, start.atStartOfDay(), end.atTime(LocalTime.MAX)); + + if (!orders.isEmpty()) { + List orderIds = orders.stream().map(FranchiseOrder::getFranchiseOrderId).toList(); + List orderItems = orderItemRepository + .findAllByFranchiseOrder_FranchiseOrderIdInAndDeletedAtIsNull(orderIds); + + List productIds = orderItems.stream().map(FranchiseOrderItem::getProductId) + .distinct().toList(); + Map productNames = productRepository.findAllByProductIdIn(productIds) + .stream() + .collect(Collectors.toMap(Product::getProductId, Product::getName)); + + for (FranchiseOrderItem item : orderItems) { + String name = productNames.getOrDefault(item.getProductId(), "알 수 없는 상품"); + vouchers.add(new FranchiseVoucherResponse( + item.getFranchiseOrder().getOrderCode(), + VoucherType.ORDER, + name + " (발주)", + item.getQuantity(), + item.getTotalPrice(), + item.getCreatedAt())); + } + } + } + + // 3. 반품 (REFUND / LOSS) + if (filterType == null || filterType == VoucherType.REFUND || filterType == VoucherType.LOSS) { + List validReturnStatuses = List.of( + ReturnStatus.PENDING, ReturnStatus.ACCEPTED, ReturnStatus.SHIPPING_PENDING, + ReturnStatus.SHIPPING, ReturnStatus.COMPLETED, ReturnStatus.INSPECTING, + ReturnStatus.DEDUCTION_COMPLETED); + + List returnsList = returnRepository.findAllByFranchiseIdAndDeletedAtIsNull(franchiseId) + .stream() + .filter(ret -> { + LocalDateTime createdAt = ret.getCreatedAt(); + if (createdAt == null) + return false; + LocalDate d = createdAt.toLocalDate(); + return !d.isBefore(start) && !d.isAfter(end); + }) + .filter(ret -> validReturnStatuses.contains(ret.getReturnStatus())) + .toList(); + + for (Returns ret : returnsList) { + List items = returnItemRepository + .findAllByReturns_ReturnIdInAndDeletedAtIsNull( + List.of(ret.getReturnId())); + BigDecimal returnTotal = BigDecimal.ZERO; + for (ReturnItem item : items) { + FranchiseOrderItem orderItem = orderItemRepository + .findById(item.getFranchiseOrderItemId()).orElse(null); + if (orderItem != null) + returnTotal = returnTotal.add(orderItem.getUnitPrice()); + } + + VoucherType type = (ret.getReturnType() == ReturnType.PRODUCT_DEFECT) + ? VoucherType.REFUND + : VoucherType.LOSS; + if (filterType == null || filterType == type) { + vouchers.add(new FranchiseVoucherResponse( + ret.getReturnCode(), + type, + ret.getReturnCode() + (type == VoucherType.REFUND ? " (상품하자 반품)" + : " (오발주 손실)"), + null, + returnTotal, + ret.getCreatedAt())); + } + } + } + + // 4. 배송비 (DELIVERY) + if (filterType == null || filterType == VoucherType.DELIVERY) { + List validDeliverStatuses = List.of(DeliverStatus.IN_TRANSIT, + DeliverStatus.DELIVERED); + List transits = transitRepository.findAllByFranchiseIdAndStatusInAndCreatedAtBetween( + franchiseId, validDeliverStatuses, start.atStartOfDay(), end.atTime(LocalTime.MAX)); + + Map> transitsByTracking = transits.stream() + .collect(Collectors.groupingBy(Transit::getTrackingNumber)); + + for (Map.Entry> entry : transitsByTracking.entrySet()) { + String trackingNumber = entry.getKey(); + List group = entry.getValue(); + List orderInfos = group.stream() + .map(t -> new OrderInfo(null, t.getOrderCode(), t.getWeight(), + t.getFranchiseId(), null, null)) + .toList(); + Long vehicleId = group.get(0).getVehicleId(); + List feeInfos = transportService.calculateDeliveryFee(orderInfos, + vehicleId); + + for (DeliveryFeeInfo feeInfo : feeInfos) { + if (feeInfo.franchiseId().equals(franchiseId)) { + vouchers.add(new FranchiseVoucherResponse( + trackingNumber, + VoucherType.DELIVERY, + "배송비 (송장: " + trackingNumber + ")", + null, + feeInfo.deliveryFee(), + group.get(0).getCreatedAt())); + } + } + } + } + + // 5. 수수료 (COMMISSION) - 일단 매출이 있는 날짜별로 합산하여 표시 + if (filterType == null || filterType == VoucherType.COMMISSION) { + // 일자별 매출 합산 + Map salesByDate = salesItemRepository + .findAllBySalesFranchiseIdAndCreatedAtBetween(franchiseId, start.atStartOfDay(), end.atTime(LocalTime.MAX)) + .stream() + .filter(item -> { + Boolean canceled = item.getSales().getIsCanceled(); + return canceled == null || !canceled; + }) + .collect(Collectors.groupingBy(item -> item.getCreatedAt().toLocalDate(), + Collectors.reducing(BigDecimal.ZERO, + item -> item.getUnitPrice().multiply(BigDecimal + .valueOf(item.getQuantity())), + BigDecimal::add))); + + salesByDate.forEach((date, total) -> { + BigDecimal commission = total.multiply(new BigDecimal("0.033")).setScale(0, + java.math.RoundingMode.HALF_UP); + if (commission.compareTo(BigDecimal.ZERO) > 0) { + vouchers.add(new FranchiseVoucherResponse( + "COMM-" + date.toString().replace("-", ""), + VoucherType.COMMISSION, + "판매 수수료 (3.3%) - " + date, + null, + commission, + date.atTime(23, 59, 59))); + } + }); + } + + return vouchers; + } + // 내부 메서드, 외부 controller에서 직접 부를 수 없는 private로 선언. // domain으로 보내지 않고 api모듈에서 계속 사용 // 일별 정산 요약 데이터 받기 - private FranchiseSettlementSummaryResponse toSummary(DailySettlementReceipt r) { + private FranchiseSettlementSummaryResponse toSummary(String franchiseName, DailySettlementReceipt r) { return new FranchiseSettlementSummaryResponse( + franchiseName, r.getFinalAmount(), r.getTotalSaleAmount(), r.getRefundAmount(), @@ -239,6 +554,19 @@ private FranchiseSettlementSummaryResponse toSummary(DailySettlementReceipt r) { r.getAdjustmentAmount()); } + private FranchiseSettlementSummaryResponse toSummary(String franchiseName, MonthlySettlement s) { + return new FranchiseSettlementSummaryResponse( + franchiseName, + s.getFinalSettlementAmount(), + s.getTotalSaleAmount(), + s.getRefundAmount(), + s.getOrderAmount(), + s.getDeliveryFee(), + s.getLossAmount(), + s.getCommissionFee(), + s.getAdjustmentAmount()); + } + // 전표 상세 보기 탭 목록 private FranchiseVoucherResponse toVoucherResponse(DailyReceiptLine line) { return new FranchiseVoucherResponse( @@ -260,22 +588,58 @@ private FranchiseVoucherResponse toVoucherResponse(SettlementVoucher voucher) { voucher.getOccurredAt()); } + /** + * 오늘 날짜의 실시간 전표 목록을 생성하여 반환함 + */ + private Page getProvisionalVouchers( + Long franchiseId, LocalDate date, VoucherType filterType, Pageable pageable) { + log.info("[DEBUG] Generating real-time vouchers for franchiseId: {}, date: {}, filter: {}", + franchiseId, date, filterType); + + // 상위 집계 메서드 재사용 (데이터 일관성 확보) + AggregationResult result = aggregateDailySettlement(franchiseId, date); + + List allVouchers = result.lines().stream() + .filter(line -> filterType == null || line.getLineType() == filterType) + .map(this::toVoucherResponse) + .collect(Collectors.toList()); + + // 발생시간 역순 정렬 (최신순) + allVouchers.sort((a, b) -> { + if (a.occurredAt() == null || b.occurredAt() == null) + return 0; + return b.occurredAt().compareTo(a.occurredAt()); + }); + + // 페이징 처리 + int start = (int) pageable.getOffset(); + int end = Math.min((start + pageable.getPageSize()), allVouchers.size()); + + if (start > allVouchers.size()) { + return new org.springframework.data.domain.PageImpl<>(new ArrayList<>(), pageable, + allVouchers.size()); + } + + return new org.springframework.data.domain.PageImpl<>( + allVouchers.subList(start, end), pageable, allVouchers.size()); + } + // SalesItem 집계: 상품명별 수량/금액 합산 후에 순위 매기기 private List aggregateSalesItems( List items, Integer limit) { // 상품명별 그룹핑 - // Map> Map> grouped = items.stream() .collect(Collectors.groupingBy(SalesItem::getProductName)); - List result = grouped.entrySet().stream() .map(entry -> { String name = entry.getKey(); List group = entry.getValue(); - int totalQty = group.size(); - BigDecimal total = group.stream() - .map(SalesItem::getUnitPrice) - .reduce(BigDecimal.ZERO, BigDecimal::add); + int totalQty = group.stream() + .mapToInt(SalesItem::getQuantity).sum(); + BigDecimal total = group.stream() + .map(item -> item.getUnitPrice().multiply( + BigDecimal.valueOf(item.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal displayPrice = group.get(0).getUnitPrice(); return new FranchiseSalesItemResponse(0, name, totalQty, displayPrice, total); }) // 단가가 변경될 경우 @@ -294,17 +658,24 @@ private List aggregateSalesItems( return ranked; } - // OrderItem 집계 (FranchiseOrderItem에는 상품명이 없으므로 productId 사용) + // OrderItem 집계 // 발주 상품별로 묶고 더해서 순위 매기기 private List aggregateOrderItems( List items, Integer limit) { // productId별 그룹핑 Map> grouped = items.stream() .collect(Collectors.groupingBy(FranchiseOrderItem::getProductId)); + + // 여러 productId에 대한 상품 정보를 한 번에 조회 + List productIds = new ArrayList<>(grouped.keySet()); + Map productNames = productRepository.findAllByProductIdIn(productIds).stream() + .collect(Collectors.toMap(Product::getProductId, Product::getName)); + List result = grouped.entrySet().stream() .map(entry -> { Long productId = entry.getKey(); - String productName = "품목 ID " + productId; // TODO: 상품 도메인에서 조회 필요 + String productName = productNames.getOrDefault(productId, + "알 수 없는 상품 (ID: " + productId + ")"); List group = entry.getValue(); int totalQty = group.stream() .mapToInt(FranchiseOrderItem::getQuantity).sum(); @@ -333,110 +704,289 @@ private List aggregateOrderItems( // pdf, excel 다운로드 (프론트 통신용 실제 URL 반환) --- @Transactional public String getDailyReceiptPdf(Long franchiseId, LocalDate date) { + String uploadedFileName = null; // 롤백 시 삭제할 파일명 추적 try { - // 1. 해당 가맹점의 일별 정산 데이터 조회 (Optional로 조회하여 트랜잭션 rollback-only 방지) - Optional receiptOpt = dailyService.findByFranchiseAndDate(franchiseId, date); - if (receiptOpt.isEmpty()) { - return generateProvisionalDailyPdf(franchiseId, date); + // 1. DB에서 확정 정산 레코드 조회 + java.util.Optional receiptOpt = dailyService + .findByFranchiseAndDate(franchiseId, date); + + // 2. 기존 문서 레코드가 있으면 삭제 (매 요청마다 최신 데이터로 재생성) + if (receiptOpt.isPresent()) { + java.util.Optional existingDoc = documentService + .getDailyDocument(receiptOpt.get().getDailyReceiptId()); + existingDoc.ifPresent(doc -> { + documentService.deleteById(doc.getSettlementDocumentId()); + }); } - DailySettlementReceipt receipt = receiptOpt.get(); - - // 2. 해당 정산 ID로 이미 생성된 문서가 있는지 확인 - java.util.Optional existingDoc = documentService - .getDailyDocument(receipt.getDailyReceiptId()); - if (existingDoc.isPresent()) { - return minioService.getFileUrl(existingDoc.get().getObjectKey(), - BucketName.SETTLEMENTS); + + // 3. 현재 정산 데이터 실시간 집계 + AggregationResult result = aggregateDailySettlement(franchiseId, date); + DailySettlementReceipt currentReceipt = result.receipt(); + List currentLines = result.lines(); + + /* 데이터가 전혀 없는 경우에도 PDF 생성을 허용하도록 예외 처리 주석 처리 + if (currentReceipt.getTotalSaleAmount().compareTo(BigDecimal.ZERO) == 0 && + currentReceipt.getOrderAmount().compareTo(BigDecimal.ZERO) == 0) { + throw new SettlementException(SettlementErrorCode.SETTLEMENT_DATA_EMPTY); + } + */ + + // [중요] ID missing 및 중복 키 에러 해결: 기존 데이터 조회 후 Upsert + java.util.Optional existingReceipt = + dailyService.findByFranchiseAndDate(franchiseId, date); + + if (existingReceipt.isPresent()) { + DailySettlementReceipt legacy = existingReceipt.get(); + legacy.updateAmounts( + currentReceipt.getTotalSaleAmount(), + currentReceipt.getOrderAmount(), + currentReceipt.getDeliveryFee(), + currentReceipt.getCommissionFee(), + currentReceipt.getLossAmount(), + currentReceipt.getRefundAmount(), + currentReceipt.getAdjustmentAmount(), + currentReceipt.getFinalAmount() + ); + currentReceipt = dailyService.save(legacy); + } else { + currentReceipt = dailyService.save(currentReceipt); } - // 3. 문서가 없으면 실시간 생성 - List lines = dailyService.getAllReceiptLines(receipt.getDailyReceiptId()); - byte[] pdfBytes = fileService.createDailyReceiptPdf(receipt, lines); - // 4. MinIO 업로드 + // 4. 가맹점명 조회 + String franchiseName = franchiseRepository.findById(franchiseId) + .map(com.chaing.domain.businessunits.entity.Franchise::getName) + .orElse("Unknown Store"); + + // 5. PDF 생성 + byte[] pdfBytes = fileService.createDailyReceiptPdf(currentReceipt, currentLines, franchiseName); + String fileName = "settlement/daily/FR_" + franchiseId + "_Daily_" + date + "_" + System.currentTimeMillis() + ".pdf"; + uploadedFileName = fileName; + minioService.uploadFile(pdfBytes, fileName, "application/pdf", BucketName.SETTLEMENTS); String fileUrl = minioService.getFileUrl(fileName, BucketName.SETTLEMENTS); - log.info("====> {} ", fileUrl); - // 5. DB에 메타데이터 저장 - SettlementDocument newDoc = SettlementDocument.builder() + + SettlementDocument.SettlementDocumentBuilder docBuilder = SettlementDocument.builder() .periodType(PeriodType.DAILY) .documentType(DocumentType.RECEIPT_PDF) .documentOwner(DocumentOwner.FRANCHISE) .franchiseId(franchiseId) - .dailyReceiptId(receipt.getDailyReceiptId()) + .dailyReceiptId(currentReceipt.getDailyReceiptId()) // 영속화된 ID 사용 .storageProvider("MINIO") .bucket(BucketName.SETTLEMENTS.getBucketName()) .objectKey(fileName) .fileUrl(fileUrl) .fileName(fileName.substring(fileName.lastIndexOf("/") + 1)) .contentType("application/pdf") - .fileSize((long) pdfBytes.length) - .build(); - documentService.save(newDoc); + .fileSize((long) pdfBytes.length); + documentService.save(docBuilder.build()); return fileUrl; } catch (Exception e) { - log.error("Failed to generate Daily Franchise Receipt PDF: ", e); + log.error("[ERROR] Daily PDF generation failed for franchise: {}, date: {}. Error: {}", + franchiseId, date, e.getMessage()); + + // 업로드 후 DB 저장 실패 시 MinIO 고아 파일 삭제 + if (uploadedFileName != null) { + try { + minioService.deleteFile(uploadedFileName, BucketName.SETTLEMENTS); + } catch (Exception ex) { + log.warn("[ROLLBACK ERROR] Failed to delete orphan file: {}", uploadedFileName); + } + } + + if (e instanceof SettlementException) { + throw e; + } throw new SettlementException(SettlementErrorCode.DOCUMENT_GENERATION_FAILED); } } + @Transactional public String getMonthlyReceiptPdf(Long franchiseId, YearMonth month) { try { - // 1. 해당 가맹점의 월별 정산 데이터 조회 (Optional로 조회하여 트랜잭션 rollback-only 방지) - Optional settlementOpt = monthlyService.findByFranchiseAndMonth(franchiseId, month); - if (settlementOpt.isEmpty()) { - return generateProvisionalMonthlyPdf(franchiseId, month); - } - MonthlySettlement settlement = settlementOpt.get(); - List vouchers = voucherRepository - .findAllByMonthlySettlementId(settlement.getMonthlySettlementId()); + // 1. DB에서 확정 월별 정산 조회 + java.util.Optional settlementOpt = monthlyService + .findByFranchiseAndMonth(franchiseId, month); - // 2. 이미 생성된 문서(RECEIPT_PDF)가 있는지 확인 - try { - List documents = documentService - .getMonthlyDocuments(settlement.getMonthlySettlementId()); + String franchiseName = franchiseRepository.findById(franchiseId) + .map(Franchise::getName).orElse("Unknown Store"); + + byte[] pdfBytes; + String fileName = "settlement/monthly/FR_" + franchiseId + "_Monthly_" + + month + "_" + System.currentTimeMillis() + ".pdf"; - String existingUrl = documents.stream() + if (settlementOpt.isPresent()) { + MonthlySettlement settlement = settlementOpt.get(); + + // 2. 기존 문서 레코드 삭제 (매 요청마다 최신 데이터로 재생성) + List documents = documentService + .getMonthlyDocuments(settlement.getMonthlySettlementId()); + documents.stream() .filter(doc -> doc.getDocumentType() == DocumentType.RECEIPT_PDF) - .findFirst() - .map(com.chaing.domain.settlements.entity.SettlementDocument::getFileUrl) - .orElse(null); + .forEach(doc -> documentService.deleteById(doc.getSettlementDocumentId())); + + // 3. 확정 스냅샷으로 RECEIPT_PDF 생성 + List vouchers = voucherRepository + .findAllByMonthlySettlementId(settlement.getMonthlySettlementId()); + pdfBytes = fileService.createMonthlyReceiptPdf(settlement, vouchers, franchiseName); + + minioService.uploadFile(pdfBytes, fileName, "application/pdf", BucketName.SETTLEMENTS); + String fileUrl = minioService.getFileUrl(fileName, BucketName.SETTLEMENTS); + + documentService.save(SettlementDocument.builder() + .periodType(PeriodType.MONTHLY) + .documentType(DocumentType.RECEIPT_PDF) + .documentOwner(DocumentOwner.FRANCHISE) + .franchiseId(franchiseId) + .monthlySettlementId(settlement.getMonthlySettlementId()) + .storageProvider("MINIO") + .bucket(BucketName.SETTLEMENTS.getBucketName()) + .objectKey(fileName) + .fileUrl(fileUrl) + .fileName(fileName.substring(fileName.lastIndexOf("/") + 1)) + .contentType("application/pdf") + .fileSize((long) pdfBytes.length) + .build()); + return fileUrl; + } + + // 4. 확정 레코드 없으면 실시간 가집계로 PDF 생성 (당월 등) + return generateProvisionalMonthlyPdf(franchiseId, month); + } catch (Exception e) { + log.error("Failed to generate Monthly Franchise Receipt PDF: ", e); + if (e instanceof SettlementException) + throw (SettlementException) e; + throw new SettlementException(SettlementErrorCode.DOCUMENT_GENERATION_FAILED); + } + } - if (existingUrl != null) { - return minioService.getFileUrl(documents.stream() + @Transactional + public String getMonthlyVouchersExcel(Long franchiseId, YearMonth month) { + try { + // 1. 해당 가맹점의 월별 정산 데이터 조회 (Optional 사용으로 트랜잭션 롤백 방지) + java.util.Optional settlementOpt = monthlyService + .findByFranchiseAndMonth(franchiseId, month); + + if (settlementOpt.isPresent()) { + MonthlySettlement settlement = settlementOpt.get(); + // 2. 이미 생성된 엑셀 문서가 있는지 확인 + List documents = documentService + .getMonthlyDocuments(settlement.getMonthlySettlementId()); + if (documents != null) { + var existingExcel = documents.stream() .filter(doc -> doc - .getDocumentType() == DocumentType.RECEIPT_PDF) - .findFirst() - .get().getObjectKey(), BucketName.SETTLEMENTS); + .getDocumentType() == DocumentType.VOUCHER_EXCEL) + .findFirst(); + if (existingExcel.isPresent()) { + return minioService.getFileUrl(existingExcel.get().getObjectKey(), + BucketName.SETTLEMENTS); + } } - } catch (SettlementException e) { - // 문서 목록 조회 시 에러가 나더라도(없으면), 아래에서 실시간 생성 진행을 위해 넘어감 - if (e.getErrorCode() == SettlementErrorCode.INVALID_SETTLEMENT_ID) { - throw e; + } + + // 3. 데이터가 없으면 실시간 가집계 엑셀 생성 시도 + return generateProvisionalMonthlyExcel(franchiseId, month); + } catch (Exception e) { + log.error("Failed to generate Monthly Franchise Vouchers Excel: ", e); + if (e instanceof SettlementException) + throw (SettlementException) e; + throw new SettlementException(SettlementErrorCode.DOCUMENT_GENERATION_FAILED); + } + } + + private String generateProvisionalMonthlyPdf(Long franchiseId, YearMonth month) { + String uploadedFileName = null; + try { + // 1. 해당 월의 모든 날짜를 순회하며 데이터 수집 + LocalDate start = month.atDay(1); + LocalDate end = month.atEndOfMonth(); + LocalDate today = LocalDate.now(); + if (end.isAfter(today)) { + end = today; + } + + List receipts = new ArrayList<>(); + List vouchers = new ArrayList<>(); + + for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) { + AggregationResult result = getInternalDailyAggregation(franchiseId, date); + DailySettlementReceipt receipt = result.receipt(); + + // [수정] 매출, 발주, 배송비 외에 환급액, 손실액, 조정액 중 하나라도 존재하면 리스트에 포함 (Coderabbit 지적 해결) + if (receipt.getTotalSaleAmount().compareTo(BigDecimal.ZERO) != 0 || + receipt.getOrderAmount().compareTo(BigDecimal.ZERO) != 0 || + receipt.getDeliveryFee().compareTo(BigDecimal.ZERO) != 0 || + receipt.getRefundAmount().compareTo(BigDecimal.ZERO) != 0 || + receipt.getLossAmount().compareTo(BigDecimal.ZERO) != 0 || + receipt.getAdjustmentAmount().compareTo(BigDecimal.ZERO) != 0) { + + receipts.add(receipt); + vouchers.add(SettlementVoucher.builder() + .voucherType(VoucherType.SALES) + .amount(receipt.getFinalAmount()) + .description(date + " 일별 정산 합계") + .occurredAt(date.atStartOfDay()) + .build()); } } - // 4. 없으면 실시간 생성 - byte[] pdfBytes = fileService.createMonthlyReceiptPdf(settlement, vouchers); + // [수정] 매출/발주가 없는 날도 가맹점 확인을 위해 PDF 생성을 허용함 + /* + if (receipts.isEmpty()) { + throw new SettlementException(SettlementErrorCode.SETTLEMENT_DATA_EMPTY); + } + */ + + // 2. 가집계 MonthlySettlement 객체 생성 및 [Upsert] (ID 확보 및 중복 방지) + MonthlySettlement provisionalSettlement = aggregateMonthlySettlement(franchiseId, month, receipts); + + java.util.Optional existingSettlement = + monthlyService.findByFranchiseAndMonth(franchiseId, month); + + if (existingSettlement.isPresent()) { + MonthlySettlement legacy = existingSettlement.get(); + legacy.updateAmounts( + provisionalSettlement.getTotalSaleAmount(), + provisionalSettlement.getOrderAmount(), + provisionalSettlement.getDeliveryFee(), + provisionalSettlement.getCommissionFee(), + provisionalSettlement.getLossAmount(), + provisionalSettlement.getRefundAmount(), + provisionalSettlement.getAdjustmentAmount(), + provisionalSettlement.getFinalSettlementAmount() + ); + provisionalSettlement = monthlyService.save(legacy); + } else { + provisionalSettlement = monthlyService.save(provisionalSettlement); + } - // 5. MinIO 업로드 - String fileName = "settlement/monthly/Franchise_" + franchiseId + "_Monthly_Receipt_" + month - + "_" + // 가맹점명 조회 + String franchiseName = franchiseRepository.findById(franchiseId) + .map(com.chaing.domain.businessunits.entity.Franchise::getName) + .orElse("Unknown Store"); + + // 3. PDF 생성 + byte[] pdfBytes = fileService.createMonthlyReceiptPdf(provisionalSettlement, vouchers, franchiseName); + + // 4. MinIO 업로드 + String fileName = "settlement/provisional/FR_" + franchiseId + "_" + month + "_Preview_" + System.currentTimeMillis() + ".pdf"; + uploadedFileName = fileName; + minioService.uploadFile(pdfBytes, fileName, "application/pdf", BucketName.SETTLEMENTS); - - // 6. DB 메타데이터 저장 String fileUrl = minioService.getFileUrl(fileName, BucketName.SETTLEMENTS); + + // 5. DB에 메타데이터 저장 documentService.save(SettlementDocument.builder() - .monthlySettlementId(settlement.getMonthlySettlementId()) .periodType(PeriodType.MONTHLY) - .documentType(DocumentType.RECEIPT_PDF) + .documentType(DocumentType.PROVISIONAL_RECEIPT_PDF) .documentOwner(DocumentOwner.FRANCHISE) .franchiseId(franchiseId) + .monthlySettlementId(provisionalSettlement.getMonthlySettlementId()) // ID 연결 .storageProvider("MINIO") .bucket(BucketName.SETTLEMENTS.getBucketName()) .objectKey(fileName) @@ -447,64 +997,105 @@ public String getMonthlyReceiptPdf(Long franchiseId, YearMonth month) { .build()); return fileUrl; - } catch (SettlementException e) { - if (e.getErrorCode() == SettlementErrorCode.MONTHLY_SETTLEMENT_NOT_FOUND) { - if (month.isAfter(YearMonth.now().minusMonths(1))) { // 이번 달인 경우 안내 메시지 포함된 코드 사용 - throw new SettlementException( - SettlementErrorCode.MONTHLY_SETTLEMENT_NOT_FINALIZED); + } catch (Exception e) { + log.error("[ERROR] Provisional Monthly PDF generation failed: ", e); + if (uploadedFileName != null) { + try { + minioService.deleteFile(uploadedFileName, BucketName.SETTLEMENTS); + } catch (Exception ex) { + log.warn("[ROLLBACK ERROR] Failed to delete orphan file: {}", uploadedFileName); } - throw e; } throw e; - } catch (Exception e) { - log.error("Failed to generate Monthly Franchise Receipt PDF: ", e); - throw new SettlementException(SettlementErrorCode.DOCUMENT_GENERATION_FAILED); } } - @Transactional - public String getMonthlyVouchersExcel(Long franchiseId, YearMonth month) { + private String generateProvisionalMonthlyExcel(Long franchiseId, YearMonth month) { + String uploadedFileName = null; try { - // 1. 해당 가맹점의 월별 정산 데이터 조회 (Optional로 조회하여 트랜잭션 rollback-only 방지) - Optional settlementOpt = monthlyService.findByFranchiseAndMonth(franchiseId, month); - if (settlementOpt.isEmpty()) { - return generateProvisionalMonthlyExcel(franchiseId, month); + // 1. 해당 월의 모든 날짜를 순회하며 전표 데이터 수집 + LocalDate start = month.atDay(1); + LocalDate end = month.atEndOfMonth(); + LocalDate today = LocalDate.now(); + if (end.isAfter(today)) { + end = today; } - MonthlySettlement settlement = settlementOpt.get(); - // 2. 이미 생성된 엑셀 문서가 있는지 확인 - List documents = documentService - .getMonthlyDocuments(settlement.getMonthlySettlementId()); - if (documents != null) { - var existingExcel = documents.stream() - .filter(doc -> doc.getDocumentType() == DocumentType.VOUCHER_EXCEL) - .findFirst(); - if (existingExcel.isPresent()) { - return minioService.getFileUrl(existingExcel.get().getObjectKey(), - BucketName.SETTLEMENTS); + List receipts = new ArrayList<>(); + List vouchers = new ArrayList<>(); + + for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) { + AggregationResult result = getInternalDailyAggregation(franchiseId, date); + DailySettlementReceipt receipt = result.receipt(); + + // [수정] 라인이 있거나, 혹은 정산 금액 요소(매출, 발주, 환급, 손실, 조정 등)가 하나라도 있으면 엑셀에 포함 + if (!result.lines().isEmpty() || + receipt.getTotalSaleAmount().compareTo(BigDecimal.ZERO) != 0 || + receipt.getAdjustmentAmount().compareTo(BigDecimal.ZERO) != 0) { + + receipts.add(receipt); + for (DailyReceiptLine line : result.lines()) { + vouchers.add(SettlementVoucher.builder() + .voucherType(line.getLineType()) + .amount(line.getAmount()) + .description(date + ": " + line.getDescription()) + .occurredAt(line.getOccurredAt()) + .referenceCode(line.getReferenceCode()) + .build()); + } } } - // 3. 없으면 실시간 생성 - List vouchers = voucherRepository - .findAllByMonthlySettlementId(settlement.getMonthlySettlementId()); + // [수정] 매출/발주가 없는 날도 가맹점 확인을 위해 Excel 생성을 허용함 + /* + if (vouchers.isEmpty()) { + throw new SettlementException(SettlementErrorCode.SETTLEMENT_DATA_EMPTY); + } + */ + + // [중요] ID missing 및 중복 키 에러 해결을 위해 MonthlySettlement 가집계 데이터 Upsert + MonthlySettlement provisionalSettlement = aggregateMonthlySettlement(franchiseId, month, receipts); + + java.util.Optional existingSettlement = + monthlyService.findByFranchiseAndMonth(franchiseId, month); + + if (existingSettlement.isPresent()) { + MonthlySettlement legacy = existingSettlement.get(); + legacy.updateAmounts( + provisionalSettlement.getTotalSaleAmount(), + provisionalSettlement.getOrderAmount(), + provisionalSettlement.getDeliveryFee(), + provisionalSettlement.getCommissionFee(), + provisionalSettlement.getLossAmount(), + provisionalSettlement.getRefundAmount(), + provisionalSettlement.getAdjustmentAmount(), + provisionalSettlement.getFinalSettlementAmount() + ); + provisionalSettlement = monthlyService.save(legacy); + } else { + provisionalSettlement = monthlyService.save(provisionalSettlement); + } + + // 2. 엑셀 생성 byte[] excelBytes = fileService.createMonthlyVoucherExcel(vouchers); - // 4. MinIO 업로드 - String fileName = "settlement/monthly/FR_" + franchiseId + "_Voucher_" + month + "_" + // 3. MinIO 업로드 + String fileName = "settlement/provisional/FR_" + franchiseId + "_" + month + "_Voucher_Preview_" + System.currentTimeMillis() + ".xlsx"; + uploadedFileName = fileName; + minioService.uploadFile(excelBytes, fileName, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", BucketName.SETTLEMENTS); String fileUrl = minioService.getFileUrl(fileName, BucketName.SETTLEMENTS); - // 5. DB에 메타데이터 저장 - SettlementDocument newDoc = SettlementDocument.builder() + // 4. DB에 메타데이터 저장 + documentService.save(SettlementDocument.builder() .periodType(PeriodType.MONTHLY) - .documentType(DocumentType.VOUCHER_EXCEL) + .documentType(DocumentType.PROVISIONAL_VOUCHER_EXCEL) .documentOwner(DocumentOwner.FRANCHISE) .franchiseId(franchiseId) - .monthlySettlementId(settlement.getMonthlySettlementId()) + .monthlySettlementId(provisionalSettlement.getMonthlySettlementId()) // ID 연결 .storageProvider("MINIO") .bucket(BucketName.SETTLEMENTS.getBucketName()) .objectKey(fileName) @@ -512,140 +1103,47 @@ public String getMonthlyVouchersExcel(Long franchiseId, YearMonth month) { .fileName(fileName.substring(fileName.lastIndexOf("/") + 1)) .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") .fileSize((long) excelBytes.length) - .build(); - documentService.save(newDoc); + .build()); return fileUrl; - } catch (SettlementException e) { - if (e.getErrorCode() == SettlementErrorCode.MONTHLY_SETTLEMENT_NOT_FOUND) { - if (month.isAfter(YearMonth.now().minusMonths(1))) { - throw new SettlementException( - SettlementErrorCode.MONTHLY_SETTLEMENT_NOT_FINALIZED); + } catch (Exception e) { + log.error("[ERROR] Provisional Monthly Excel generation failed: ", e); + if (uploadedFileName != null) { + try { + minioService.deleteFile(uploadedFileName, BucketName.SETTLEMENTS); + } catch (Exception ex) { + log.warn("[ROLLBACK ERROR] Failed to delete orphan file: {}", uploadedFileName); } } throw e; - } catch (Exception e) { - log.error("Failed to generate Monthly Franchise Vouchers Excel: ", e); - throw new SettlementException(SettlementErrorCode.DOCUMENT_GENERATION_FAILED); } } - private String generateProvisionalMonthlyPdf(Long franchiseId, YearMonth month) { - // 1. 해당 월의 모든 일별 정산 데이터 조회 - List dailyReceipts = dailyService.getAllByFranchiseAndDateRange( - franchiseId, month.atDay(1), month.atEndOfMonth()); - - if (dailyReceipts.isEmpty()) { - // 상황 2: 정산 데이터 자체가 없음 (휴무일 등) - throw new SettlementException(SettlementErrorCode.SETTLEMENT_DATA_EMPTY); - } - - // 2. 가집계 MonthlySettlement 객체 생성 - MonthlySettlement provisionalSettlement = aggregateMonthlySettlement(franchiseId, month, dailyReceipts); - - // 3. 일별 데이터를 기반으로 가상 전표(Voucher) 생성 - List provisionalVouchers = dailyReceipts.stream() - .map(r -> SettlementVoucher.builder() - .voucherType(VoucherType.SALES) - .amount(r.getFinalAmount()) - .description(r.getSettlementDate() + " 일별 정산 합계 (가집계 내역)") - .occurredAt(r.getSettlementDate().atStartOfDay()) - .build()) - .collect(Collectors.toList()); - - // 4. PDF 생성 - byte[] pdfBytes = fileService.createMonthlyReceiptPdf(provisionalSettlement, provisionalVouchers); - - // 5. MinIO 업로드 - String fileName = "settlement/provisional/FR_" + franchiseId + "_" + month + "_Preview_" - + System.currentTimeMillis() + ".pdf"; - minioService.uploadFile(pdfBytes, fileName, "application/pdf", BucketName.SETTLEMENTS); - String fileUrl = minioService.getFileUrl(fileName, BucketName.SETTLEMENTS); - - // 6. DB에 메타데이터 저장 (추적용) - documentService.save(SettlementDocument.builder() - .periodType(PeriodType.MONTHLY) - .documentType(DocumentType.PROVISIONAL_RECEIPT_PDF) - .documentOwner(DocumentOwner.FRANCHISE) - .franchiseId(franchiseId) - .storageProvider("MINIO") - .bucket(BucketName.SETTLEMENTS.getBucketName()) - .objectKey(fileName) - .fileUrl(fileUrl) - .fileName(fileName.substring(fileName.lastIndexOf("/") + 1)) - .contentType("application/pdf") - .fileSize((long) pdfBytes.length) - .build()); - - return fileUrl; - } - - private String generateProvisionalMonthlyExcel(Long franchiseId, YearMonth month) { - // 1. 해당 월의 모든 일별 정산 데이터 조회 - List dailyReceipts = dailyService.getAllByFranchiseAndDateRange( - franchiseId, month.atDay(1), month.atEndOfMonth()); - - if (dailyReceipts.isEmpty()) { - // 상황 2: 정산 데이터 자체가 없음 - throw new SettlementException(SettlementErrorCode.SETTLEMENT_DATA_EMPTY); - } - - // 2. 일별 데이터를 기반으로 가상 전표 생성 - List provisionalVouchers = dailyReceipts.stream() - .map(r -> SettlementVoucher.builder() - .voucherType(VoucherType.SALES) - .amount(r.getFinalAmount()) - .description(r.getSettlementDate() + " 일별 정산 합계 (가집계 내역)") - .occurredAt(r.getSettlementDate().atStartOfDay()) - .build()) - .collect(Collectors.toList()); - - // 3. 엑셀 생성 - byte[] excelBytes = fileService.createMonthlyVoucherExcel(provisionalVouchers); - - // 4. MinIO 업로드 - String fileName = "settlement/provisional/FR_" + franchiseId + "_" + month + "_Voucher_Preview_" - + System.currentTimeMillis() + ".xlsx"; - minioService.uploadFile(excelBytes, fileName, - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - BucketName.SETTLEMENTS); - String fileUrl = minioService.getFileUrl(fileName, BucketName.SETTLEMENTS); - - // 5. DB에 메타데이터 저장 (추적용) - documentService.save(SettlementDocument.builder() - .periodType(PeriodType.MONTHLY) - .documentType(DocumentType.PROVISIONAL_VOUCHER_EXCEL) - .documentOwner(DocumentOwner.FRANCHISE) - .franchiseId(franchiseId) - .storageProvider("MINIO") - .bucket(BucketName.SETTLEMENTS.getBucketName()) - .objectKey(fileName) - .fileUrl(fileUrl) - .fileName(fileName.substring(fileName.lastIndexOf("/") + 1)) - .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") - .fileSize((long) excelBytes.length) - .build()); - - return fileUrl; - } - private MonthlySettlement aggregateMonthlySettlement(Long franchiseId, YearMonth month, List dailyReceipts) { - BigDecimal totalSale = dailyReceipts.stream().map(DailySettlementReceipt::getTotalSaleAmount) + BigDecimal totalSale = dailyReceipts.stream() + .map(r -> r.getTotalSaleAmount() != null ? r.getTotalSaleAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal orderAmount = dailyReceipts.stream().map(DailySettlementReceipt::getOrderAmount) + BigDecimal orderAmount = dailyReceipts.stream() + .map(r -> r.getOrderAmount() != null ? r.getOrderAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal commissionFee = dailyReceipts.stream().map(DailySettlementReceipt::getCommissionFee) + BigDecimal commissionFee = dailyReceipts.stream() + .map(r -> r.getCommissionFee() != null ? r.getCommissionFee() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal deliveryFee = dailyReceipts.stream().map(DailySettlementReceipt::getDeliveryFee) + BigDecimal deliveryFee = dailyReceipts.stream() + .map(r -> r.getDeliveryFee() != null ? r.getDeliveryFee() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal lossAmount = dailyReceipts.stream().map(DailySettlementReceipt::getLossAmount) + BigDecimal lossAmount = dailyReceipts.stream() + .map(r -> r.getLossAmount() != null ? r.getLossAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal refundAmount = dailyReceipts.stream().map(DailySettlementReceipt::getRefundAmount) + BigDecimal refundAmount = dailyReceipts.stream() + .map(r -> r.getRefundAmount() != null ? r.getRefundAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal adjustmentAmount = dailyReceipts.stream().map(DailySettlementReceipt::getAdjustmentAmount) + BigDecimal adjustmentAmount = dailyReceipts.stream() + .map(r -> r.getAdjustmentAmount() != null ? r.getAdjustmentAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal finalAmount = dailyReceipts.stream().map(DailySettlementReceipt::getFinalAmount) + BigDecimal finalAmount = dailyReceipts.stream() + .map(r -> r.getFinalAmount() != null ? r.getFinalAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); return MonthlySettlement.builder() @@ -663,91 +1161,308 @@ private MonthlySettlement aggregateMonthlySettlement(Long franchiseId, YearMonth .build(); } - private DailySettlementReceipt aggregateDailySettlement(Long franchiseId, LocalDate date) { - // 1. 해당 날짜의 판매 내역 조회 및 집계 - List salesList = salesRepository.findAllByFranchiseIdAndIsCanceledFalseAndCreatedAtBetween( - franchiseId, date.atStartOfDay(), date.atTime(23, 59, 59)); + // 실시간 가집계 데이터 및 상세 전표 라인 수집을 위한 내부 레코드 + private record AggregationResult( + DailySettlementReceipt receipt, + List lines) { + } + + // 실시간 가집계 (매출, 수수료, 발주, 배송비, 반품 등) + private AggregationResult aggregateDailySettlement(Long franchiseId, LocalDate date) { + List lines = new ArrayList<>(); + // 1. 해당 날짜의 판매 내역 조회 및 집계 (취소 안 된 것만) + log.info("[DEBUG] 정산 조회 franchiseId={}, date={}", franchiseId, date); + + // SalesItem 기준으로 집계하여 판매 관리 페이지와 100% 일치 보장 + // 시간대 오차 방지를 위해 toLocalDate()로 직접 비교 + LocalDateTime start = date.atStartOfDay(); + LocalDateTime end = date.atTime(LocalTime.MAX); + + List salesItems = salesItemRepository.findAllBySalesFranchiseIdAndCreatedAtBetween(franchiseId, start, end) + .stream() + .filter(item -> { + Boolean canceled = item.getSales().getIsCanceled(); + return canceled == null || !canceled; + }) + .toList(); + + log.info("[DEBUG] aggregateDailySettlement - found items count: {}", salesItems.size()); - BigDecimal totalSale = salesList.stream() - .map(Sales::getTotalAmount) + BigDecimal totalSale = salesItems.stream() + .map(item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); + // 판매 상세 라인 추가 + for (SalesItem item : salesItems) { + lines.add(DailyReceiptLine.builder() + .lineType(VoucherType.SALES) + .description(item.getProductName() + " (판매)") + .productName(item.getProductName()) + .quantity(item.getQuantity()) + .unitPrice(item.getUnitPrice()) + .amount(item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) + .occurredAt(item.getCreatedAt()) + .referenceCode(item.getSales().getSalesCode()) + .build()); + } + // TODO: 실제 정산 로직에 맞게 수수료, 발주금액, 배송비 등 추가 집계 필요 // 현재는 요약 페이지에서 보여주는 핵심 항목 위주로 가집계 - // 2. 해당 날짜의 발주 내역 (ACCEPTED) 조회 및 집계 - List orders = orderRepository.findAllByFranchiseIdAndOrderStatusAndCreatedAtBetween( - franchiseId, FranchiseOrderStatus.ACCEPTED, date.atStartOfDay(), - date.atTime(23, 59, 59)); + // 2. 해당 날짜의 발주 내역 (PENDING 포함 모든 유효 단계) 조회 및 집계 + List validOrderStatuses = List.of( + FranchiseOrderStatus.PENDING, // 발주 즉시 반영을 위해 추가 + FranchiseOrderStatus.ACCEPTED, + FranchiseOrderStatus.PARTIAL, + FranchiseOrderStatus.SHIPPING_PENDING, + FranchiseOrderStatus.SHIPPING, + FranchiseOrderStatus.COMPLETED); + + // DB 레벨 조회가 아닌, 전체 데이터를 가져와 toLocalDate()로 필터링하여 정합성 극대화 + List orders = orderRepository.findAllByFranchiseIdAndOrderStatusInAndCreatedAtBetween( + franchiseId, validOrderStatuses, date.atStartOfDay(), date.atTime(LocalTime.MAX)); + + log.info("[DEBUG] 발주대금 조회 - franchiseId={}, date={}, 전체건수={}, 필터후건수={}", + franchiseId, date, + orderRepository.findAllByFranchiseId(franchiseId).size(), + orders.size()); + + BigDecimal orderAmount = BigDecimal.ZERO; + if (!orders.isEmpty()) { + List orderIds = orders.stream().map(FranchiseOrder::getFranchiseOrderId).toList(); + List orderItems = orderItemRepository + .findAllByFranchiseOrder_FranchiseOrderIdInAndDeletedAtIsNull(orderIds); + + orderAmount = orderItems.stream() + .map(FranchiseOrderItem::getTotalPrice) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + log.info("[DEBUG] 발주 품목 상세 집계 - 발주건수: {}, 전체품목수: {}, 총액: {}", + orders.size(), orderItems.size(), orderAmount); + + // 발주 상세 라인 추가 (상품명 매핑 포함) + // 여러 productId에 대한 상품 정보를 한 번에 조회 + List productIds = orderItems.stream().map(FranchiseOrderItem::getProductId).distinct() + .toList(); + Map productNames = productRepository.findAllByProductIdIn(productIds).stream() + .collect(Collectors.toMap(Product::getProductId, Product::getName)); + + for (FranchiseOrderItem item : orderItems) { + String name = productNames.getOrDefault(item.getProductId(), + "알 수 없는 상품 (ID: " + item.getProductId() + ")"); + lines.add(DailyReceiptLine.builder() + .lineType(VoucherType.ORDER) + .description(name + " (발주)") + .productName(name) + .quantity(item.getQuantity()) + .unitPrice(item.getUnitPrice()) + .amount(item.getTotalPrice()) + .occurredAt(item.getCreatedAt()) + .referenceCode(item.getFranchiseOrder().getOrderCode()) + .build()); + } + } + + // 3. 해당 날짜의 반품 내역 (PENDING 포함 모든 유효 단계) 조회 및 집계 + List validReturnStatuses = List.of( + ReturnStatus.PENDING, // 반품 신청 즉시 반영을 위해 추가 + ReturnStatus.ACCEPTED, + ReturnStatus.SHIPPING_PENDING, + ReturnStatus.SHIPPING, + ReturnStatus.COMPLETED, + ReturnStatus.INSPECTING, + ReturnStatus.DEDUCTION_COMPLETED); + + // DB 레벨 조회가 아닌, 전체 데이터를 가져와 toLocalDate()로 필터링 + List returnsList = returnRepository.findAllByFranchiseIdAndDeletedAtIsNull(franchiseId) + .stream() + .filter(ret -> { + LocalDateTime createdAt = ret.getCreatedAt(); + return createdAt != null && createdAt.toLocalDate().equals(date); + }) + .filter(ret -> validReturnStatuses.contains(ret.getReturnStatus())) + .toList(); + log.info("[DEBUG] 반품 조회 결과 - 건수: {}", returnsList.size()); + + BigDecimal refundAmount = BigDecimal.ZERO; + BigDecimal lossAmount = BigDecimal.ZERO; + + if (!returnsList.isEmpty()) { + List returnIds = returnsList.stream().map(Returns::getReturnId).toList(); + List returnItems = returnItemRepository + .findAllByReturns_ReturnIdInAndDeletedAtIsNull(returnIds); + + // 각 반품 건별로 아이템들을 단가 합산하여 집계 + Map> itemsByReturnId = returnItems.stream() + .collect(Collectors.groupingBy(item -> item.getReturns().getReturnId())); + + for (Returns ret : returnsList) { + List items = itemsByReturnId.getOrDefault(ret.getReturnId(), List.of()); + + // 품목별 단가 합산 (반품 관리 UI와 동일한 방식) + BigDecimal returnTotal = BigDecimal.ZERO; + for (ReturnItem item : items) { + // 연결된 발주 품목의 단가 정보를 가져옴 + FranchiseOrderItem orderItem = orderItemRepository + .findById(item.getFranchiseOrderItemId()).orElse(null); + if (orderItem != null) { + returnTotal = returnTotal.add(orderItem.getUnitPrice()); + } + } - BigDecimal orderAmount = orders.stream() - .map(FranchiseOrder::getTotalAmount) - .reduce(BigDecimal.ZERO, BigDecimal::add); + log.info("[DEBUG] 반품 건 상세 - 코드: {}, 유형: {}, 계산된 금액: {}, 상태: {}", + ret.getReturnCode(), ret.getReturnType(), returnTotal, + ret.getReturnStatus()); + + if (ret.getReturnType() == ReturnType.PRODUCT_DEFECT) { + refundAmount = refundAmount.add(returnTotal); + lines.add(DailyReceiptLine.builder() + .lineType(VoucherType.REFUND) + .description(ret.getReturnCode() + " (상품하자 반품)") + .amount(returnTotal) + .occurredAt(ret.getCreatedAt()) + .referenceCode(ret.getReturnCode()) + .build()); + } else if (ret.getReturnType() == ReturnType.MISORDER) { + lossAmount = lossAmount.add(returnTotal); + lines.add(DailyReceiptLine.builder() + .lineType(VoucherType.LOSS) + .description(ret.getReturnCode() + " (오발주 손실)") + .amount(returnTotal) + .occurredAt(ret.getCreatedAt()) + .referenceCode(ret.getReturnCode()) + .build()); + } + } + } + + // 4. 해당 날짜, 해당 가맹점의 유효한 배송 기록(배송 중, 완료) 조회 및 집계 + List validDeliverStatuses = List.of(DeliverStatus.IN_TRANSIT, DeliverStatus.DELIVERED); + List transits = transitRepository.findAllByFranchiseIdAndStatusInAndCreatedAtBetween( + franchiseId, validDeliverStatuses, date.atStartOfDay(), date.atTime(LocalTime.MAX)); + + // 송장 번호(trackingNumber) 기준으로 그룹화하여 동일 배송은 1건으로 처리 + Map> transitsByTracking = transits.stream() + .collect(Collectors.groupingBy(Transit::getTrackingNumber)); + + BigDecimal deliveryFee = BigDecimal.ZERO; + for (Map.Entry> entry : transitsByTracking.entrySet()) { + String trackingNumber = entry.getKey(); + List group = entry.getValue(); + + // 1. 배송 모듈 서비스 호출을 위한 OrderInfo 리스트 생성 + List orderInfos = group.stream() + .map(t -> new OrderInfo(null, t.getOrderCode(), t.getWeight(), + t.getFranchiseId(), null, null)) + .toList(); + + // 2. 해당 배송 차량의 배송비 계산 서비스 호출 + Long vehicleId = group.get(0).getVehicleId(); + List feeInfos = transportService.calculateDeliveryFee(orderInfos, vehicleId); + + // 3. 현재 가맹점의 배송비 반영 + for (DeliveryFeeInfo feeInfo : feeInfos) { + if (feeInfo.franchiseId().equals(franchiseId)) { + BigDecimal fee = feeInfo.deliveryFee(); + deliveryFee = deliveryFee.add(fee); + + // 상세 영수증 라인에 배송비 추가 + lines.add(DailyReceiptLine.builder() + .lineType(VoucherType.DELIVERY) + .description("배송비 (송장: " + trackingNumber + ")") + .amount(fee) + .occurredAt(group.get(0).getCreatedAt()) + .referenceCode(trackingNumber) + .build()); + } + } + } + + log.info("[DEBUG] 배송비 집계 결과 - 건수: {}, 총액: {}", transitsByTracking.size(), deliveryFee); + + log.info("[DEBUG] 최종 집계 결과 - 반품환급액(DEFECT): {}, 손실액(MISORDER): {}", refundAmount, lossAmount); + + // 3.3% 수수료 산출 (정수 자리로 반올림하여 계산의 명확성 확보) + BigDecimal commissionFee = totalSale.multiply(new BigDecimal("0.033")) + .setScale(0, java.math.RoundingMode.HALF_UP); + + log.info("[DEBUG] 수수료 계산 - 매출: {}, 요율: 3.3%, 산출수수료: {}", totalSale, commissionFee); + + // 수수료 내역 추가 (상세 전표 조회용) + if (commissionFee.compareTo(BigDecimal.ZERO) > 0) { + lines.add(DailyReceiptLine.builder() + .lineType(VoucherType.COMMISSION) + .description("판매 수수료 (3.3%)") + .amount(commissionFee) + .occurredAt(date.atTime(23, 59, 59)) + .referenceCode("COMM-" + date.toString().replace("-", "")) + .build()); + } + + // Null-safe finalAmount 계산 (모든 항목이 null이 아님을 보장하지만 명시적으로 처리) + BigDecimal totalDeductions = orderAmount.add(lossAmount).add(commissionFee).add(deliveryFee); + BigDecimal finalAmount = totalSale.subtract(totalDeductions).add(refundAmount); + + log.info("[DEBUG] 정산 금액 산출 - 매출(+): {}, 환급(+): {}, 공제(-): {}, 최종: {}", + totalSale, refundAmount, totalDeductions, finalAmount); // 간소화된 가집계 결과 반환 - return DailySettlementReceipt.builder() - .franchiseId(franchiseId) - .settlementDate(date) - .totalSaleAmount(totalSale) - .orderAmount(orderAmount) - .refundAmount(BigDecimal.ZERO) // 필요시 추가 구현 - .deliveryFee(BigDecimal.ZERO) // 필요시 추가 구현 - .lossAmount(BigDecimal.ZERO) // 필요시 추가 구현 - .commissionFee(BigDecimal.ZERO) // 필요시 추가 구현 - .adjustmentAmount(BigDecimal.ZERO) - .finalAmount(totalSale.subtract(orderAmount)) // 단순 차액 - .build(); + return new AggregationResult( + DailySettlementReceipt.builder() + .franchiseId(franchiseId) + .settlementDate(date) + .totalSaleAmount(totalSale) + .orderAmount(orderAmount) + .refundAmount(refundAmount) // 상품하자 환급액 + .lossAmount(lossAmount) // 오발주 손실액 + .deliveryFee(deliveryFee) + .commissionFee(commissionFee) + .adjustmentAmount(BigDecimal.ZERO) + .finalAmount(finalAmount) + .build(), + lines); } - private String generateProvisionalDailyPdf(Long franchiseId, LocalDate date) { - // 1. 실시간 가집계 데이터 생성 - DailySettlementReceipt provisionalReceipt = aggregateDailySettlement(franchiseId, date); + // 월별 매출 추이 조회 (그래프용) + @Transactional(readOnly = true) + public List getMonthlySalesTrend(Long franchiseId, YearMonth month) { + log.info("[DEBUG] getMonthlySalesTrend - franchiseId: {}, month: {}", franchiseId, month); + + LocalDateTime start = month.atDay(1).atStartOfDay(); + LocalDateTime end = month.atEndOfMonth().atTime(LocalTime.MAX); + + // 월간 모든 매출 아이템 조회 + List items = salesItemRepository.findAllBySalesFranchiseIdAndCreatedAtBetween(franchiseId, start, end) + .stream() + .filter(item -> { + Boolean canceled = item.getSales().getIsCanceled(); + return canceled == null || !canceled; + }) + .toList(); + + // 일자별 합계 계산 + Map dailySums = items.stream() + .collect(Collectors.groupingBy( + item -> item.getCreatedAt().toLocalDate(), + Collectors.reducing(BigDecimal.ZERO, + item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity())), + BigDecimal::add))); + + // 해당 월의 모든 날짜에 대해 데이터 생성 (비어있는 날은 0원) + List result = new ArrayList<>(); + LocalDate dateLoop = month.atDay(1); + LocalDate lastDate = month.atEndOfMonth(); + if (lastDate.isAfter(LocalDate.now())) { + lastDate = LocalDate.now(); + } - if (provisionalReceipt.getTotalSaleAmount().compareTo(BigDecimal.ZERO) == 0 && - provisionalReceipt.getOrderAmount().compareTo(BigDecimal.ZERO) == 0) { - // 상황 2: 정산 데이터 자체가 없음 - throw new SettlementException(SettlementErrorCode.SETTLEMENT_DATA_EMPTY); + while (!dateLoop.isAfter(lastDate)) { + BigDecimal amount = dailySums.getOrDefault(dateLoop, BigDecimal.ZERO); + result.add(new FranchiseDailyGraphResponse(dateLoop, amount)); + dateLoop = dateLoop.plusDays(1); } - // 2. 가상 전표(Line) 생성 (요약 정보만 표시) - List provisionalLines = new ArrayList<>(); - provisionalLines.add(DailyReceiptLine.builder() - .lineType(VoucherType.SALES) - .amount(provisionalReceipt.getTotalSaleAmount()) - .description(date + " 실시간 총 매출 (가집계)") - .occurredAt(date.atStartOfDay()) - .build()); - provisionalLines.add(DailyReceiptLine.builder() - .lineType(VoucherType.ORDER) - .amount(provisionalReceipt.getOrderAmount()) - .description(date + " 실시간 총 발주 (가집계)") - .occurredAt(date.atStartOfDay()) - .build()); - - // 3. PDF 생성 - byte[] pdfBytes = fileService.createDailyReceiptPdf(provisionalReceipt, provisionalLines); - - // 4. MinIO 업로드 (임시 폴더) - String fileName = "settlement/provisional/FR_" + franchiseId + "_Daily_Preview_" + date + "_" - + System.currentTimeMillis() + ".pdf"; - minioService.uploadFile(pdfBytes, fileName, "application/pdf", BucketName.SETTLEMENTS); - String fileUrl = minioService.getFileUrl(fileName, BucketName.SETTLEMENTS); - - // 5. DB에 메타데이터 저장 (추적용) - documentService.save(SettlementDocument.builder() - .periodType(PeriodType.DAILY) - .documentType(DocumentType.PROVISIONAL_RECEIPT_PDF) - .documentOwner(DocumentOwner.FRANCHISE) - .storageProvider("MINIO") - .bucket(BucketName.SETTLEMENTS.getBucketName()) - .franchiseId(franchiseId) - .objectKey(fileName) - .fileUrl(fileUrl) - .fileName(fileName.substring(fileName.lastIndexOf("/") + 1)) - .contentType("application/pdf") - .fileSize((long) pdfBytes.length) - .build()); - - return fileUrl; + return result; } + } diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQInventoryFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQInventoryFacade.java index 2a53956de..1ef9762a2 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQInventoryFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQInventoryFacade.java @@ -424,7 +424,7 @@ public List convertsSerialCode(List boxes) { @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) public Void decreaseInventory(@Valid InventoryBatchRequest inventoryBatchRequest) { List serialCodes = convertsSerialCode(inventoryBatchRequest.boxes()); - inventoryService.updateShippingStatus(serialCodes); + inventoryService.updateShippingStatus(serialCodes, LogType.SHIPPING); List logs = convert(inventoryBatchRequest); inventoryLogService.recordInventoryLog(logs); @@ -511,6 +511,7 @@ public List convert(InventoryBatchRequest request) { @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) public Void disposalInventory(DisposalRequest request, Long locationId) { String actorTypeRaw = request.actorType().toUpperCase(); + String disposalTransactionCode = "DISP-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase(); // 1) 사용자가 선택한 재고를 boxCode 기준으로 전체 확장 List expandedIds = inventoryService.expandInventoryIdsByBoxCode( @@ -538,7 +539,7 @@ public Void disposalInventory(DisposalRequest request, Long locationId) { inv.getProductId(), pInfo != null ? pInfo.productName() : "Unknown", inv.getBoxCode(), - null, + disposalTransactionCode, LogType.DISPOSAL, 1, LocationType.HQ, @@ -561,7 +562,7 @@ public Void disposalInventory(DisposalRequest request, Long locationId) { inv.getProductId(), pInfo != null ? pInfo.productName() : "Unknown", inv.getBoxCode(), - null, + disposalTransactionCode, LogType.DISPOSAL, 1, LocationType.FACTORY, @@ -584,7 +585,7 @@ public Void disposalInventory(DisposalRequest request, Long locationId) { inv.getProductId(), pInfo != null ? pInfo.productName() : "Unknown", inv.getBoxCode(), - null, + disposalTransactionCode, LogType.DISPOSAL, 1, LocationType.FRANCHISE, @@ -628,10 +629,6 @@ public void resetSafetyStock(Long locationId, Long productId) { publishStockAlert("FACTORY", locationId); } - public boolean verifyAdminPassword(Long userId, String password) { - return userManagementService.verifyPassword(userId, password); - } - private String nullToDash(String value) { return value == null ? "-" : value; } @@ -651,7 +648,7 @@ private void publishStockAlert(String locationTypeRaw, Long locationId) { int expirationCount = expirationAlerts.size(); String subject = "FACTORY".equals(locationType) ? "본사(공장)" - : "가맹점(" + locationId + ")"; + : "가맹점(" + resolveFranchiseName(locationId) + ")"; String message = "[재고 알림] %s - 안전재고 부족 %d건, 유통기한 임박 %d건" .formatted(subject, lowStockCount, expirationCount); @@ -673,8 +670,11 @@ private void publishStockAlert(String locationTypeRaw, Long locationId) { } private List resolveRecipientUserIds(String locationType, Long locationId) { - UserRole locationRole = "FACTORY".equals(locationType) ? UserRole.FACTORY : UserRole.FRANCHISE; - List locationUsers = userManagementService.getActiveUserIdsByRoleAndBusinessUnitId(locationRole, locationId); + if ("FRANCHISE".equals(locationType)) { + return userManagementService.getActiveUserIdsByRoleAndBusinessUnitId(UserRole.FRANCHISE, locationId); + } + + List locationUsers = userManagementService.getActiveUserIdsByRoleAndBusinessUnitId(UserRole.FACTORY, locationId); List hqUsers = userManagementService.getActiveUserIdsByRole(UserRole.HQ); return java.util.stream.Stream.concat(locationUsers.stream(), hqUsers.stream()) .distinct() @@ -688,6 +688,16 @@ private long resolveUserStockAlertTargetId(String locationType, Long locationId, return FACTORY_STOCK_ALERT_TARGET_BASE + (locationId * TARGET_LOCATION_MULTIPLIER) + userId; } + private String resolveFranchiseName(Long franchiseId) { + if (franchiseId == null) { + return "-"; + } + + return franchiseRepository.findByFranchiseIdAndDeletedAtIsNull(franchiseId) + .map(Franchise::getName) + .orElse("ID:" + franchiseId); + } + private List readListCache(String key, TypeReference> typeRef) { try { String cached = redisTemplate.opsForValue().get(key); diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQReturnFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQReturnFacade.java index 8b4559a2d..89154bc1c 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQReturnFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQReturnFacade.java @@ -6,6 +6,8 @@ import com.chaing.core.dto.info.ProductInfo; import com.chaing.core.enums.ReturnItemStatus; import com.chaing.domain.businessunits.service.impl.FranchiseServiceImpl; +import com.chaing.domain.inventories.entity.FranchiseInventory; +import com.chaing.domain.inventories.entity.HQInventory; import com.chaing.domain.inventories.service.InventoryService; import com.chaing.domain.orders.service.FranchiseOrderService; import com.chaing.domain.products.service.ProductService; @@ -178,90 +180,49 @@ public HQReturnDetailResponse getReturn(String returnCode) { // 발주 코드 조회 String orderCode = franchiseOrderService.getOrderCodeByOrderId(returnInfo.franchiseOrderId()); - // 연관 발주 제품 정보 조회 - // Map - Map orderItemIdByReturnItemId = franchiseReturnService.getReturnItemId(returnCode); - List orderItemIds = orderItemIdByReturnItemId.values().stream().toList(); - List returnItemIds = orderItemIdByReturnItemId.keySet().stream().toList(); - - // Map - Map itemInspectionByBoxCode; - Map orderItemIdBySerialCode; - List serialCodes; - Map boxCodeBySerialCode; - Map productIdBySerialCode; + // returnItem의 boxCode 목록 조회 + List boxCodes = franchiseReturnService.getReturnItemBoxCodes(returnCode); + log.info("boxCodes: {}", boxCodes); + // 상태에 따라 조회 대상 인벤토리 분기 Set hqStatuses = Set.of( ReturnStatus.COMPLETED, ReturnStatus.INSPECTING, ReturnStatus.DEDUCTION_COMPLETED, ReturnStatus.DEDUCTION_REJECTED - ); - - if (!hqStatuses.contains(returnInfo.status())) { - // FranchiseInventory에 제품 존재 - // 재고 정보 조회 - // Map - orderItemIdBySerialCode = inventoryService.getSerialCodesByOrderItemIdsFromFranchise(orderItemIds); - // List - serialCodes = orderItemIdBySerialCode.keySet().stream().toList(); - // Map - boxCodeBySerialCode = inventoryService.getBoxCodeFromFranchise(serialCodes); - // Map - productIdBySerialCode = inventoryService.getProductIdBySerialCodeFromFranchise(serialCodes); - // Map - itemInspectionByBoxCode = inventoryService.getReturnItemInspectionFromFranchise(orderItemIds); + ); + + List items; + + if (hqStatuses.contains(returnInfo.status())) { + // 배송완료, 검수중 → HQInventory에서 조회 + List inventories = inventoryService.getHQInventoriesByBoxCodes(boxCodes); + log.info("배송완료, 검수중"); + log.info("inventories: {}", inventories); + + items = inventories.stream() + .map(inv -> FranchiseReturnItemDetailResponse.builder() + .boxCode(inv.getBoxCode()) + .serialCode(inv.getSerialCode()) + .isInspected(inv.getIsInspected()) + .status(inv.getReturnItemStatus() != null ? inv.getReturnItemStatus() : ReturnItemStatus.BEFORE_INSPECTION) + .build()) + .toList(); } else { - // HQInventory에 제품 존재 - // 재고 정보 조회 - // Map - orderItemIdBySerialCode = inventoryService.getSerialCodesByOrderItemIdsFromHQ(orderItemIds); - // List - serialCodes = orderItemIdBySerialCode.keySet().stream().toList(); - // Map - boxCodeBySerialCode = inventoryService.getBoxCodeFromHQ(serialCodes); - // Map - productIdBySerialCode = inventoryService.getProductIdBySerialCodeFromHQ(serialCodes); - // Map - itemInspectionByBoxCode = inventoryService.getReturnItemInspection(orderItemIdByReturnItemId); + // 대기, 접수, 배송대기, 배송중 → FranchiseInventory에서 조회 + List inventories = inventoryService.getFranchiseInventoriesByBoxCodes(boxCodes); + log.info("대기, 접수, 배송대기, 배송중"); + + items = inventories.stream() + .map(inv -> FranchiseReturnItemDetailResponse.builder() + .boxCode(inv.getBoxCode()) + .serialCode(inv.getSerialCode()) + .isInspected(false) + .status(ReturnItemStatus.BEFORE_INSPECTION) + .build()) + .toList(); } - log.info("productIdBySerialCode: {}", productIdBySerialCode); - - // 제품 정보 조회 - List productIds = productIdBySerialCode.values().stream().toList(); - log.info("productIds: {}", productIds); - // Map - Map productInfoByProductId = productService.getProductInfos(productIds); - log.info("productInfoByProductId: {}", productInfoByProductId); - - // 반품 제품 DTO - List items = boxCodeBySerialCode.entrySet().stream() - .map(entry -> { - log.info("entry: {}", entry); - Long productId = productIdBySerialCode.get(entry.getKey()); - log.info("productId: {}", productId); - ProductInfo productInfo = productInfoByProductId.get(productId); - log.info("productInfo: {}", productInfo); - - ReturnItemInspection inspection = itemInspectionByBoxCode.get(entry.getValue()); - - if (inspection == null) { - throw new FranchiseReturnException(FranchiseReturnErrorCode.RETURN_ITEM_NOT_FOUND); - } - - if (productInfo == null) { - throw new FranchiseReturnException(FranchiseReturnErrorCode.PRODUCT_NOT_FOUND); - } - - return FranchiseReturnItemDetailResponse.builder() - .boxCode(entry.getValue()) - .serialCode(entry.getKey()) - .status(inspection.status()) - .build(); - }) - .toList(); - // 반환 HQReturnDetailResponse result = HQReturnDetailResponse.builder() .returnCode(returnCode) diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQSettlementAdjustmentFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQSettlementAdjustmentFacade.java index 5d90923c5..2373b5b44 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQSettlementAdjustmentFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQSettlementAdjustmentFacade.java @@ -12,72 +12,127 @@ import java.math.BigDecimal; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import java.util.UUID; @Component @RequiredArgsConstructor @Transactional(readOnly = true) public class HQSettlementAdjustmentFacade { - private final com.chaing.domain.settlements.service.SettlementAdjustmentService adjustmentService; - - // 1. 드롭다운용 가맹점 목록 조회 - public List getFranchisesForDropdown() { - // TODO: FranchiseService 등을 통해 실제 목룍 조회 - return List.of(); - } - - // 2. 드롭다운용 전표 유형 리스트 조회 - public List getAdjustmentTypes() { - return List.of(VoucherType.values()); // 실제 enum 값들 반환 - } - - // 3. 조정 전표 등록 (생성) - @Transactional - public void createAdjustment(HQSettlementAdjustmentVoucherRequest request) { - com.chaing.domain.settlements.entity.SettlementAdjustment adjustment = com.chaing.domain.settlements.entity.SettlementAdjustment - .builder() - .settlementVoucherId(0L) // TODO: 전표 ID 매핑 또는 참조 로직 추가 - .adjustmentCode("AD-" + System.currentTimeMillis()) // 임시 코드 채번 - .franchiseId(request.franchiseId()) - .voucherType(request.type()) - .occurredAt(request.occurredAt().atStartOfDay()) - .isMinus(request.isMinus()) - .createdByName("System") // TODO: Security/Auth에서 작성자 주입 - .adjustmentAmount(BigDecimal.valueOf(request.amount())) - .reason(request.reason()) - .createdBy(0L) // TODO: 실제 작성자 ID 주입 - .build(); - - adjustmentService.create(adjustment); - } - - // 4. 조정 전표 목록 조회 (페이징) - public Page getAdjustments(HQSettlementAdjustmentListRequest request) { - int page = request.page() != null ? request.page() : 0; - int size = request.size() != null ? request.size() : 20; - Pageable pageable = PageRequest.of(page, size); - - Page adjustments = adjustmentService.getAll( - request.franchiseId(), request.type(), pageable); - - // (주의: request.month() 조건은 SettlementAdjustment에 현재 필드가 없으므로, 향후 월별 필터 로직 추가 - // 필요) - - List dtos = adjustments.stream() - .map(a -> HQAdjustmentResponse.of( - a.getSettlementAdjustmentId(), - "가맹점명 (추후 연동)", // TODO: Franchise API 연동 - a.getVoucherType(), - a.getOccurredAt().toLocalDate(), - a.getAdjustmentAmount().longValue(), - a.getIsMinus(), - a.getReason())) - .collect(Collectors.toList()); - - return new PageImpl<>(dtos, pageable, adjustments.getTotalElements()); - } + private final com.chaing.domain.settlements.service.SettlementAdjustmentService adjustmentService; + private final com.chaing.domain.businessunits.repository.FranchiseRepository franchiseRepository; + private final com.chaing.domain.users.repository.UserRepository userRepository; + private final com.chaing.domain.settlements.repository.interfaces.MonthlySettlementRepository monthlySettlementRepository; + private final com.chaing.domain.settlements.repository.interfaces.SettlementVoucherRepository settlementVoucherRepository; + + // 1. 드롭다운용 가맹점 목록 조회 + public List getFranchisesForDropdown() { + return franchiseRepository.findAll().stream() + .map(f -> HQAdjustmentFranchiseResponse.of(f.getFranchiseId(), f.getName())) + .collect(Collectors.toList()); + } + + // 2. 드롭다운용 전표 유형 리스트 조회 + public List getAdjustmentTypes() { + return List.of(VoucherType.values()); // 실제 enum 값들 반환 + } + + // 3. 조정 전표 등록 (생성) + @Transactional + public void createAdjustment(HQSettlementAdjustmentVoucherRequest request, + com.chaing.api.security.principal.UserPrincipal principal) { + String createdByName = userRepository.findById(principal.getId()) + .map(com.chaing.domain.users.entity.User::getUsername) + .orElse("Unknown"); + + com.chaing.domain.settlements.enums.AdjustmentDirection direction = request.direction(); + com.chaing.domain.returns.enums.ReturnType returnType = request.returnType(); + + // ⭐️ 반품 유형(RETURN)일 경우 자동 매핑 로직 적용 + if (request.type() == com.chaing.domain.settlements.enums.VoucherType.RETURN && returnType != null) { + if (returnType == com.chaing.domain.returns.enums.ReturnType.MISORDER) { + direction = com.chaing.domain.settlements.enums.AdjustmentDirection.DECREASE; // 오배송 -> + // 가맹점 차감 + } else if (returnType == com.chaing.domain.returns.enums.ReturnType.PRODUCT_DEFECT) { + direction = com.chaing.domain.settlements.enums.AdjustmentDirection.INCREASE; // 상품하자 -> + // 본사 보전 + } + } + + BigDecimal amount = BigDecimal.valueOf(request.amount()); + BigDecimal finalAmount = (direction == com.chaing.domain.settlements.enums.AdjustmentDirection.INCREASE) + ? amount + : amount.negate(); + + // ⭐️ 추가: 기존 정산 전표(SettlementVoucher) 자동 조회 및 매핑 + java.time.YearMonth month = request.settlementMonth(); + Long settlementVoucherId = monthlySettlementRepository + .findByFranchiseIdAndSettlementMonth(request.franchiseId(), month) + .map(m -> settlementVoucherRepository + .findAllByMonthlySettlementId(m.getMonthlySettlementId())) + .filter(list -> !list.isEmpty()) + .map(list -> list.get(0).getSettlementVoucherId()) + .orElse(null); // ⭐️ 수정: 전표가 없더라도 null로 설정하여 등록 허용 + + com.chaing.domain.settlements.entity.SettlementAdjustment adjustment = com.chaing.domain.settlements.entity.SettlementAdjustment + .builder() + .settlementVoucherId(settlementVoucherId) // ⭐️ 자동 매핑된 ID 사용 + .adjustmentCode("AD-" + UUID.randomUUID().toString().substring(0, 8).toUpperCase()) + .franchiseId(request.franchiseId()) + .voucherType(request.type()) + .occurredAt(request.occurredAt().atStartOfDay()) + .settlementMonth(request.settlementMonth().toString()) + .isMinus(direction == com.chaing.domain.settlements.enums.AdjustmentDirection.DECREASE) + .returnType(returnType) // ⭐️ 반품 사유 저장 + .createdByName(createdByName) + .adjustmentAmount(finalAmount) + .reason(request.reason()) + .createdBy(principal.getId()) + .build(); + + adjustmentService.create(adjustment); + } + + // 4. 조정 전표 목록 조회 (페이징) + public Page getAdjustments(HQSettlementAdjustmentListRequest request) { + int page = request.page() != null ? request.page() : 0; + int size = request.size() != null ? request.size() : 20; + Pageable pageable = PageRequest.of(page, size); + + String settlementMonth = request.month() != null ? request.month().toString() : null; + + Page adjustments = adjustmentService.getAll( + request.franchiseId(), request.type(), settlementMonth, pageable); + + List franchiseIds = adjustments.stream() + .map(com.chaing.domain.settlements.entity.SettlementAdjustment::getFranchiseId) + .distinct() + .collect(Collectors.toList()); + + Map franchiseNameMap = franchiseRepository.findNamesByIds(franchiseIds).stream() + .collect(Collectors.toMap( + row -> (Long) row[0], + row -> (String) row[1])); + + List dtos = adjustments.stream() + .map(a -> HQAdjustmentResponse.of( + a.getSettlementAdjustmentId(), + franchiseNameMap.getOrDefault(a.getFranchiseId(), "Unknown"), + a.getVoucherType(), + a.getOccurredAt().toLocalDate(), + a.getAdjustmentAmount().longValue(), + a.getIsMinus() ? com.chaing.domain.settlements.enums.AdjustmentDirection.DECREASE + : com.chaing.domain.settlements.enums.AdjustmentDirection.INCREASE, + a.getReason(), + a.getSettlementMonth(), + a.getReturnType())) // ⭐️ 응답에 반품 사유 포함 + .collect(Collectors.toList()); + + return new PageImpl<>(dtos, pageable, adjustments.getTotalElements()); + } } diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQSettlementConfirmFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQSettlementConfirmFacade.java index 673583f35..00333f522 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQSettlementConfirmFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQSettlementConfirmFacade.java @@ -3,6 +3,15 @@ import com.chaing.api.dto.hq.settlement.request.HQSettlementConfirmMonthlyRequest; import com.chaing.api.dto.hq.settlement.response.HQConfirmFranchiseResponse; import com.chaing.api.dto.hq.settlement.response.HQConfirmStatusCountResponse; +import com.chaing.domain.businessunits.entity.Franchise; +import com.chaing.domain.businessunits.repository.FranchiseRepository; +import com.chaing.domain.settlements.entity.DailySettlementReceipt; +import com.chaing.domain.settlements.entity.MonthlySettlement; +import com.chaing.domain.settlements.enums.SettlementStatus; +import com.chaing.domain.settlements.repository.interfaces.MonthlySettlementRepository; +import com.chaing.domain.settlements.repository.interfaces.SettlementAdjustmentRepository; +import com.chaing.domain.settlements.service.DailySettlementService; +import com.chaing.domain.settlements.service.MonthlySettlementService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; @@ -11,7 +20,9 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; import java.time.YearMonth; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -21,36 +32,74 @@ @Transactional(readOnly = true) public class HQSettlementConfirmFacade { - private final com.chaing.domain.settlements.service.MonthlySettlementService monthlyService; + private final MonthlySettlementService monthlyService; + private final DailySettlementService dailyService; + private final FranchiseRepository franchiseRepository; + private final SettlementAdjustmentRepository adjustmentRepository; + private final MonthlySettlementRepository monthlyRepo; // 1. 상단 상태별 카운트 조회 public HQConfirmStatusCountResponse getMonthlyStatusCounts(YearMonth month) { - Map counts = monthlyService.getStatusCounts(month); - return HQConfirmStatusCountResponse.of( - counts.getOrDefault("CALCULATED", 0L), - counts.getOrDefault("CONFIRM_REQUESTED", 0L), - counts.getOrDefault("CONFIRMED", 0L)); + List allFranchises = franchiseRepository.findAll(); + List existingSettlements = monthlyService.getAllByMonth(month, null); + Map statusMap = existingSettlements.stream() + .collect(Collectors.toMap(MonthlySettlement::getFranchiseId, MonthlySettlement::getStatus)); + + long draftCount = 0; + long requestedCount = 0; + long confirmedCount = 0; + + for (Franchise f : allFranchises) { + SettlementStatus status = statusMap.getOrDefault(f.getFranchiseId(), SettlementStatus.CALCULATED); + if (status == SettlementStatus.CALCULATED) draftCount++; + else if (status == SettlementStatus.CONFIRM_REQUESTED) requestedCount++; + else if (status == SettlementStatus.CONFIRMED) confirmedCount++; + } + + return HQConfirmStatusCountResponse.of(draftCount, requestedCount, confirmedCount); } // 2. 월별 정산 확정 목록 페이징 조회 public Page getMonthlyConfirmList(HQSettlementConfirmMonthlyRequest request) { - List settlements = monthlyService - .getAllByMonth(request.month(), request.keyword()); - - if (request.status() != null) { - settlements = settlements.stream() - .filter(s -> s.getStatus() == request.status()) + // 모든 가맹점 조회 + List franchises = franchiseRepository.findAll(); + if (request.keyword() != null && !request.keyword().trim().isEmpty()) { + franchises = franchises.stream() + .filter(f -> f.getName().contains(request.keyword())) .collect(Collectors.toList()); } - List dtos = settlements.stream() - .map(s -> HQConfirmFranchiseResponse.of( - s.getFranchiseId(), - "가맹점명 (추후 연동)", // TODO: Franchise API 연동 - s.getFinalSettlementAmount().longValue(), - s.getStatus())) - .collect(Collectors.toList()); + List existingSettlements = monthlyService.getAllByMonth(request.month(), null); + Map settlementMap = existingSettlements.stream() + .collect(Collectors.toMap(MonthlySettlement::getFranchiseId, s -> s)); + + List dtos = new ArrayList<>(); + for (Franchise f : franchises) { + MonthlySettlement ms = settlementMap.get(f.getFranchiseId()); + SettlementStatus status = (ms != null) ? ms.getStatus() : SettlementStatus.CALCULATED; + + // 상태 필터링 + if (request.status() != null && status != request.status()) { + continue; + } + + long finalAmount; + if (status == SettlementStatus.CONFIRMED && ms != null) { + // 최종 확정된 경우엔 저장된 스냅샷 금액 사용 + finalAmount = ms.getFinalSettlementAmount().longValue(); + } else { + // 그 외엔(작성중, 확정요청) 실시간 집계 금액 표시 + finalAmount = calculatePotentialAmount(f.getFranchiseId(), request.month()); + } + + dtos.add(HQConfirmFranchiseResponse.of( + f.getFranchiseId(), + f.getName(), + finalAmount, + status)); + } + // 페이지네이션 int page = request.page() != null ? request.page() : 0; int size = request.size() != null ? request.size() : 20; Pageable pageable = PageRequest.of(page, size); @@ -65,37 +114,90 @@ public Page getMonthlyConfirmList(HQSettlementConfir return new PageImpl<>(dtos.subList(start, end), pageable, dtos.size()); } - // 3. 상태 변경: 작성중 -> 확정요청 + private long calculatePotentialAmount(Long franchiseId, YearMonth month) { + List receipts = dailyService.getAllByFranchiseAndDateRange( + franchiseId, month.atDay(1), month.atEndOfMonth()); + + BigDecimal totalFinal = receipts.stream() + .map(DailySettlementReceipt::getFinalAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal adj = adjustmentRepository.sumAdjustmentAmountByMonth(month.toString(), franchiseId); + return totalFinal.add(adj != null ? adj : BigDecimal.ZERO).longValue(); + } + @Transactional public void requestConfirm(Long franchiseId, YearMonth month) { - com.chaing.domain.settlements.entity.MonthlySettlement ms = monthlyService.getByFranchiseAndMonth(franchiseId, - month); + MonthlySettlement ms = getOrCreateMonthlySettlement(franchiseId, month); monthlyService.requestConfirm(ms.getMonthlySettlementId()); } - // 4. 상태 변경: 확정요청 -> 최종확정 (단건) @Transactional public void finalizeConfirm(Long franchiseId, YearMonth month) { - com.chaing.domain.settlements.entity.MonthlySettlement ms = monthlyService.getByFranchiseAndMonth(franchiseId, - month); + MonthlySettlement ms = getOrCreateMonthlySettlement(franchiseId, month); monthlyService.confirm(ms.getMonthlySettlementId()); } - // 5. 상태 롤백: 확정요청/최종확정 -> 작성중 @Transactional public void rollbackToDraft(Long franchiseId, YearMonth month) { - com.chaing.domain.settlements.entity.MonthlySettlement ms = monthlyService.getByFranchiseAndMonth(franchiseId, - month); + MonthlySettlement ms = getOrCreateMonthlySettlement(franchiseId, month); monthlyService.rollback(ms.getMonthlySettlementId()); } - // 6. 상태 변경: 일괄 최종확정 @Transactional public void finalizeAll(YearMonth month) { - List settlements = monthlyService.getAllByMonth(month, - null); + List settlements = monthlyService.getAllByMonth(month, null); settlements.stream() - .filter(s -> s.getStatus() == com.chaing.domain.settlements.enums.SettlementStatus.CONFIRM_REQUESTED) + .filter(s -> s.getStatus() == SettlementStatus.CONFIRM_REQUESTED) .forEach(s -> monthlyService.confirm(s.getMonthlySettlementId())); } + + private MonthlySettlement getOrCreateMonthlySettlement(Long franchiseId, YearMonth month) { + return monthlyService.findByFranchiseAndMonth(franchiseId, month) + .orElseGet(() -> { + List receipts = dailyService.getAllByFranchiseAndDateRange( + franchiseId, month.atDay(1), month.atEndOfMonth()); + + BigDecimal totalSale = BigDecimal.ZERO; + BigDecimal orderAmt = BigDecimal.ZERO; + BigDecimal delivery = BigDecimal.ZERO; + BigDecimal commission = BigDecimal.ZERO; + BigDecimal refund = BigDecimal.ZERO; + BigDecimal loss = BigDecimal.ZERO; + BigDecimal finalAmt = BigDecimal.ZERO; + + for (DailySettlementReceipt r : receipts) { + totalSale = totalSale.add(r.getTotalSaleAmount()); + orderAmt = orderAmt.add(r.getOrderAmount()); + delivery = delivery.add(r.getDeliveryFee()); + commission = commission.add(r.getCommissionFee()); + refund = refund.add(r.getRefundAmount()); + loss = loss.add(r.getLossAmount()); + finalAmt = finalAmt.add(r.getFinalAmount()); + } + + BigDecimal adj = adjustmentRepository.sumAdjustmentAmountByMonth(month.toString(), franchiseId); + BigDecimal currentAdj = adj != null ? adj : BigDecimal.ZERO; + + return createAndSaveMonthlySettlement(franchiseId, month, totalSale, orderAmt, delivery, commission, refund, loss, currentAdj, finalAmt.add(currentAdj)); + }); + } + + private MonthlySettlement createAndSaveMonthlySettlement(Long franchiseId, YearMonth month, + BigDecimal totalSale, BigDecimal orderAmt, BigDecimal delivery, BigDecimal commission, BigDecimal refund, BigDecimal loss, BigDecimal adjAmt, BigDecimal finalAmt) { + MonthlySettlement ms = MonthlySettlement.builder() + .franchiseId(franchiseId) + .settlementMonth(month) + .totalSaleAmount(totalSale) + .orderAmount(orderAmt) + .deliveryFee(delivery) + .commissionFee(commission) + .refundAmount(refund) + .lossAmount(loss) + .adjustmentAmount(adjAmt) + .finalSettlementAmount(finalAmt) + .status(SettlementStatus.CALCULATED) + .build(); + return monthlyRepo.save(ms); + } } diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQSettlementFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQSettlementFacade.java index e2065fda2..ab073e8f2 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQSettlementFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/hq/HQSettlementFacade.java @@ -6,9 +6,19 @@ import com.chaing.api.dto.hq.settlement.response.*; import com.chaing.domain.settlements.enums.PeriodType; import com.chaing.domain.settlements.enums.VoucherType; +import com.chaing.domain.settlements.enums.SettlementStatus; import com.chaing.domain.settlements.service.DailySettlementService; import com.chaing.domain.settlements.service.MonthlySettlementService; import com.chaing.domain.settlements.service.SettlementFileService; +import com.chaing.domain.settlements.service.SettlementDocumentService; +import com.chaing.domain.settlements.repository.interfaces.SettlementAdjustmentRepository; +import com.chaing.domain.settlements.repository.interfaces.SettlementVoucherRepository; +import com.chaing.domain.settlements.entity.MonthlySettlement; +import com.chaing.domain.settlements.entity.SettlementDocument; +import com.chaing.domain.settlements.exception.SettlementException; +import com.chaing.domain.settlements.exception.SettlementErrorCode; +import com.chaing.domain.settlements.enums.DocumentType; +import com.chaing.domain.settlements.enums.DocumentOwner; import com.chaing.core.enums.BucketName; import com.chaing.core.service.MinioService; import lombok.RequiredArgsConstructor; @@ -18,16 +28,40 @@ import org.springframework.transaction.annotation.Transactional; import com.chaing.domain.businessunits.repository.FranchiseRepository; import com.chaing.domain.businessunits.entity.Franchise; +import com.chaing.domain.orders.entity.FranchiseOrder; +import com.chaing.domain.orders.entity.FranchiseOrderItem; +import com.chaing.domain.orders.enums.FranchiseOrderStatus; +import com.chaing.domain.orders.repository.FranchiseOrderItemRepository; +import com.chaing.domain.orders.repository.FranchiseOrderRepository; +import com.chaing.domain.sales.entity.SalesItem; +import com.chaing.domain.sales.repository.FranchiseSalesItemRepository; +import com.chaing.domain.settlements.entity.DailyReceiptLine; +import com.chaing.domain.settlements.entity.DailySettlementReceipt; +import com.chaing.domain.returns.entity.Returns; +import com.chaing.domain.returns.enums.ReturnStatus; +import com.chaing.domain.returns.enums.ReturnType; +import com.chaing.domain.returns.entity.ReturnItem; +import com.chaing.domain.returns.repository.FranchiseReturnItemRepository; +import com.chaing.domain.returns.repository.FranchiseReturnRepository; +import com.chaing.domain.settlements.entity.SettlementVoucher; import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.YearMonth; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import com.chaing.api.dto.franchise.settlement.response.FranchiseSalesItemResponse; +import com.chaing.api.dto.franchise.settlement.response.FranchiseOrderItemResponse; +import com.chaing.api.dto.franchise.settlement.response.FranchiseDailyGraphResponse; +import com.chaing.domain.products.entity.Product; +import com.chaing.domain.products.repository.ProductRepository; @Slf4j @Component @@ -38,79 +72,98 @@ public class HQSettlementFacade { private final MonthlySettlementService monthlyService; private final SettlementFileService fileService; private final MinioService minioService; - private final com.chaing.domain.settlements.service.SettlementDocumentService documentService; - private final com.chaing.domain.settlements.repository.interfaces.SettlementVoucherRepository voucherRepository; + private final SettlementDocumentService documentService; + private final SettlementVoucherRepository voucherRepository; + private final SettlementAdjustmentRepository adjustmentRepository; private final FranchiseRepository franchiseRepository; + private final FranchiseSalesItemRepository salesItemRepository; + private final FranchiseOrderRepository orderRepository; + private final FranchiseOrderItemRepository orderItemRepository; + private final FranchiseReturnRepository returnRepository; + private final FranchiseReturnItemRepository returnItemRepository; + private final ProductRepository productRepository; + // 1. 일별 조회 @Transactional(readOnly = true) public HQSettlementSummaryResponse getDailySummary(HQSettlementDailySummaryRequest request) { - List receipts = dailyService - .getAllByDate(request.date(), null); - - BigDecimal totalOrder = receipts.stream() - .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getOrderAmount) - .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal totalSale = receipts.stream() - .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getTotalSaleAmount) - .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal totalCommission = receipts.stream() - .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getCommissionFee) - .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal totalDelivery = receipts.stream() - .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getDeliveryFee) - .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal totalRefund = receipts.stream() - .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getRefundAmount) - .reduce(BigDecimal.ZERO, BigDecimal::add); - BigDecimal totalLoss = receipts.stream() - .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getLossAmount) - .reduce(BigDecimal.ZERO, BigDecimal::add); + // 모든 가맹점 조회 + List franchises = franchiseRepository.findAll(); + + BigDecimal hqTotalFinal = BigDecimal.ZERO; + BigDecimal totalOrder = BigDecimal.ZERO; + BigDecimal totalSale = BigDecimal.ZERO; + BigDecimal totalCommission = BigDecimal.ZERO; + BigDecimal totalDelivery = BigDecimal.ZERO; + BigDecimal totalRefund = BigDecimal.ZERO; + BigDecimal totalLoss = BigDecimal.ZERO; + + for (Franchise franchise : franchises) { + AggregationResult result = getInternalDailyAggregation(franchise.getFranchiseId(), + request.date()); + DailySettlementReceipt r = result.receipt(); + + totalOrder = totalOrder.add(r.getOrderAmount()); + totalSale = totalSale.add(r.getTotalSaleAmount()); + totalCommission = totalCommission.add(r.getCommissionFee()); + totalDelivery = totalDelivery.add(r.getDeliveryFee()); + totalRefund = totalRefund.add(r.getRefundAmount()); + totalLoss = totalLoss.add(r.getLossAmount()); + } - // 본사 관점 최종 정산 금액 = 발주 매출 + 수수료 수익 + 배송 수익 - 반품 차감액 - 본사 손실 - BigDecimal hqTotalFinal = totalOrder.add(totalCommission).add(totalDelivery) - .subtract(totalRefund).subtract(totalLoss); + hqTotalFinal = totalOrder.add(totalCommission) + .subtract(totalDelivery).subtract(totalRefund).subtract(totalLoss); + + // 조정 금액 합산 (해당 월의 전체 조정) + String monthStr = request.date().toString().substring(0, 7); + BigDecimal totalAdjustment = adjustmentRepository.sumAdjustmentAmountByMonth(monthStr, null); + hqTotalFinal = hqTotalFinal.add(totalAdjustment); return HQSettlementSummaryResponse.of(hqTotalFinal, totalOrder, totalSale, totalCommission, - totalDelivery, - totalRefund, totalLoss); + totalDelivery, totalRefund, totalLoss, totalAdjustment); } @Transactional(readOnly = true) public Page getDailyFranchises(HQSettlementDailyFranchisesRequest request) { - // 1. 도메인 서비스에서 날짜 및 키워드로 전체 목록 조회 - List receipts = dailyService - .getAllByDate(request.date(), request.keyword()); - - // 2. 가맹점 정보 벌크 조회 (N+1 쿼리 방지) - List franchiseIds = receipts.stream() - .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getFranchiseId) - .distinct() - .collect(Collectors.toList()); - - Map franchiseNameMap = franchiseRepository.findAllById(franchiseIds).stream() - .collect(Collectors.toMap(Franchise::getFranchiseId, Franchise::getName)); + // 1. 가맹점 목록 조회 (검색어 포함) + List franchises; + if (request.keyword() != null && !request.keyword().trim().isEmpty()) { + // 간단한 인메모리 필터링 (가맹점 수가 적으므로) + franchises = franchiseRepository.findAll().stream() + .filter(f -> f.getName().contains(request.keyword())) + .collect(Collectors.toList()); + } else { + franchises = franchiseRepository.findAll(); + } - List dtos = receipts.stream() - .map(r -> { - String franchiseName = franchiseNameMap.getOrDefault(r.getFranchiseId(), - "Unknown"); - return HQFranchiseSettlementResponse.of( - r.getFranchiseId(), - franchiseName, - r.getTotalSaleAmount(), - r.getOrderAmount(), - r.getDeliveryFee(), - r.getCommissionFee(), - r.getRefundAmount(), - r.getLossAmount(), - r.getFinalAmount(), - com.chaing.domain.settlements.enums.SettlementStatus.CONFIRMED, // 기본값 - r.getSettlementDate()); - }) - .collect(Collectors.toList()); + // 2. 가맹점별 실시간 집계 + List dtos = new ArrayList<>(); + for (Franchise f : franchises) { + AggregationResult result = getInternalDailyAggregation(f.getFranchiseId(), request.date()); + DailySettlementReceipt r = result.receipt(); + + // 개별 가맹점 조정 금액 (해당 월) + String monthStr = request.date().toString().substring(0, 7); + BigDecimal adjAmount = adjustmentRepository.sumAdjustmentAmountByMonth(monthStr, f.getFranchiseId()); + BigDecimal finalAmount = r.getFinalAmount().add(adjAmount); + + dtos.add(HQFranchiseSettlementResponse.of( + f.getFranchiseId(), + f.getName(), + r.getTotalSaleAmount(), + r.getOrderAmount(), + r.getDeliveryFee(), + r.getCommissionFee(), + r.getRefundAmount(), + r.getLossAmount(), + adjAmount, + finalAmount, // 가맹점 관점 최종 정산액 (조정 포함) + SettlementStatus.CONFIRMED, // 가집계이므로 논리적으로 CONFIRMED 또는 PENDING 처리 가능 + r.getSettlementDate())); + } + // 3. 페이지네이션 처리 int page = request.page() != null ? request.page() : 0; int size = request.size() != null ? request.size() : 20; Pageable pageable = PageRequest.of(page, size); @@ -171,12 +224,12 @@ public HQSettlementSummaryResponse getMonthlySummary(HQSettlementMonthlySummaryR .reduce(BigDecimal.ZERO, BigDecimal::add); // 본사 관점 최종 정산 금액 - BigDecimal hqTotalFinal = totalOrder.add(totalCommission).add(totalDelivery) - .subtract(totalRefund).subtract(totalLoss); + BigDecimal totalAdjustment = adjustmentRepository.sumAdjustmentAmountByMonth(request.month().toString(), null); + BigDecimal hqTotalFinal = totalOrder.add(totalCommission) + .subtract(totalDelivery).subtract(totalRefund).subtract(totalLoss).add(totalAdjustment); return HQSettlementSummaryResponse.of(hqTotalFinal, totalOrder, totalSale, totalCommission, - totalDelivery, - totalRefund, totalLoss); + totalDelivery, totalRefund, totalLoss, totalAdjustment); } @Transactional(readOnly = true) @@ -204,6 +257,9 @@ public Page getMonthlyFranchises(HQSettlementMont .map(s -> { String franchiseName = franchiseNameMap.getOrDefault(s.getFranchiseId(), "Unknown"); + BigDecimal adjAmount = adjustmentRepository.sumAdjustmentAmountByMonth(s.getSettlementMonth().toString(), s.getFranchiseId()); + BigDecimal finalSettlementAmount = s.getFinalSettlementAmount().add(adjAmount); + return HQFranchiseSettlementResponse.of( s.getFranchiseId(), franchiseName, @@ -213,7 +269,8 @@ public Page getMonthlyFranchises(HQSettlementMont s.getCommissionFee(), s.getRefundAmount(), s.getLossAmount(), - s.getFinalSettlementAmount(), + adjAmount, + finalSettlementAmount, s.getStatus(), s.getSettlementMonth().atDay(1)); // 기준일 }) @@ -264,8 +321,13 @@ public FranchiseSettlementSummaryResponse getDailyFranchiseSummary(Long franchis HQSettlementFranchiseDailyDetailRequest request) { com.chaing.domain.settlements.entity.DailySettlementReceipt receipt = dailyService .getByFranchiseAndDate(franchiseId, request.date()); + + String franchiseName = franchiseRepository.findById(franchiseId) + .map(Franchise::getName) + .orElse("Unknown Store"); return new FranchiseSettlementSummaryResponse( + franchiseName, receipt.getFinalAmount(), receipt.getTotalSaleAmount(), receipt.getRefundAmount(), @@ -282,7 +344,12 @@ public FranchiseSettlementSummaryResponse getMonthlyFranchiseSummary(Long franch com.chaing.domain.settlements.entity.MonthlySettlement settlement = monthlyService .getByFranchiseAndMonth(franchiseId, request.month()); + String franchiseName = franchiseRepository.findById(franchiseId) + .map(Franchise::getName) + .orElse("Unknown Store"); + return new FranchiseSettlementSummaryResponse( + franchiseName, settlement.getFinalSettlementAmount(), settlement.getTotalSaleAmount(), settlement.getRefundAmount(), @@ -417,7 +484,15 @@ public String getDailyAllSummaryPdf(HQSettlementDailyAllPdfRequest request) { .getAllByDate(request.date(), null); if (receipts.isEmpty()) { - // 상황 2: 정산 데이터 자체가 없음 (휴무일 등) + // DB에 데이터가 없으면 전체 가맹점 실시간 집계 시도 + List franchises = franchiseRepository.findAll(); + receipts = franchises.stream() + .map(f -> getInternalDailyAggregation(f.getFranchiseId(), request.date()) + .receipt()) + .collect(Collectors.toList()); + } + + if (receipts.isEmpty()) { log.warn("[WARN] No receipts found for HQ Daily PDF: {}", request.date()); throw new com.chaing.domain.settlements.exception.SettlementException( com.chaing.domain.settlements.exception.SettlementErrorCode.SETTLEMENT_DATA_EMPTY); @@ -471,6 +546,34 @@ public String getMonthlyAllSummaryPdf(HQSettlementMonthlyAllPdfRequest request) List settlements = monthlyService .getAllByMonth(request.month(), null); + if (settlements.isEmpty()) { + // DB에 데이터가 없으면 전체 가맹점 실시간 집계 시도 + List franchises = franchiseRepository.findAll(); + settlements = new ArrayList<>(); + for (Franchise f : franchises) { + LocalDate startDay = request.month().atDay(1); + LocalDate endDay = request.month().atEndOfMonth(); + LocalDate todayLimit = LocalDate.now(); + if (endDay.isAfter(todayLimit)) endDay = todayLimit; + + List dailyReceipts = new ArrayList<>(); + for (LocalDate d = startDay; !d.isAfter(endDay); d = d.plusDays(1)) { + com.chaing.domain.settlements.entity.DailySettlementReceipt dr = getInternalDailyAggregation( + f.getFranchiseId(), d).receipt(); + if (dr.getTotalSaleAmount().compareTo(BigDecimal.ZERO) > 0 || + dr.getOrderAmount().compareTo(BigDecimal.ZERO) > 0 || + dr.getDeliveryFee().compareTo(BigDecimal.ZERO) > 0) { + dailyReceipts.add(dr); + } + } + + if (!dailyReceipts.isEmpty()) { + settlements.add(aggregateMonthlySettlement(f.getFranchiseId(), request.month(), + dailyReceipts)); + } + } + } + if (settlements.isEmpty()) { throw new com.chaing.domain.settlements.exception.SettlementException( com.chaing.domain.settlements.exception.SettlementErrorCode.SETTLEMENT_DATA_EMPTY); @@ -514,34 +617,59 @@ public String getMonthlyAllSummaryPdf(HQSettlementMonthlyAllPdfRequest request) public String getDailyFranchiseReceiptPdf(Long franchiseId, HQSettlementFranchiseDailyReceiptPdfRequest request) { try { - com.chaing.domain.settlements.entity.DailySettlementReceipt receipt = dailyService - .getByFranchiseAndDate(franchiseId, request.date()); - - // 1. 이미 생성된 문서가 있는지 확인 - java.util.Optional existingDoc = documentService - .getDailyDocument(receipt.getDailyReceiptId()); - if (existingDoc.isPresent()) { - return minioService.getFileUrl(existingDoc.get().getObjectKey(), - BucketName.SETTLEMENTS); + // 1. 해당 가맹점의 일별 정산 데이터 조회 (Optional 사용으로 트랜잭션 롤백 방지) + java.util.Optional receiptOpt = dailyService + .findByFranchiseAndDate(franchiseId, request.date()); + + if (receiptOpt.isPresent()) { + com.chaing.domain.settlements.entity.DailySettlementReceipt receipt = receiptOpt.get(); + // 2. 해당 정산 ID로 이미 생성된 문서가 있는지 확인 + java.util.Optional existingDoc = documentService + .getDailyDocument(receipt.getDailyReceiptId()); + if (existingDoc.isPresent()) { + return minioService.getFileUrl(existingDoc.get().getObjectKey(), + BucketName.SETTLEMENTS); + } } - List lines = dailyService - .getAllReceiptLines(receipt.getDailyReceiptId()); + // 3. 문서가 없으면 실시간 생성 (상세 라인 포함) + AggregationResult result = aggregateDailySettlement(franchiseId, request.date()); + com.chaing.domain.settlements.entity.DailySettlementReceipt currentReceipt = result.receipt(); + List currentLines = result.lines(); - byte[] pdfBytes = fileService.createDailyReceiptPdf(receipt, lines); + // [수정] 매출/발주가 0원이더라도 조정 전표 확인 등을 위해 PDF 생성을 허용함 + /* + if (currentReceipt.getTotalSaleAmount().compareTo(BigDecimal.ZERO) == 0 && + currentReceipt.getOrderAmount().compareTo(BigDecimal.ZERO) == 0) { + throw new com.chaing.domain.settlements.exception.SettlementException( + com.chaing.domain.settlements.exception.SettlementErrorCode.SETTLEMENT_DATA_EMPTY); + } + */ + + // 가맹점명 조회 + String franchiseName = franchiseRepository.findById(franchiseId) + .map(com.chaing.domain.businessunits.entity.Franchise::getName) + .orElse("Unknown Store"); + + byte[] pdfBytes = fileService.createDailyReceiptPdf(currentReceipt, currentLines, franchiseName); - String fileName = "settlement/daily/Franchise_" + franchiseId + "_Receipt_" + request.date() - + "_" + // 4. MinIO 업로드 (확정 데이터면 daily, 아니면 provisional 폴더) + String folder = receiptOpt.isPresent() ? "settlement/daily/" : "settlement/provisional/"; + String prefix = receiptOpt.isPresent() ? "FR_" : "FR_Preview_"; + String fileName = folder + prefix + franchiseId + "_Daily_" + request.date() + "_" + System.currentTimeMillis() + ".pdf"; + minioService.uploadFile(pdfBytes, fileName, "application/pdf", BucketName.SETTLEMENTS); String fileUrl = minioService.getFileUrl(fileName, BucketName.SETTLEMENTS); - // 2. 메타데이터 저장 - documentService.save(com.chaing.domain.settlements.entity.SettlementDocument.builder() - .dailyReceiptId(receipt.getDailyReceiptId()) + // 5. DB에 메타데이터 저장 + SettlementDocument.SettlementDocumentBuilder docBuilder = SettlementDocument + .builder() .periodType(PeriodType.DAILY) - .documentType(com.chaing.domain.settlements.enums.DocumentType.RECEIPT_PDF) - .documentOwner(com.chaing.domain.settlements.enums.DocumentOwner.FRANCHISE) + .documentType(receiptOpt.isPresent() + ? DocumentType.RECEIPT_PDF + : DocumentType.PROVISIONAL_RECEIPT_PDF) + .documentOwner(DocumentOwner.FRANCHISE) .franchiseId(franchiseId) .storageProvider("MINIO") .bucket(BucketName.SETTLEMENTS.getBucketName()) @@ -549,16 +677,22 @@ public String getDailyFranchiseReceiptPdf(Long franchiseId, .fileUrl(fileUrl) .fileName(fileName.substring(fileName.lastIndexOf("/") + 1)) .contentType("application/pdf") - .fileSize((long) pdfBytes.length) - .build()); + .fileSize((long) pdfBytes.length); + + if (receiptOpt.isPresent()) { + docBuilder.dailyReceiptId(receiptOpt.get().getDailyReceiptId()); + } + + documentService.save(docBuilder.build()); return fileUrl; - } catch (com.chaing.domain.settlements.exception.SettlementException e) { - throw e; } catch (Exception e) { log.error("Failed to generate Daily Franchise Receipt PDF: ", e); - throw new com.chaing.domain.settlements.exception.SettlementException( - com.chaing.domain.settlements.exception.SettlementErrorCode.DOCUMENT_GENERATION_FAILED); + if (e instanceof SettlementException || e instanceof RuntimeException) { + throw e; + } + throw new SettlementException( + SettlementErrorCode.DOCUMENT_GENERATION_FAILED); } } @@ -566,61 +700,36 @@ public String getDailyFranchiseReceiptPdf(Long franchiseId, public String getMonthlyFranchiseReceiptPdf(Long franchiseId, HQSettlementFranchiseMonthlyReceiptPdfRequest request) { try { - com.chaing.domain.settlements.entity.MonthlySettlement settlement = monthlyService - .getByFranchiseAndMonth(franchiseId, request.month()); - - // 1. 이미 생성된 문서가 있는지 확인 - List documents = documentService - .getMonthlyDocuments(settlement.getMonthlySettlementId()); - String existingUrl = documents.stream() - .filter(doc -> doc - .getDocumentType() == com.chaing.domain.settlements.enums.DocumentType.RECEIPT_PDF) - .findFirst() - .map(com.chaing.domain.settlements.entity.SettlementDocument::getFileUrl) - .orElse(null); - - if (existingUrl != null) { - return minioService.getFileUrl(documents.stream() - .filter(doc -> doc - .getDocumentType() == com.chaing.domain.settlements.enums.DocumentType.RECEIPT_PDF) - .findFirst() - .get().getObjectKey(), BucketName.SETTLEMENTS); - } - - List vouchers = voucherRepository - .findAllByMonthlySettlementId(settlement.getMonthlySettlementId()); + // 1. 해당 가맹점의 월별 정산 데이터 조회 + java.util.Optional settlementOpt = monthlyService + .findByFranchiseAndMonth(franchiseId, request.month()); - byte[] pdfBytes = fileService.createMonthlyReceiptPdf(settlement, vouchers); + if (settlementOpt.isPresent()) { + MonthlySettlement settlement = settlementOpt.get(); + // 2. 이미 생성된 문서(RECEIPT_PDF)가 있는지 확인 + java.util.List documents = documentService + .getMonthlyDocuments(settlement.getMonthlySettlementId()); - String fileName = "settlement/monthly/Franchise_" + franchiseId + "_Receipt_" + request.month() - + "_" - + System.currentTimeMillis() + ".pdf"; - minioService.uploadFile(pdfBytes, fileName, "application/pdf", BucketName.SETTLEMENTS); - String fileUrl = minioService.getFileUrl(fileName, BucketName.SETTLEMENTS); + String existingKey = documents.stream() + .filter(doc -> doc + .getDocumentType() == DocumentType.RECEIPT_PDF) + .findFirst() + .map(SettlementDocument::getObjectKey) + .orElse(null); - // 2. 메타데이터 저장 - documentService.save(com.chaing.domain.settlements.entity.SettlementDocument.builder() - .monthlySettlementId(settlement.getMonthlySettlementId()) - .periodType(PeriodType.MONTHLY) - .documentType(com.chaing.domain.settlements.enums.DocumentType.RECEIPT_PDF) - .documentOwner(com.chaing.domain.settlements.enums.DocumentOwner.FRANCHISE) - .franchiseId(franchiseId) - .storageProvider("MINIO") - .bucket(BucketName.SETTLEMENTS.getBucketName()) - .objectKey(fileName) - .fileUrl(fileUrl) - .fileName(fileName.substring(fileName.lastIndexOf("/") + 1)) - .contentType("application/pdf") - .fileSize((long) pdfBytes.length) - .build()); + if (existingKey != null) { + return minioService.getFileUrl(existingKey, BucketName.SETTLEMENTS); + } + } - return fileUrl; - } catch (com.chaing.domain.settlements.exception.SettlementException e) { - throw e; + // 3. 데이터가 없거나 문서가 없으면 실시간 가집계 PDF 생성 시도 + return generateProvisionalMonthlyPdf(franchiseId, request.month()); } catch (Exception e) { log.error("Failed to generate Monthly Franchise Receipt PDF: ", e); - throw new com.chaing.domain.settlements.exception.SettlementException( - com.chaing.domain.settlements.exception.SettlementErrorCode.DOCUMENT_GENERATION_FAILED); + if (e instanceof SettlementException) + throw (SettlementException) e; + throw new SettlementException( + SettlementErrorCode.DOCUMENT_GENERATION_FAILED); } } @@ -671,4 +780,388 @@ public String getMonthlyExcel(HQSettlementMonthlyExcelRequest request) { com.chaing.domain.settlements.exception.SettlementErrorCode.DOCUMENT_GENERATION_FAILED); } } + + // 내부 합산용 일별 정산 조회 (실시간 또는 DB) + private AggregationResult getInternalDailyAggregation(Long franchiseId, LocalDate date) { + if (date.equals(LocalDate.now())) { + return aggregateDailySettlement(franchiseId, date); + } + + return dailyService.findByFranchiseAndDate(franchiseId, date) + .map(receipt -> new AggregationResult(receipt, + dailyService.getAllReceiptLines(receipt.getDailyReceiptId()))) + .orElseGet(() -> aggregateDailySettlement(franchiseId, date)); + } + + // 실시간 가집계 로직 (FranchiseSettlementFacade의 구성을 따름) + private AggregationResult aggregateDailySettlement(Long franchiseId, LocalDate date) { + List lines = new ArrayList<>(); + + // 1. 매출 집계 + List salesItems = salesItemRepository.findAllBySalesFranchiseId(franchiseId).stream() + .filter(item -> item.getCreatedAt() != null + && item.getCreatedAt().toLocalDate().equals(date)) + .filter(item -> item.getSales().getIsCanceled() == null + || !item.getSales().getIsCanceled()) + .toList(); + + BigDecimal totalSale = salesItems.stream() + .map(item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + + // 2. 발주 집계 + List validOrderStatuses = List.of( + FranchiseOrderStatus.PENDING, FranchiseOrderStatus.ACCEPTED, + FranchiseOrderStatus.PARTIAL, FranchiseOrderStatus.SHIPPING_PENDING, + FranchiseOrderStatus.SHIPPING, FranchiseOrderStatus.COMPLETED); + + List orders = orderRepository.findAllByFranchiseId(franchiseId).stream() + .filter(order -> order.getCreatedAt() != null + && order.getCreatedAt().toLocalDate().equals(date)) + .filter(order -> validOrderStatuses.contains(order.getOrderStatus())) + .toList(); + + BigDecimal orderAmount = BigDecimal.ZERO; + if (!orders.isEmpty()) { + orderAmount = orderItemRepository + .findAllByFranchiseOrder_FranchiseOrderIdInAndDeletedAtIsNull( + orders.stream().map(FranchiseOrder::getFranchiseOrderId) + .toList()) + .stream() + .map(FranchiseOrderItem::getTotalPrice) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + // 3. 반품/손실 집계 + List validReturnStatuses = List.of( + ReturnStatus.PENDING, ReturnStatus.ACCEPTED, ReturnStatus.SHIPPING_PENDING, + ReturnStatus.SHIPPING, ReturnStatus.COMPLETED, ReturnStatus.INSPECTING, + ReturnStatus.DEDUCTION_COMPLETED); + + List returnsList = returnRepository.findAllByFranchiseIdAndDeletedAtIsNull(franchiseId) + .stream() + .filter(ret -> ret.getCreatedAt() != null + && ret.getCreatedAt().toLocalDate().equals(date)) + .filter(ret -> validReturnStatuses.contains(ret.getReturnStatus())) + .toList(); + + BigDecimal refundAmount = BigDecimal.ZERO; + BigDecimal lossAmount = BigDecimal.ZERO; + + for (Returns ret : returnsList) { + List items = returnItemRepository + .findAllByReturns_ReturnIdInAndDeletedAtIsNull(List.of(ret.getReturnId())); + BigDecimal returnTotal = BigDecimal.ZERO; + for (ReturnItem item : items) { + returnTotal = returnTotal.add(orderItemRepository + .findById(item.getFranchiseOrderItemId()) + .map(FranchiseOrderItem::getUnitPrice).orElse(BigDecimal.ZERO)); + } + if (ret.getReturnType() == ReturnType.PRODUCT_DEFECT) { + refundAmount = refundAmount.add(returnTotal); + } else { + lossAmount = lossAmount.add(returnTotal); + } + } + + // 4. 배송비 집계 + + BigDecimal deliveryFee = BigDecimal.ZERO; + /*List validDeliverStatuses = List.of(DeliverStatus.IN_TRANSIT, DeliverStatus.DELIVERED); + List transits = transitRepository.findByFranchiseId(franchiseId).stream() + .filter(t -> t.getCreatedAt() != null && t.getCreatedAt().toLocalDate().equals(date)) + .filter(t -> validDeliverStatuses.contains(t.getStatus())) + .toList(); + + BigDecimal deliveryFee = BigDecimal.ZERO; + if (!transits.isEmpty()) { + Map> grouped = transits.stream() + .collect(Collectors.groupingBy(Transit::getTrackingNumber)); + for (Map.Entry> entry : grouped.entrySet()) { + List orderInfos = entry.getValue().stream() + .map(t -> new OrderInfo(null, t.getOrderCode(), t.getWeight(), + t.getFranchiseId(), null, null)) + .toList(); + List fees = transportService.calculateDeliveryFee(orderInfos, + entry.getValue().get(0).getVehicleId()); + for (DeliveryFeeInfo fee : fees) { + if (fee.franchiseId().equals(franchiseId)) { + deliveryFee = deliveryFee.add(fee.deliveryFee()); + } + } + } + }*/ + + // 5. 수수료 (3.3%) + BigDecimal commissionFee = totalSale.multiply(new BigDecimal("0.033")) + .setScale(0, java.math.RoundingMode.HALF_UP); + + // 정산 객체 생성 + DailySettlementReceipt receipt = DailySettlementReceipt.builder() + .franchiseId(franchiseId) + .settlementDate(date) + .totalSaleAmount(totalSale) + .orderAmount(orderAmount) + .refundAmount(refundAmount) + .lossAmount(lossAmount) + .deliveryFee(deliveryFee) + .commissionFee(commissionFee) + .adjustmentAmount(BigDecimal.ZERO) + .finalAmount(totalSale + .subtract(orderAmount.add(lossAmount).add(commissionFee) + .add(deliveryFee)) + .add(refundAmount)) + .build(); + + return new AggregationResult(receipt, lines); + } + + private String generateProvisionalMonthlyPdf(Long franchiseId, YearMonth month) { + // 1. 해당 월의 모든 날짜를 순회하며 데이터 수집 (확정 + 실시간 가집계) + LocalDate start = month.atDay(1); + LocalDate end = month.atEndOfMonth(); + LocalDate today = LocalDate.now(); + if (end.isAfter(today)) { + end = today; + } + + List receipts = new ArrayList<>(); + List vouchers = new ArrayList<>(); + + for (LocalDate date = start; !date.isAfter(end); date = date.plusDays(1)) { + AggregationResult result = aggregateDailySettlement(franchiseId, date); + com.chaing.domain.settlements.entity.DailySettlementReceipt receipt = result.receipt(); + + // 의미 있는 데이터가 있는 날만 포함 + if (receipt.getTotalSaleAmount().compareTo(BigDecimal.ZERO) > 0 || + receipt.getOrderAmount().compareTo(BigDecimal.ZERO) > 0 || + receipt.getDeliveryFee().compareTo(BigDecimal.ZERO) > 0) { + + receipts.add(receipt); + + vouchers.add(SettlementVoucher.builder() + .voucherType(VoucherType.SALES) + .amount(receipt.getFinalAmount()) + .description(date + " 일별 정산 합계") + .occurredAt(date.atStartOfDay()) + .build()); + } + } + + if (receipts.isEmpty()) { + throw new com.chaing.domain.settlements.exception.SettlementException( + com.chaing.domain.settlements.exception.SettlementErrorCode.SETTLEMENT_DATA_EMPTY); + } + + // 2. 가집계 MonthlySettlement 객체 생성 + com.chaing.domain.settlements.entity.MonthlySettlement provisionalSettlement = aggregateMonthlySettlement( + franchiseId, month, receipts); + + // 가맹점명 조회 + String franchiseName = franchiseRepository.findById(franchiseId) + .map(com.chaing.domain.businessunits.entity.Franchise::getName) + .orElse("Unknown Store"); + + // 3. PDF 생성 + byte[] pdfBytes = fileService.createMonthlyReceiptPdf(provisionalSettlement, vouchers, franchiseName); + + // 4. MinIO 업로드 + String fileName = "settlement/provisional/FR_" + franchiseId + "_" + month + "_Preview_" + + System.currentTimeMillis() + ".pdf"; + minioService.uploadFile(pdfBytes, fileName, "application/pdf", BucketName.SETTLEMENTS); + String fileUrl = minioService.getFileUrl(fileName, BucketName.SETTLEMENTS); + + // 5. DB에 메타데이터 저장 (추적용) + documentService.save(com.chaing.domain.settlements.entity.SettlementDocument.builder() + .periodType(PeriodType.MONTHLY) + .documentType(com.chaing.domain.settlements.enums.DocumentType.PROVISIONAL_RECEIPT_PDF) + .documentOwner(com.chaing.domain.settlements.enums.DocumentOwner.FRANCHISE) + .franchiseId(franchiseId) + .storageProvider("MINIO") + .bucket(BucketName.SETTLEMENTS.getBucketName()) + .objectKey(fileName) + .fileUrl(fileUrl) + .fileName(fileName.substring(fileName.lastIndexOf("/") + 1)) + .contentType("application/pdf") + .fileSize((long) pdfBytes.length) + .build()); + + return fileUrl; + } + + private com.chaing.domain.settlements.entity.MonthlySettlement aggregateMonthlySettlement(Long franchiseId, + YearMonth month, + List dailyReceipts) { + BigDecimal totalSale = dailyReceipts.stream() + .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getTotalSaleAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal orderAmount = dailyReceipts.stream() + .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getOrderAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal commissionFee = dailyReceipts.stream() + .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getCommissionFee) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal deliveryFee = dailyReceipts.stream() + .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getDeliveryFee) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal lossAmount = dailyReceipts.stream() + .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getLossAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal refundAmount = dailyReceipts.stream() + .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getRefundAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal adjustmentAmount = dailyReceipts.stream() + .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getAdjustmentAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal finalAmount = dailyReceipts.stream() + .map(com.chaing.domain.settlements.entity.DailySettlementReceipt::getFinalAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + return com.chaing.domain.settlements.entity.MonthlySettlement.builder() + .franchiseId(franchiseId) + .settlementMonth(month) + .totalSaleAmount(totalSale) + .orderAmount(orderAmount) + .commissionFee(commissionFee) + .deliveryFee(deliveryFee) + .lossAmount(lossAmount) + .refundAmount(refundAmount) + .adjustmentAmount(adjustmentAmount) + .finalSettlementAmount(finalAmount) + .status(com.chaing.domain.settlements.enums.SettlementStatus.DRAFT) // 가집계용 상태 + .build(); + } + + + // 상세 내역 (가맹점 페이지 연동용) + + @Transactional(readOnly = true) + public List getDailySalesItems(Long franchiseId, LocalDate date, Integer limit) { + LocalDateTime start = date.atStartOfDay(); + LocalDateTime end = date.atTime(LocalTime.MAX); + List items = salesItemRepository.findAllBySalesFranchiseIdAndCreatedAtBetween(franchiseId, start, end) + .stream() + .filter(item -> item.getSales().getIsCanceled() == null || !item.getSales().getIsCanceled()) + .collect(Collectors.toList()); + return aggregateSalesItems(items, limit); + } + + @Transactional(readOnly = true) + public List getDailyOrderItems(Long franchiseId, LocalDate date, Integer limit) { + List validStatuses = List.of( + FranchiseOrderStatus.PENDING, FranchiseOrderStatus.ACCEPTED, + FranchiseOrderStatus.PARTIAL, FranchiseOrderStatus.SHIPPING_PENDING, + FranchiseOrderStatus.SHIPPING, FranchiseOrderStatus.COMPLETED); + List orders = orderRepository.findAllByFranchiseIdAndOrderStatusInAndCreatedAtBetween( + franchiseId, validStatuses, date.atStartOfDay(), date.atTime(LocalTime.MAX)); + List orderIds = orders.stream().map(FranchiseOrder::getFranchiseOrderId).collect(Collectors.toList()); + List items = orderItemRepository.findAllByFranchiseOrderFranchiseOrderIdIn(orderIds); + return aggregateOrderItems(items, limit); + } + + @Transactional(readOnly = true) + public List getMonthlySalesItems(Long franchiseId, YearMonth month, Integer limit) { + LocalDateTime start = month.atDay(1).atStartOfDay(); + LocalDateTime end = month.atEndOfMonth().atTime(LocalTime.MAX); + List items = salesItemRepository.findAllBySalesFranchiseIdAndCreatedAtBetween(franchiseId, start, end) + .stream() + .filter(item -> item.getSales().getIsCanceled() == null || !item.getSales().getIsCanceled()) + .collect(Collectors.toList()); + return aggregateSalesItems(items, limit); + } + + @Transactional(readOnly = true) + public List getMonthlyOrderItems(Long franchiseId, YearMonth month, Integer limit) { + List validStatuses = List.of( + FranchiseOrderStatus.PENDING, FranchiseOrderStatus.ACCEPTED, + FranchiseOrderStatus.PARTIAL, FranchiseOrderStatus.SHIPPING_PENDING, + FranchiseOrderStatus.SHIPPING, FranchiseOrderStatus.COMPLETED); + List orders = orderRepository.findAllByFranchiseIdAndOrderStatusInAndCreatedAtBetween( + franchiseId, validStatuses, month.atDay(1).atStartOfDay(), month.atEndOfMonth().atTime(LocalTime.MAX)); + List orderIds = orders.stream().map(FranchiseOrder::getFranchiseOrderId).collect(Collectors.toList()); + List items = orderItemRepository.findAllByFranchiseOrderFranchiseOrderIdIn(orderIds); + return aggregateOrderItems(items, limit); + } + + @Transactional(readOnly = true) + public List getMonthlySalesTrend(Long franchiseId, YearMonth month) { + LocalDate startDay = month.atDay(1); + LocalDate endDay = month.atEndOfMonth(); + if (month.equals(YearMonth.now())) { + endDay = LocalDate.now(); + } + + List trend = new ArrayList<>(); + for (LocalDate date = startDay; !date.isAfter(endDay); date = date.plusDays(1)) { + AggregationResult result = getInternalDailyAggregation(franchiseId, date); + trend.add(new FranchiseDailyGraphResponse(date, result.receipt().getTotalSaleAmount())); + } + return trend; + } + + private List aggregateSalesItems(List items, Integer limit) { + Map> grouped = items.stream() + .collect(Collectors.groupingBy(SalesItem::getProductName)); + List result = grouped.entrySet().stream() + .map(entry -> { + String name = entry.getKey(); + List group = entry.getValue(); + int totalQty = group.stream().mapToInt(SalesItem::getQuantity).sum(); + BigDecimal total = group.stream() + .map(item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal displayPrice = group.get(0).getUnitPrice(); + return new FranchiseSalesItemResponse(0, name, totalQty, displayPrice, total); + }) + .sorted((a, b) -> b.totalSales().compareTo(a.totalSales())) + .collect(Collectors.toList()); + + List ranked = new ArrayList<>(); + for (int i = 0; i < result.size(); i++) { + if (limit != null && i >= limit) + break; + var item = result.get(i); + ranked.add(new FranchiseSalesItemResponse( + i + 1, item.productName(), item.totalQuantity(), item.unitPrice(), item.totalSales())); + } + return ranked; + } + + private List aggregateOrderItems(List items, Integer limit) { + Map> grouped = items.stream() + .collect(Collectors.groupingBy(FranchiseOrderItem::getProductId)); + + List productIds = new ArrayList<>(grouped.keySet()); + Map productNames = productRepository.findAllByProductIdIn(productIds).stream() + .collect(Collectors.toMap(Product::getProductId, Product::getName)); + + List result = grouped.entrySet().stream() + .map(entry -> { + Long productId = entry.getKey(); + String productName = productNames.getOrDefault(productId, "알 수 없는 상품 (ID: " + productId + ")"); + List group = entry.getValue(); + int totalQty = group.stream().mapToInt(FranchiseOrderItem::getQuantity).sum(); + BigDecimal total = group.stream() + .map(item -> item.getUnitPrice().multiply(BigDecimal.valueOf(item.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + BigDecimal displayPrice = group.get(0).getUnitPrice(); + return new FranchiseOrderItemResponse(0, productName, totalQty, displayPrice, total); + }) + .sorted((a, b) -> b.totalAmount().compareTo(a.totalAmount())) + .collect(Collectors.toList()); + + List ranked = new ArrayList<>(); + for (int i = 0; i < result.size(); i++) { + if (limit != null && i >= limit) + break; + var item = result.get(i); + ranked.add(new FranchiseOrderItemResponse( + i + 1, item.productName(), item.totalQuantity(), item.unitPrice(), item.totalAmount())); + } + return ranked; + } + + private record AggregationResult(DailySettlementReceipt receipt, List lines) { + } } diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/hq/UserManagementFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/hq/UserManagementFacade.java index 345c11e72..33172d2dc 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/hq/UserManagementFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/hq/UserManagementFacade.java @@ -11,6 +11,7 @@ import com.chaing.domain.businessunits.service.BusinessUnitService; import com.chaing.domain.notifications.enums.NotificationType; import com.chaing.domain.notifications.event.NotificationEvent; +import com.chaing.domain.businessunits.dto.condition.BusinessUnitSearchCondition; import com.chaing.domain.users.enums.UserRole; import com.chaing.domain.users.event.ProfileImageDeleteEvent; import com.chaing.domain.users.event.UserInfoResendEvent; @@ -117,7 +118,20 @@ public void sendUserInfo(Long userId) { // 회원 목록 조회 public Page getUserList(UserSearchRequest request, Pageable pageable) { - UserSearchCondition condition = request.toCondition(); + List hqIds = null; + List franchiseIds = null; + List factoryIds = null; + + if (request.orgName() != null && !request.orgName().isBlank()) { + BusinessUnitSearchCondition buCondition = new BusinessUnitSearchCondition( + null, request.orgName(), null, null, null, null, null, null); + + hqIds = headquarterServiceImpl.getAllIdsByCondition(buCondition); + franchiseIds = franchiseServiceImpl.getAllIdsByCondition(buCondition); + factoryIds = factoryServiceImpl.getAllIdsByCondition(buCondition); + } + + UserSearchCondition condition = request.toCondition(hqIds, franchiseIds, factoryIds); Page userPage = userManagementService.getUserList(condition, pageable); Map> roleUnitIdsMap = userPage.getContent().stream() @@ -146,7 +160,6 @@ public Page getUserList(UserSearchRequest request, Pageable case HQ -> hqNameMap.getOrDefault(user.getBusinessUnitId(), "-"); case FRANCHISE -> franchiseNameMap.getOrDefault(user.getBusinessUnitId(), "-"); case FACTORY -> factoryNameMap.getOrDefault(user.getBusinessUnitId(), "-"); - default -> "-"; }; return UserSummaryResponse.from(user, unitName); }); @@ -235,7 +248,6 @@ private String getBusinessUnitName(User user) { case HQ -> headquarterServiceImpl.getById(unitId).name(); case FRANCHISE -> franchiseServiceImpl.getById(unitId).name(); case FACTORY -> factoryServiceImpl.getById(unitId).name(); - default -> "-"; }; } catch (Exception e) { return "-"; diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/inbound/InboundFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/inbound/InboundFacade.java index 37c11064c..8630b5d95 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/inbound/InboundFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/inbound/InboundFacade.java @@ -5,6 +5,7 @@ import com.chaing.api.dto.inbound.request.InboundScanItemRequest; import com.chaing.api.dto.inbound.response.InboundBoxSummaryResponse; import com.chaing.api.dto.inbound.response.InboundDetailResponse; +import com.chaing.api.facade.inventorylogs.InventoryLogFacade; import com.chaing.core.dto.info.ProductInfo; import com.chaing.domain.inventories.dto.command.FactoryInboundCreateCommand; import com.chaing.domain.inventories.dto.command.FranchiseInboundCreateCommand; @@ -15,6 +16,7 @@ import com.chaing.domain.inventories.dto.raw.FranchiseInventoryRawData; import com.chaing.domain.inventories.exception.InventoriesErrorCode; import com.chaing.domain.inventories.exception.InventoriesException; +import com.chaing.domain.inventories.service.InventoryService; import com.chaing.domain.inventories.service.inbound.InboundService; import com.chaing.domain.orders.dto.response.FranchiseOrderForTransitResponse; import com.chaing.domain.orders.service.FranchiseOrderService; @@ -48,6 +50,8 @@ public class InboundFacade { private final InboundService factoryInboundService; private final ProductService productService; private final FranchiseOrderService orderService; + private final InventoryService inventoryService; + private final InventoryLogFacade inventoryLogFacade; @Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2)) @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) @@ -168,7 +172,7 @@ public void confirmInbound(@Valid InboundConfirmRequest request, UserRole role) factoryInboundService.confirmInbound(selectedList); } else if (role == UserRole.FRANCHISE) { franchiseInboundService.confirmInbound(selectedList); - + inventoryService.deleteFactoryInventory(selectedList); } else { throw new InventoriesException(InventoriesErrorCode.INBOUND_ROLE_INVALID); } diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/inventorylogs/InventoryLogFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/inventorylogs/InventoryLogFacade.java index b6d9ae5f5..1c82312e2 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/inventorylogs/InventoryLogFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/inventorylogs/InventoryLogFacade.java @@ -1,9 +1,13 @@ package com.chaing.api.facade.inventorylogs; +import com.chaing.api.security.principal.UserPrincipal; import com.chaing.core.dto.info.ProductInfo; import com.chaing.core.enums.LogType; -import com.chaing.domain.inventories.entity.FactoryInventory; +import com.chaing.domain.inventories.dto.info.InboundProductIdInfo; +import com.chaing.domain.inventories.dto.info.StockInfoForLog; import com.chaing.domain.inventories.entity.FranchiseInventory; +import com.chaing.domain.inventories.exception.InventoriesErrorCode; +import com.chaing.domain.inventories.exception.InventoriesException; import com.chaing.domain.inventories.service.InventoryService; import com.chaing.domain.inventorylogs.dto.request.InventoryLogCreateRequest; import com.chaing.domain.inventorylogs.dto.response.BoxCodeResponse; @@ -12,10 +16,8 @@ import com.chaing.domain.inventorylogs.exception.InventoryLogException; import com.chaing.domain.inventorylogs.exception.InventoryLogtErrorCode; import com.chaing.domain.inventorylogs.service.InventoryLogService; -import com.chaing.domain.orders.entity.FranchiseOrder; -import com.chaing.domain.orders.entity.HeadOfficeOrder; -import com.chaing.domain.orders.repository.FranchiseOrderRepository; -import com.chaing.domain.orders.repository.HeadOfficeOrderRepository; +import com.chaing.domain.orders.dto.info.OrderInfoForLog; +import com.chaing.domain.orders.service.FranchiseOrderService; import com.chaing.domain.products.service.ProductService; import com.chaing.domain.returns.entity.ReturnItem; import com.chaing.domain.returns.entity.Returns; @@ -37,130 +39,164 @@ @Transactional(readOnly = true) public class InventoryLogFacade { - private static final Long HQ_ID = 1L; + private static final Long FACTORY_ID = 1L; private final InventoryLogService inventoryLogService; - private final HeadOfficeOrderRepository headOfficeOrderRepository; - private final FranchiseOrderRepository franchiseOrderRepository; private final FranchiseReturnService franchiseReturnService; private final ProductService productService; private final InventoryService inventoryService; + private final FranchiseOrderService franchiseOrderService; - // orderType: FRANCHISE | HQ + // orderType: FRANCHISE | FACTORY @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) - public void recordOrderLogs(Long orderId, String orderType, Long toId, LogType logType, String actorType, Long actorId) { - if (orderId == null || toId == null || logType == null || actorId == null) { - throw new InventoryLogException(InventoryLogtErrorCode.INVALID_INPUT); - } + public void recordOrderLogs(List serialCodes, UserPrincipal userInfo, LogType status) { - ActorType parsedActorType = parseActorType(actorType); - if ("FRANCHISE".equalsIgnoreCase(orderType)) { - FranchiseOrder order = franchiseOrderRepository - .findByFranchiseOrderIdAndDeletedAtIsNull(orderId) - .orElseThrow(() -> new InventoryLogException( - InventoryLogtErrorCode.INVALID_INPUT)); - List inventories = inventoryService.getFactoryInventoriesByOrderId(orderId); - recordFranchiseOrderLogs(order, inventories, logType, toId, parsedActorType, actorId); - return; - } + Long unitId = userInfo.getBusinessUnitId(); + String role = userInfo.getRole().name(); - if ("HQ".equalsIgnoreCase(orderType)) { - HeadOfficeOrder order = headOfficeOrderRepository - .findByHeadOfficeOrderIdAndDeletedAtIsNull(orderId) - .orElseThrow(() -> new InventoryLogException( - InventoryLogtErrorCode.INVALID_INPUT)); - List inventories = inventoryService - .getFranchiseInventoriesByOrderId(orderId); - recordHqOrderLogs(order, inventories, logType, toId, parsedActorType, actorId); - return; - } + List inventoryLogInfo; + List orderIds = new ArrayList<>(); + Long quantity = serialCodes.stream().distinct().count(); - throw new InventoryLogException(InventoryLogtErrorCode.INVALID_INPUT); - } - // FRANCHISE 주문: Factory -> Franchise - // boxCode 기준 1박스 1로그, quantity는 박스 내 개수 - public void recordFranchiseOrderLogs(FranchiseOrder order, List inventories, LogType logType, Long toId, ActorType actorType, Long actorId) { - Map> inventoriesByBox = inventories.stream() - .filter(inv -> inv.getBoxCode() != null && !inv.getBoxCode().isBlank()) - .collect(Collectors.groupingBy( - FactoryInventory::getBoxCode, - LinkedHashMap::new, - Collectors.toList())); - List productIds = inventoriesByBox.values().stream() - .flatMap(List::stream) - .map(FactoryInventory::getProductId) + Map orderInfos; + + switch (role) { + case "FRANCHISE": + inventoryLogInfo = inventoryService.getStockBySerialCodeFromFranchise(serialCodes, unitId); + orderIds = inventoryLogInfo.stream() + .map(StockInfoForLog::orderId) + .distinct() + .toList(); + + if (orderIds.isEmpty()) { + throw new InventoriesException(InventoriesErrorCode.INVALID_LOCATION_ID); + } + orderInfos = franchiseOrderService.getOrderInfoForLog(orderIds); + break; + + case "FACTORY": + inventoryLogInfo = inventoryService.getStockBySerialCodeFromFactory(serialCodes); + orderIds = inventoryLogInfo.stream() + .map(StockInfoForLog::orderId) + .filter(orderId -> orderId != null) + .distinct() + .toList(); + + if (orderIds.isEmpty()) { + List inboundProductIdInfos = inventoryService.getProductIdFromFactory(serialCodes); + + List productIds = inboundProductIdInfos.stream() + .map(InboundProductIdInfo::productId) + .toList(); + + Map productInfoMap = productIds.isEmpty() + ? Map.of() + : productService.getProductInfos(productIds); + + List logs = new ArrayList<>(); + + for(InboundProductIdInfo info : inboundProductIdInfos){ + InventoryLogCreateRequest log = new InventoryLogCreateRequest( + info.productId(), + productInfoMap.get(info.productId()).productName(), + "", + "", + status, + info.quantity().intValue(), + LocationType.FACTORY, + FACTORY_ID, + LocationType.FACTORY, + FACTORY_ID, + ActorType.FACTORY, + FACTORY_ID + ); + logs.add(log); + } + if (!logs.isEmpty()) { + inventoryLogService.recordInventoryLog(logs); + } + return; + } + + orderInfos = franchiseOrderService.getOrderInfoForLog(orderIds); + break; + + default: + throw new InventoryLogException(InventoryLogtErrorCode.INVALID_ACTOR_TYPE); + } + + List productIds = inventoryLogInfo.stream() + .map(StockInfoForLog::productId) .distinct() .toList(); - Map productInfos = productIds.isEmpty() + + Map productInfoMap = productIds.isEmpty() ? Map.of() : productService.getProductInfos(productIds); - List logs = new ArrayList<>(); - for (Map.Entry> entry : inventoriesByBox.entrySet()) { - String boxCode = entry.getKey(); - List boxItems = entry.getValue(); - FactoryInventory first = boxItems.get(0); - int quantity = boxItems.size(); - ProductInfo pInfo = productInfos.get(first.getProductId()); - logs.add(new InventoryLogCreateRequest( - first.getProductId(), - pInfo != null ? pInfo.productName() : "알 수 없는 상품", - boxCode, - order.getOrderCode(), - logType, - quantity, - LocationType.FACTORY, - toId, - LocationType.FRANCHISE, - order.getFranchiseId(), - actorType, - actorId)); - } - if (!logs.isEmpty()) { - inventoryLogService.recordInventoryLog(logs); - } - } - // HQ 주문: Factory -> HQ - // boxCode 기준 1박스 1로그, quantity는 박스 내 개수 - public void recordHqOrderLogs(HeadOfficeOrder order, List inventories, LogType logType, Long toId, ActorType actorType, Long actorId) { - Map> inventoriesByBox = inventories.stream() - .filter(inv -> inv.getBoxCode() != null && !inv.getBoxCode().isBlank()) - .collect(Collectors.groupingBy( - FranchiseInventory::getBoxCode, - LinkedHashMap::new, - Collectors.toList())); - List productIds = inventoriesByBox.values().stream() - .flatMap(List::stream) - .map(FranchiseInventory::getProductId) + List boxCodes = inventoryLogInfo.stream() + .map(StockInfoForLog::boxCode) + .filter(boxCode -> boxCode != null && !boxCode.isBlank()) .distinct() .toList(); - Map productInfos = productIds.isEmpty() - ? Map.of() - : productService.getProductInfos(productIds); + + // 박스당 속해있는 수 + Map quantityByBoxCode = switch (role) { + case "FRANCHISE" -> inventoryService.getFranchiseQuantityByBoxCodes(unitId, boxCodes); + case "FACTORY" -> inventoryService.getFactoryQuantityByBoxCodes(boxCodes); + default -> Map.of(); + }; + List logs = new ArrayList<>(); - for (Map.Entry> entry : inventoriesByBox.entrySet()) { - String boxCode = entry.getKey(); - List boxItems = entry.getValue(); - FranchiseInventory first = boxItems.get(0); - int quantity = boxItems.size(); - ProductInfo pInfo = productInfos.get(first.getProductId()); - logs.add(new InventoryLogCreateRequest( - first.getProductId(), - pInfo != null ? pInfo.productName() : "알 수 없는 상품", - boxCode, - order.getOrderCode(), - logType, - quantity, - LocationType.FACTORY, - toId, - LocationType.HQ, - HQ_ID, - actorType, - actorId)); + + for (StockInfoForLog stock : inventoryLogInfo) { + OrderInfoForLog orderInfo = orderInfos.get(stock.orderId()); + if (orderInfo == null) { + throw new InventoryLogException(InventoryLogtErrorCode.ORDER_NOT_FOUND); + } + + ProductInfo productInfo = productInfoMap.get(stock.productId()); + String productName = productInfo != null ? productInfo.productName() : "알 수 없는 상품"; + + InventoryLogCreateRequest log = switch (role) { + case "FRANCHISE" -> new InventoryLogCreateRequest( + stock.productId(), + productName, + stock.boxCode(), + orderInfo.orderCode(), + status, + quantityByBoxCode.getOrDefault(stock.boxCode(),1L).intValue(), + LocationType.FACTORY, + FACTORY_ID, + LocationType.FRANCHISE, + orderInfo.toId(), + parseActorType(role), + unitId + ); + case "FACTORY" -> new InventoryLogCreateRequest( + stock.productId(), + productName, + stock.boxCode() != null ? stock.boxCode() : "", + orderInfo.orderCode(), + status, + quantityByBoxCode.getOrDefault(stock.boxCode(),1L).intValue(), + LocationType.FACTORY, + FACTORY_ID, + LocationType.FACTORY, + FACTORY_ID, + parseActorType(role), + unitId + ); + default -> throw new InventoryLogException(InventoryLogtErrorCode.INVALID_ACTOR_TYPE); + }; + + logs.add(log); } + if (!logs.isEmpty()) { inventoryLogService.recordInventoryLog(logs); } + } // 반품: Franchise -> HQ @@ -209,9 +245,9 @@ public void recordReturnLogs(Long returnId, LogType logType, ActorType actorType logType, quantity, actorType == ActorType.HQ ? LocationType.HQ : LocationType.FRANCHISE, - actorType == ActorType.HQ ? HQ_ID : returns.getFranchiseId(), + actorType == ActorType.HQ ? FACTORY_ID : returns.getFranchiseId(), actorType == ActorType.HQ ? LocationType.FRANCHISE : LocationType.HQ, - actorType == ActorType.HQ ? returns.getFranchiseId() : HQ_ID, + actorType == ActorType.HQ ? returns.getFranchiseId() : FACTORY_ID, actorType, actorId)); } @@ -220,8 +256,9 @@ public void recordReturnLogs(Long returnId, LogType logType, ActorType actorType } } - public List getBoxCodes(String transactionCode, LocalDate date) { - return inventoryLogService.findBoxCodesByTransactionCode(transactionCode, date); + public List getBoxCodes(String transactionCode, LocalDate date, String productName, + LogType logType) { + return inventoryLogService.findBoxCodesByTransactionCode(transactionCode, date, productName, logType); } private ActorType parseActorType(String actorType) { diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/outbound/OutboundFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/outbound/OutboundFacade.java index 24c997f33..389a5d64f 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/outbound/OutboundFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/outbound/OutboundFacade.java @@ -18,6 +18,7 @@ import com.chaing.domain.transports.service.InternalTransportService; import com.chaing.external.transport.ExternalTransportService; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotEmpty; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.orm.ObjectOptimisticLockingFailureException; @@ -52,9 +53,8 @@ public class OutboundFacade { // 재고 상태 변경 @Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2)) @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) - public void updateOutboundStatus(@Valid OutboundUpdateRequest request, LogType currentStatus) { - List selectedList = request.serialCodes(); - outboundService.updateStatus(selectedList, currentStatus); + public void updateOutboundStatus(@NotEmpty List request, LogType currentStatus) { + outboundService.updateStatus(request, currentStatus); /* * System.out.println(currentStatus); @@ -78,8 +78,11 @@ public void updateOutboundStatus(@Valid OutboundUpdateRequest request, LogType c // 박스 할당 @Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2)) @Transactional(rollbackFor = Exception.class, propagation = Propagation.REQUIRES_NEW) - public void assignBoxToInventories(String boxCode, List serialCodes) { - outboundService.assignBox(boxCode, serialCodes); + public void assignBoxToInventories(String boxCode, Long orderItemId, List serialCodes) { + + Long orderId = orderService.getOrderIdByOrderItemId(orderItemId); + + outboundService.assignBox(boxCode, orderId, orderItemId, serialCodes); } @Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 100, multiplier = 2)) diff --git a/module-app/app-api/src/main/java/com/chaing/api/facade/transport/InternalTransportFacade.java b/module-app/app-api/src/main/java/com/chaing/api/facade/transport/InternalTransportFacade.java index c6b1fe217..3332b5782 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/facade/transport/InternalTransportFacade.java +++ b/module-app/app-api/src/main/java/com/chaing/api/facade/transport/InternalTransportFacade.java @@ -6,20 +6,26 @@ import com.chaing.api.dto.transport.internal.response.TransportLogResponse; import com.chaing.api.dto.transport.internal.response.UnassignedOrderResponse; import com.chaing.api.dto.transport.internal.response.UnassignedReturnResponse; +import com.chaing.core.enums.LogType; import com.chaing.domain.businessunits.dto.internal.BusinessUnitInternal; import com.chaing.domain.businessunits.exception.BusinessUnitErrorCode; import com.chaing.domain.businessunits.exception.BusinessUnitException; import com.chaing.domain.businessunits.service.BusinessUnitService; +import com.chaing.domain.inventories.service.InventoryService; import com.chaing.domain.orders.dto.response.FranchiseOrderForTransitResponse; +import com.chaing.domain.orders.enums.FranchiseOrderStatus; import com.chaing.domain.orders.service.FranchiseOrderService; import com.chaing.domain.products.service.ProductService; import com.chaing.domain.returns.dto.command.HQReturnCommand; +import com.chaing.domain.returns.enums.ReturnStatus; import com.chaing.domain.returns.service.FranchiseReturnService; import com.chaing.domain.transports.dto.DeliveryFeeInfo; import com.chaing.domain.transports.dto.OrderInfo; import com.chaing.domain.returns.dto.command.FranchiseReturnCommandForTransit; import com.chaing.domain.transports.dto.info.TransportLogInfo; +import com.chaing.domain.transports.dto.info.UpdateDeliverStatusInfo; import com.chaing.domain.transports.dto.response.AvailableVehicleInfo; +import com.chaing.domain.transports.enums.DeliverStatus; import com.chaing.domain.transports.exception.TransportErrorCode; import com.chaing.domain.transports.exception.TransportException; import com.chaing.domain.transports.service.InternalTransportService; @@ -30,9 +36,12 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.stream.Collectors; import static com.chaing.domain.returns.enums.ReturnStatus.ACCEPTED; @@ -44,6 +53,7 @@ public class InternalTransportFacade { private final InternalTransportService transportService; private final FranchiseOrderService franchiseOrderService; + private final InventoryService inventoryService; private final ProductService productService; private final BusinessUnitService franchiseServiceImpl; private final FranchiseReturnService franchiseReturnService; @@ -245,11 +255,42 @@ public void assignVehicleReturns(@Valid VehicleAssignmentRequest request) { ); } + @Transactional public void updateDeliverStatus(@NotEmpty List orderCodes) { boolean isPendingOrders = transportService.filterPendingOrders(orderCodes); transportService.updateDeliveryStatus(orderCodes); + List currentStatusInfos = transportService.getDeliveryStatus(orderCodes); + + if (CollectionUtils.isEmpty(currentStatusInfos)) { + return; + } + + DeliverStatus status = currentStatusInfos.get(0).currentStatus(); + List orderCodeList = currentStatusInfos.stream().map(UpdateDeliverStatusInfo::orderCode).toList(); + List returnCodeList = currentStatusInfos.stream() + .map(UpdateDeliverStatusInfo::returnCode) + .filter(StringUtils::hasText) + .toList(); + + if(CollectionUtils.isEmpty(returnCodeList)) { + switch (status) { + case IN_TRANSIT: + franchiseOrderService.updateDeliveryStatus(orderCodeList, FranchiseOrderStatus.SHIPPING); + break; + case DELIVERED: + franchiseOrderService.updateDeliveryStatus(orderCodeList, FranchiseOrderStatus.COMPLETED); + inventoryService.updateShippingStatus(orderCodeList, LogType.SHIPPED); + break; + } + } else { + if (Objects.requireNonNull(status) == DeliverStatus.DELIVERED) { + franchiseReturnService.updateDeliveryStatus(returnCodeList, ReturnStatus.COMPLETED); + inventoryService.updateShippingStatus(orderCodeList, LogType.SHIPPED); + } + } + if (isPendingOrders) { externalTrackingModule.scheduleDeliveryCompletion(orderCodes); } diff --git a/module-app/app-api/src/main/java/com/chaing/api/security/config/SecurityConfig.java b/module-app/app-api/src/main/java/com/chaing/api/security/config/SecurityConfig.java index aff166baa..a39571226 100644 --- a/module-app/app-api/src/main/java/com/chaing/api/security/config/SecurityConfig.java +++ b/module-app/app-api/src/main/java/com/chaing/api/security/config/SecurityConfig.java @@ -80,7 +80,9 @@ public CorsConfigurationSource corsConfigurationSource() { configuration.setAllowedOrigins(List.of( "http://localhost:5173", "https://chaing.org", - "https://img.chaing.org" + "https://api.chaing.org", + "https://img.chaing.org", + "https://minio.chaing.org" )); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("Authorization", "Authorization-Refresh", "Content-Type", "Cache-Control", "Connection")); diff --git a/module-app/app-api/src/main/resources/fonts/NanumGothic.ttf b/module-app/app-api/src/main/resources/fonts/NanumGothic.ttf index 76a585c92..75d010a5f 100644 Binary files a/module-app/app-api/src/main/resources/fonts/NanumGothic.ttf and b/module-app/app-api/src/main/resources/fonts/NanumGothic.ttf differ diff --git a/module-core/src/main/java/com/chaing/core/service/MinioService.java b/module-core/src/main/java/com/chaing/core/service/MinioService.java index 58b60c76d..5fbf16a07 100644 --- a/module-core/src/main/java/com/chaing/core/service/MinioService.java +++ b/module-core/src/main/java/com/chaing/core/service/MinioService.java @@ -77,6 +77,27 @@ private void ensureBucketExists(String bucketName) throws Exception { } } + // 파일 존재 여부 확인 (MinIO stat) + public boolean objectExists(String fileName, BucketName bucket) { + try { + minioClient.statObject( + io.minio.StatObjectArgs.builder() + .bucket(bucket.getBucketName()) + .object(fileName) + .build()); + return true; + } catch (io.minio.errors.ErrorResponseException e) { + if ("NoSuchKey".equals(e.errorResponse().code())) { + return false; + } + log.error("MinIO statObject error for key '{}': {}", fileName, e.getMessage()); + return false; + } catch (Exception e) { + log.error("MinIO objectExists check failed for key '{}': {}", fileName, e.getMessage()); + return false; + } + } + // 이미지 조회 URL 생성 public String getFileUrl(String fileName, BucketName bucket) { if (fileName == null || fileName.isBlank()) { diff --git a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/entity/Factory.java b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/entity/Factory.java index b4f427d2b..4e9b096f0 100644 --- a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/entity/Factory.java +++ b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/entity/Factory.java @@ -14,6 +14,8 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -27,6 +29,12 @@ @AllArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLRestriction("deleted_at IS NULL") +@Table(name = "factory", uniqueConstraints = { + @UniqueConstraint( + name = "uk_factory_name_deleted_at", + columnNames = {"name", "deleted_at"} + ) +}) public class Factory extends BaseEntity { @Id diff --git a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/entity/Franchise.java b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/entity/Franchise.java index df15bf16f..1202e761d 100644 --- a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/entity/Franchise.java +++ b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/entity/Franchise.java @@ -12,6 +12,8 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -28,6 +30,12 @@ @AllArgsConstructor(access = AccessLevel.PROTECTED) @NoArgsConstructor(access = AccessLevel.PROTECTED) @SQLRestriction("deleted_at IS NULL") +@Table(name = "franchise", uniqueConstraints = { + @UniqueConstraint( + name = "uk_franchise_name_deleted_at", + columnNames = {"name", "deleted_at"} + ) +}) public class Franchise extends BaseEntity { @Id diff --git a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/exception/BusinessUnitErrorCode.java b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/exception/BusinessUnitErrorCode.java index 7a8a983b1..b6b823eae 100644 --- a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/exception/BusinessUnitErrorCode.java +++ b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/exception/BusinessUnitErrorCode.java @@ -13,7 +13,8 @@ public enum BusinessUnitErrorCode implements ErrorCode { WARNING_ONLY_FOR_FRANCHISE(400, "B003", "가맹점을 위한 경고 로직입니다."), CODE_OVERFLOW(400, "B004", "생성 가능한 사업장 코드 범위를 초과했습니다."), INVALID_PRODUCTION_LINE_COUNT(400, "B005", "생산 라인 개수는 0 이상이어야 합니다."), - INVALID_WARNING_COUNT(400, "B006", "가맹점 경고 횟수가 3회 이상입니다."); + INVALID_WARNING_COUNT(400, "B006", "가맹점 경고 횟수가 3회 이상입니다."), + DUPLICATE_BUSINESS_UNIT_NAME(400, "B007", "이미 존재하는 사업장 이름입니다."); private final Integer status; private final String code; diff --git a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/FactoryRepository.java b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/FactoryRepository.java index 4705dbef4..68a46b6e8 100644 --- a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/FactoryRepository.java +++ b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/FactoryRepository.java @@ -22,4 +22,10 @@ public interface FactoryRepository extends JpaRepository, Factory @Query("SELECT f.factoryId, f.name FROM Factory f WHERE f.factoryId IN :ids") List findNamesByIds(@Param("ids") List ids); + + @Query("SELECT COUNT(f) > 0 FROM Factory f WHERE f.name = :name AND f.deletedAt IS NULL") + boolean existsByNameExcludeDeleted(@Param("name") String name); + + @Query("SELECT f.factoryId FROM Factory f WHERE f.name LIKE %:name% AND f.deletedAt IS NULL") + List findAllIdsByName(@Param("name") String name); } diff --git a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/FranchiseRepository.java b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/FranchiseRepository.java index e5cfa0bab..4ed6c3d66 100644 --- a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/FranchiseRepository.java +++ b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/FranchiseRepository.java @@ -25,4 +25,10 @@ public interface FranchiseRepository extends JpaRepository, Fra List findNamesByIds(@Param("ids") List ids); Optional findByFranchiseIdAndDeletedAtIsNull(Long franchiseId); + + @Query("SELECT COUNT(f) > 0 FROM Franchise f WHERE f.name = :name AND f.deletedAt IS NULL") + boolean existsByNameExcludeDeleted(@Param("name") String name); + + @Query("SELECT f.franchiseId FROM Franchise f WHERE f.name LIKE %:name% AND f.deletedAt IS NULL") + List findAllIdsByName(@Param("name") String name); } diff --git a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/HeadquarterRepository.java b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/HeadquarterRepository.java index 916693bb0..4d5005a89 100644 --- a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/HeadquarterRepository.java +++ b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/HeadquarterRepository.java @@ -1,6 +1,8 @@ package com.chaing.domain.businessunits.repository; import com.chaing.domain.businessunits.entity.Headquarter; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -9,8 +11,13 @@ import java.util.List; @Repository -public interface HeadquarterRepository extends JpaRepository { +public interface HeadquarterRepository extends JpaRepository { @Query("SELECT f.hqId, f.name FROM Headquarter f WHERE f.hqId IN :ids") List findNamesByIds(@Param("ids") List ids); + + Page findByNameContainingIgnoreCase(String name, Pageable pageable); + + @Query("SELECT f.hqId FROM Headquarter f WHERE f.name LIKE %:name% AND f.deletedAt IS NULL") + List findAllIdsByName(@Param("name") String name); } diff --git a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/impl/FactoryRepositoryImpl.java b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/impl/FactoryRepositoryImpl.java index 1031cbf91..5f89ee701 100644 --- a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/impl/FactoryRepositoryImpl.java +++ b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/impl/FactoryRepositoryImpl.java @@ -33,10 +33,8 @@ public Page search(BusinessUnitSearchCondition condition, Pageable page List content = queryFactory .selectFrom(factory) .where( - codeContains(condition.code()), - nameContains(condition.name()), - representativeContains(condition.representativeName()), - businessNumberContains(condition.businessNumber()), + nameOrCodeContains(condition.code(), condition.name()), + representativeOrBusinessNumberContains(condition.representativeName(), condition.businessNumber()), regionEq(condition.region()), statusEq(condition.status()) ) @@ -49,10 +47,8 @@ public Page search(BusinessUnitSearchCondition condition, Pageable page .select(factory.count()) .from(factory) .where( - codeContains(condition.code()), - nameContains(condition.name()), - representativeContains(condition.representativeName()), - businessNumberContains(condition.businessNumber()), + nameOrCodeContains(condition.code(), condition.name()), + representativeOrBusinessNumberContains(condition.representativeName(), condition.businessNumber()), regionEq(condition.region()), statusEq(condition.status()) ); @@ -60,20 +56,20 @@ public Page search(BusinessUnitSearchCondition condition, Pageable page return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); } - private BooleanExpression codeContains(String code) { - return hasText(code) ? QFactory.factory.factoryCode.contains(code) : null; + private BooleanExpression nameOrCodeContains(String code, String name) { + BooleanExpression codeExpr = hasText(code) ? QFactory.factory.factoryCode.containsIgnoreCase(code) : null; + BooleanExpression nameExpr = hasText(name) ? QFactory.factory.name.containsIgnoreCase(name) : null; + if (codeExpr != null && nameExpr != null) return codeExpr.or(nameExpr); + if (codeExpr != null) return codeExpr; + return nameExpr; } - private BooleanExpression nameContains(String name) { - return hasText(name) ? QFactory.factory.name.contains(name) : null; - } - - private BooleanExpression representativeContains(String representativeName) { - return hasText(representativeName) ? QFactory.factory.representativeName.contains(representativeName) : null; - } - - private BooleanExpression businessNumberContains(String businessNumber) { - return hasText(businessNumber) ? QFactory.factory.businessNumber.contains(businessNumber) : null; + private BooleanExpression representativeOrBusinessNumberContains(String representativeName, String businessNumber) { + BooleanExpression repExpr = hasText(representativeName) ? QFactory.factory.representativeName.containsIgnoreCase(representativeName) : null; + BooleanExpression bizExpr = hasText(businessNumber) ? QFactory.factory.businessNumber.containsIgnoreCase(businessNumber) : null; + if (repExpr != null && bizExpr != null) return repExpr.or(bizExpr); + if (repExpr != null) return repExpr; + return bizExpr; } private BooleanExpression regionEq(Region region) { diff --git a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/impl/FranchiseRepositoryImpl.java b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/impl/FranchiseRepositoryImpl.java index 3c43de2c5..09a74690c 100644 --- a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/impl/FranchiseRepositoryImpl.java +++ b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/repository/impl/FranchiseRepositoryImpl.java @@ -34,10 +34,8 @@ public Page search(BusinessUnitSearchCondition condition, Pageable pa List content = queryFactory .selectFrom(franchise) .where( - codeContains(condition.code()), - nameContains(condition.name()), - representativeContains(condition.representativeName()), - businessNumberContains(condition.businessNumber()), + nameOrCodeContains(condition.code(), condition.name()), + representativeOrBusinessNumberContains(condition.representativeName(), condition.businessNumber()), regionEq(condition.region()), statusEq(condition.status()), operatingDaysContains(condition.operatingDays()), @@ -52,10 +50,8 @@ public Page search(BusinessUnitSearchCondition condition, Pageable pa .select(franchise.count()) .from(franchise) .where( - codeContains(condition.code()), - nameContains(condition.name()), - representativeContains(condition.representativeName()), - businessNumberContains(condition.businessNumber()), + nameOrCodeContains(condition.code(), condition.name()), + representativeOrBusinessNumberContains(condition.representativeName(), condition.businessNumber()), regionEq(condition.region()), statusEq(condition.status()), operatingDaysContains(condition.operatingDays()), @@ -65,20 +61,20 @@ public Page search(BusinessUnitSearchCondition condition, Pageable pa return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); } - private BooleanExpression codeContains(String code) { - return hasText(code) ? QFranchise.franchise.franchiseCode.contains(code) : null; + private BooleanExpression nameOrCodeContains(String code, String name) { + BooleanExpression codeExpr = hasText(code) ? QFranchise.franchise.franchiseCode.containsIgnoreCase(code) : null; + BooleanExpression nameExpr = hasText(name) ? QFranchise.franchise.name.containsIgnoreCase(name) : null; + if (codeExpr != null && nameExpr != null) return codeExpr.or(nameExpr); + if (codeExpr != null) return codeExpr; + return nameExpr; } - private BooleanExpression nameContains(String name) { - return hasText(name) ? QFranchise.franchise.name.contains(name) : null; - } - - private BooleanExpression representativeContains(String representativeName) { - return hasText(representativeName) ? QFranchise.franchise.representativeName.contains(representativeName) : null; - } - - private BooleanExpression businessNumberContains(String businessNumber) { - return hasText(businessNumber) ? QFranchise.franchise.businessNumber.contains(businessNumber) : null; + private BooleanExpression representativeOrBusinessNumberContains(String representativeName, String businessNumber) { + BooleanExpression repExpr = hasText(representativeName) ? QFranchise.franchise.representativeName.containsIgnoreCase(representativeName) : null; + BooleanExpression bizExpr = hasText(businessNumber) ? QFranchise.franchise.businessNumber.containsIgnoreCase(businessNumber) : null; + if (repExpr != null && bizExpr != null) return repExpr.or(bizExpr); + if (repExpr != null) return repExpr; + return bizExpr; } private BooleanExpression regionEq(Region region) { diff --git a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/BusinessUnitService.java b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/BusinessUnitService.java index 7a3c58bd0..61d0eeedf 100644 --- a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/BusinessUnitService.java +++ b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/BusinessUnitService.java @@ -15,5 +15,5 @@ public interface BusinessUnitService { Map getNamesByIds(List ids); Page getBusinessUnitList(BusinessUnitSearchCondition condition, Pageable pageable); BusinessUnitInternal updateInfo(Long id, BusinessUnitUpdateCommand command); - + List getAllIdsByCondition(BusinessUnitSearchCondition condition); } diff --git a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/impl/FactoryServiceImpl.java b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/impl/FactoryServiceImpl.java index c4dc97464..a20ee751d 100644 --- a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/impl/FactoryServiceImpl.java +++ b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/impl/FactoryServiceImpl.java @@ -12,6 +12,7 @@ import com.chaing.domain.businessunits.repository.FactoryRepository; import com.chaing.domain.businessunits.service.BusinessUnitManagementService; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -41,17 +42,38 @@ public BusinessUnitInternal getById(Long id) { public BusinessUnitInternal updateInfo(Long id, BusinessUnitUpdateCommand command) { Factory factory = factoryRepository.findById(id) .orElseThrow(() -> new BusinessUnitException(BusinessUnitErrorCode.BUSINESS_UNIT_NOT_FOUND)); - factory.updateFactoryInfo(command); - return BusinessUnitInternal.from(factory); + + if (command.name() != null && !factory.getName().equals(command.name().trim())) { + if (factoryRepository.existsByNameExcludeDeleted(command.name().trim())) { + throw new BusinessUnitException(BusinessUnitErrorCode.DUPLICATE_BUSINESS_UNIT_NAME); + } + } + + try { + factory.updateFactoryInfo(command); + factoryRepository.saveAndFlush(factory); + return BusinessUnitInternal.from(factory); + } catch (DataIntegrityViolationException e) { + throw new BusinessUnitException(BusinessUnitErrorCode.DUPLICATE_BUSINESS_UNIT_NAME); + } } // 공장 등록 @Override public BusinessUnitInternal create(BusinessUnitCreateCommand command) { - String generatedCode = codeGenerator.generateFactoryCode(); - Factory factory = Factory.from(command, generatedCode); - factoryRepository.save(factory); - return BusinessUnitInternal.from(factory); + if (command.name() != null && factoryRepository.existsByNameExcludeDeleted(command.name().trim())) { + throw new BusinessUnitException(BusinessUnitErrorCode.DUPLICATE_BUSINESS_UNIT_NAME); + } + + try { + String generatedCode = codeGenerator.generateFactoryCode(); + Factory factory = Factory.from(command, generatedCode); + factoryRepository.saveAndFlush(factory); + return BusinessUnitInternal.from(factory); + + } catch (DataIntegrityViolationException e) { + throw new BusinessUnitException(BusinessUnitErrorCode.DUPLICATE_BUSINESS_UNIT_NAME); + } } // 공장 목록 조회 @@ -60,6 +82,12 @@ public Page getBusinessUnitList(BusinessUnitSearchConditio return factoryRepository.search(condition, pageable).map(BusinessUnitInternal::from); } + // 검색 조건에 따른 ID 리스트 반환 + @Override + public List getAllIdsByCondition(BusinessUnitSearchCondition condition) { + return factoryRepository.findAllIdsByName(condition.name()); + } + // 아이디로 이름 조회 @Override public Map getNamesByIds(List ids) { diff --git a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/impl/FranchiseServiceImpl.java b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/impl/FranchiseServiceImpl.java index 72896cf1a..a02efc943 100644 --- a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/impl/FranchiseServiceImpl.java +++ b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/impl/FranchiseServiceImpl.java @@ -12,6 +12,7 @@ import com.chaing.domain.businessunits.repository.FranchiseRepository; import com.chaing.domain.businessunits.service.BusinessUnitManagementService; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -42,18 +43,39 @@ public BusinessUnitInternal getById(Long id) { public BusinessUnitInternal updateInfo(Long id, BusinessUnitUpdateCommand command) { Franchise franchise = franchiseRepository.findById(id) .orElseThrow(() -> new BusinessUnitException(BusinessUnitErrorCode.BUSINESS_UNIT_NOT_FOUND)); - franchise.updateFranchiseInfo(command); - return BusinessUnitInternal.from(franchise); + + if (command.name() != null && !franchise.getName().equals(command.name().trim())) { + if (franchiseRepository.existsByNameExcludeDeleted(command.name().trim())) { + throw new BusinessUnitException(BusinessUnitErrorCode.DUPLICATE_BUSINESS_UNIT_NAME); + } + } + + try { + franchise.updateFranchiseInfo(command); + franchiseRepository.saveAndFlush(franchise); + return BusinessUnitInternal.from(franchise); + } catch (DataIntegrityViolationException e) { + throw new BusinessUnitException(BusinessUnitErrorCode.DUPLICATE_BUSINESS_UNIT_NAME); + } } // 가맹점 등록 @Override public BusinessUnitInternal create(BusinessUnitCreateCommand command) { - String generatedCode = codeGenerator.generateFranchiseCode(command.region()); - Double distance = calculate(); - Franchise franchise = Franchise.from(command, generatedCode, distance); - franchiseRepository.save(franchise); - return BusinessUnitInternal.from(franchise); + if (command.name() != null && franchiseRepository.existsByNameExcludeDeleted(command.name().trim())) { + throw new BusinessUnitException(BusinessUnitErrorCode.DUPLICATE_BUSINESS_UNIT_NAME); + } + + try { + String generatedCode = codeGenerator.generateFranchiseCode(command.region()); + Double distance = calculate(); + Franchise franchise = Franchise.from(command, generatedCode, distance); + franchiseRepository.saveAndFlush(franchise); + return BusinessUnitInternal.from(franchise); + + } catch (DataIntegrityViolationException e) { + throw new BusinessUnitException(BusinessUnitErrorCode.DUPLICATE_BUSINESS_UNIT_NAME); + } } // 공장과 가맹점 사이 거리 랜덤 생성 @@ -68,16 +90,22 @@ public Page getBusinessUnitList(BusinessUnitSearchConditio return franchiseRepository.search(condition, pageable).map(BusinessUnitInternal::from); } + // 검색 조건에 따른 ID 리스트 반환 + @Override + public List getAllIdsByCondition(BusinessUnitSearchCondition condition) { + return franchiseRepository.findAllIdsByName(condition.name()); + } + // 아이디로 이름 조회 @Override public Map getNamesByIds(List ids) { - if (ids == null || ids.isEmpty()) return Collections.emptyMap(); + if (ids == null || ids.isEmpty()) + return Collections.emptyMap(); List results = franchiseRepository.findNamesByIds(ids); return results.stream().collect(Collectors.toMap( row -> (Long) row[0], - row -> (String) row[1] - )); + row -> (String) row[1])); } // 가맹점 상태 변경 diff --git a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/impl/HeadquarterServiceImpl.java b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/impl/HeadquarterServiceImpl.java index 503053726..38338b414 100644 --- a/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/impl/HeadquarterServiceImpl.java +++ b/module-domain/domain-businessunits/src/main/java/com/chaing/domain/businessunits/service/impl/HeadquarterServiceImpl.java @@ -47,9 +47,18 @@ public Map getNamesByIds(List ids) { // 본사 목록 조회 @Override public Page getBusinessUnitList(BusinessUnitSearchCondition condition, Pageable pageable) { + if (org.springframework.util.StringUtils.hasText(condition.name())) { + return headquarterRepository.findByNameContainingIgnoreCase(condition.name(), pageable).map(BusinessUnitInternal::from); + } return headquarterRepository.findAll(pageable).map(BusinessUnitInternal::from); } + // 검색 조건에 따른 ID 리스트 반환 + @Override + public List getAllIdsByCondition(BusinessUnitSearchCondition condition) { + return headquarterRepository.findAllIdsByName(condition.name()); + } + // 본사 정보 수정 @Override public BusinessUnitInternal updateInfo(Long id, BusinessUnitUpdateCommand command) { diff --git a/module-domain/domain-businessunits/src/test/java/com/chaing/domain/businessunits/service/impl/FactoryServiceImplTests.java b/module-domain/domain-businessunits/src/test/java/com/chaing/domain/businessunits/service/impl/FactoryServiceImplTests.java index 1a2511d52..95c810b9b 100644 --- a/module-domain/domain-businessunits/src/test/java/com/chaing/domain/businessunits/service/impl/FactoryServiceImplTests.java +++ b/module-domain/domain-businessunits/src/test/java/com/chaing/domain/businessunits/service/impl/FactoryServiceImplTests.java @@ -8,6 +8,8 @@ import com.chaing.domain.businessunits.dto.condition.BusinessUnitSearchCondition; import com.chaing.domain.businessunits.dto.internal.BusinessUnitInternal; import com.chaing.domain.businessunits.entity.Factory; +import com.chaing.domain.businessunits.exception.BusinessUnitErrorCode; +import com.chaing.domain.businessunits.exception.BusinessUnitException; import com.chaing.domain.businessunits.repository.FactoryRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,8 +27,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -51,9 +57,10 @@ void create() { // given String generatedCode = "FA01"; BusinessUnitCreateCommand command = new BusinessUnitCreateCommand( - "공장", "주소", "010-0000-0000", "대표", "0123456", + "새공장", "주소", "010-0000-0000", "대표", "0123456", Region.SEOUL, null, new BusinessUnitCreateCommand.FactoryCreate(5) ); + when(factoryRepository.existsByNameExcludeDeleted("새공장")).thenReturn(false); when(codeGenerator.generateFactoryCode()).thenReturn(generatedCode); // when @@ -61,10 +68,30 @@ void create() { // then assertNotNull(result); - verify(factoryRepository, times(1)).save(any(Factory.class)); + verify(factoryRepository, times(1)).saveAndFlush(any(Factory.class)); verify(codeGenerator, times(1)).generateFactoryCode(); } + @Test + @DisplayName("공장 등록 시 DB 유니크 제약 조건 위반 발생") + void create_fail_DataIntegrityViolation() { + + // given + BusinessUnitCreateCommand command = new BusinessUnitCreateCommand( + "중복공장", "주소", "010-0000-0000", "대표", "0123456", + Region.SEOUL, null, new BusinessUnitCreateCommand.FactoryCreate(5) + ); + when(factoryRepository.existsByNameExcludeDeleted(anyString())).thenReturn(false); + when(codeGenerator.generateFactoryCode()).thenReturn("FA01"); + doThrow(new org.springframework.dao.DataIntegrityViolationException("중복")) + .when(factoryRepository).saveAndFlush(any(Factory.class)); + + // when & then + BusinessUnitException exception = assertThrows(BusinessUnitException.class, () -> + factoryService.create(command)); + assertEquals(BusinessUnitErrorCode.DUPLICATE_BUSINESS_UNIT_NAME, exception.getErrorCode()); + } + @Test @DisplayName("공장 목록 조회") void getBusinessUnitList() { @@ -92,6 +119,43 @@ void getBusinessUnitList() { verify(factoryRepository, times(1)).search(condition, pageable); } + @Test + @DisplayName("검색 조건에 따른 모든 공장 ID 리스트 조회") + void getAllIdsByCondition() { + + // given + BusinessUnitSearchCondition condition = new BusinessUnitSearchCondition( + null, "검색어", null, null, null, null, null, null + ); + List expectedIds = List.of(1L, 2L, 3L); + when(factoryRepository.findAllIdsByName("검색어")).thenReturn(expectedIds); + + // when + List result = factoryService.getAllIdsByCondition(condition); + + // then + assertEquals(3, result.size()); + assertEquals(1L, result.get(0)); + assertEquals(expectedIds, result); + verify(factoryRepository, times(1)).findAllIdsByName("검색어"); + } + + @Test + @DisplayName("이미 존재하는 이름으로 등록 시 예외 발생") + void create_fail_duplicateName() { + + // given + BusinessUnitCreateCommand command = new BusinessUnitCreateCommand( + "중복공장", "주소", "010-0000-0000", "대표", "0123456", + Region.SEOUL, null, null + ); + when(factoryRepository.existsByNameExcludeDeleted("중복공장")).thenReturn(true); + + // when & then + assertThrows(BusinessUnitException.class, () -> factoryService.create(command)); + verify(factoryRepository, never()).save(any()); + } + @Test @DisplayName("공장 조회") void getById() { @@ -116,25 +180,39 @@ void updateInfo() { // given Long id = 1L; - Factory factory = Factory.builder().factoryId(id).name("기존 이름").productionLineCount(10).build(); - + Factory factory = Factory.builder().factoryId(id).name("기존 이름").build(); BusinessUnitUpdateCommand command = new BusinessUnitUpdateCommand( - "변경 이름", null, null, null, null, - null, null, new BusinessUnitUpdateCommand.FactoryUpdate(null) + "새 이름", null, null, null, null, null, null, + new BusinessUnitUpdateCommand.FactoryUpdate(15) ); when(factoryRepository.findById(id)).thenReturn(Optional.of(factory)); + when(factoryRepository.existsByNameExcludeDeleted("새 이름")).thenReturn(false); // when BusinessUnitInternal result = factoryService.updateInfo(id, command); // then - assertEquals("변경 이름", factory.getName()); - assertEquals(10, factory.getProductionLineCount()); - assertNotNull(result); - assertEquals("변경 이름", result.name()); - assertEquals(id, result.id()); - verify(factoryRepository, times(1)).findById(id); + assertEquals("새 이름", result.name()); + verify(factoryRepository).existsByNameExcludeDeleted("새 이름"); + } + + @Test + @DisplayName("공장 정보 수정 시 중복 이름 예외 발생") + void updateInfo_fail_duplicateName() { + + // given + Long id = 1L; + Factory factory = Factory.builder().factoryId(id).name("기존공장").build(); + BusinessUnitUpdateCommand command = new BusinessUnitUpdateCommand( + "중복된이름", null, null, null, null, null, null, null + ); + when(factoryRepository.findById(id)).thenReturn(Optional.of(factory)); + when(factoryRepository.existsByNameExcludeDeleted("중복된이름")).thenReturn(true); + + // when & then + BusinessUnitException exception = assertThrows(BusinessUnitException.class, () -> factoryService.updateInfo(id, command)); + assertEquals(BusinessUnitErrorCode.DUPLICATE_BUSINESS_UNIT_NAME, exception.getErrorCode()); } @Test diff --git a/module-domain/domain-businessunits/src/test/java/com/chaing/domain/businessunits/service/impl/FranchiseServiceImplTests.java b/module-domain/domain-businessunits/src/test/java/com/chaing/domain/businessunits/service/impl/FranchiseServiceImplTests.java index 139b6d0f9..f36a2cf12 100644 --- a/module-domain/domain-businessunits/src/test/java/com/chaing/domain/businessunits/service/impl/FranchiseServiceImplTests.java +++ b/module-domain/domain-businessunits/src/test/java/com/chaing/domain/businessunits/service/impl/FranchiseServiceImplTests.java @@ -8,6 +8,8 @@ import com.chaing.domain.businessunits.dto.condition.BusinessUnitSearchCondition; import com.chaing.domain.businessunits.dto.internal.BusinessUnitInternal; import com.chaing.domain.businessunits.entity.Franchise; +import com.chaing.domain.businessunits.exception.BusinessUnitErrorCode; +import com.chaing.domain.businessunits.exception.BusinessUnitException; import com.chaing.domain.businessunits.repository.FranchiseRepository; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -21,8 +23,7 @@ import org.springframework.data.domain.Pageable; import java.time.LocalTime; -import java.util.List; -import java.util.Optional; +import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -41,6 +42,24 @@ class FranchiseServiceImplTests { @InjectMocks private FranchiseServiceImpl franchiseService; + @Test + @DisplayName("가맹점 조회") + void getById() { + + // given + Long id = 1L; + Franchise franchise = Franchise.builder().franchiseId(id).name("가맹점1").build(); + when(franchiseRepository.findById(id)).thenReturn(Optional.of(franchise)); + + // when + BusinessUnitInternal result = franchiseService.getById(id); + + // then + assertNotNull(result); + assertEquals("가맹점1", result.name()); + verify(franchiseRepository, times(1)).findById(id); + } + @Test @DisplayName("가맹점 등록") void create() { @@ -49,9 +68,11 @@ void create() { String generatedCode = "SE01"; BusinessUnitCreateCommand command = new BusinessUnitCreateCommand( "신규 가맹점", "서울시 서초구", "010-1111-2222", "대표", "0123456", - Region.SEOUL, new BusinessUnitCreateCommand.FranchiseCreate("월화수목금", LocalTime.of(9,0), LocalTime.of(22,0)), null + Region.SEOUL, + new BusinessUnitCreateCommand.FranchiseCreate("월화수목금", LocalTime.of(9,0), LocalTime.of(22,0)), + null ); - + when(franchiseRepository.existsByNameExcludeDeleted("신규 가맹점")).thenReturn(false); when(codeGenerator.generateFranchiseCode(Region.SEOUL)).thenReturn(generatedCode); // when @@ -59,52 +80,50 @@ void create() { // then assertNotNull(result); - verify(franchiseRepository, times(1)).save(any(Franchise.class)); + verify(franchiseRepository, times(1)).saveAndFlush(any(Franchise.class)); verify(codeGenerator, times(1)).generateFranchiseCode(Region.SEOUL); } @Test - @DisplayName("가맹점 목록 조회") - void getBusinessUnitList() { - + @DisplayName("가맹점 등록 시 DB 유니크 제약 조건 위반 발생") + void create_fail_DataIntegrityViolation() { // given - Pageable pageable = PageRequest.of(0, 10); - BusinessUnitSearchCondition condition = new BusinessUnitSearchCondition( - null, null, null, null, null, null, null, null + BusinessUnitCreateCommand command = new BusinessUnitCreateCommand( + "중복가맹점", "주소", "010-1111-2222", "대표", "0123456", + Region.SEOUL, new BusinessUnitCreateCommand.FranchiseCreate("월-금", LocalTime.of(9,0), LocalTime.of(22,0)), null ); - List franchises = List.of( - Franchise.builder().franchiseId(1L).name("가맹점1").build(), - Franchise.builder().franchiseId(2L).name("가맹점2").build() - ); + when(franchiseRepository.existsByNameExcludeDeleted(anyString())).thenReturn(false); + when(codeGenerator.generateFranchiseCode(any())).thenReturn("SE01"); - Page franchisePage = new PageImpl<>(franchises, pageable, franchises.size()); - when(franchiseRepository.search(eq(condition), eq(pageable))).thenReturn(franchisePage); + // 강제 예외 발생 + doThrow(new org.springframework.dao.DataIntegrityViolationException("중복")) + .when(franchiseRepository).saveAndFlush(any(Franchise.class)); - // when - Page result = franchiseService.getBusinessUnitList(condition, pageable); + // when & then + BusinessUnitException exception = assertThrows(BusinessUnitException.class, () -> + franchiseService.create(command)); - // then - assertEquals(2, result.getContent().size()); - verify(franchiseRepository, times(1)).search(condition, pageable); + assertEquals(BusinessUnitErrorCode.DUPLICATE_BUSINESS_UNIT_NAME, exception.getErrorCode()); } @Test - @DisplayName("가맹점 조회") - void getById() { + @DisplayName("가맹점 정보 수정 시 중복된 이름으로 인한 DB 예외 발생") + void updateInfo_fail_DataIntegrityViolation() { // given Long id = 1L; - Franchise franchise = Franchise.builder().franchiseId(id).name("가맹점").build(); + Franchise franchise = Franchise.builder().franchiseId(id).name("기존점").build(); + BusinessUnitUpdateCommand command = new BusinessUnitUpdateCommand( + "중복된이름", null, null, null, null, null, null, null + ); when(franchiseRepository.findById(id)).thenReturn(Optional.of(franchise)); + when(franchiseRepository.existsByNameExcludeDeleted("중복된이름")).thenReturn(false); + doThrow(new org.springframework.dao.DataIntegrityViolationException("중복")) + .when(franchiseRepository).saveAndFlush(any(Franchise.class)); - // when - BusinessUnitInternal result = franchiseService.getById(id); - - // then - assertNotNull(result); - assertEquals("가맹점", result.name()); - verify(franchiseRepository, times(1)).findById(id); + // when & then + assertThrows(BusinessUnitException.class, () -> franchiseService.updateInfo(id, command)); } @Test @@ -113,22 +132,58 @@ void updateInfo() { // given Long id = 1L; - Franchise franchise = Franchise.builder().franchiseId(id).name("기존 가맹점").phone("010-1234-5678").build(); - + Franchise franchise = Franchise.builder().franchiseId(id).name("기존점").build(); BusinessUnitUpdateCommand command = new BusinessUnitUpdateCommand( - "변경된 가맹점", null, null, null, null, null, - new BusinessUnitUpdateCommand.FranchiseUpdate(null, null, null, null, null), - null - ); + "새이름", null, null, null, null, null, + new BusinessUnitUpdateCommand.FranchiseUpdate(null, null, null, null, null), null); when(franchiseRepository.findById(id)).thenReturn(Optional.of(franchise)); + when(franchiseRepository.existsByNameExcludeDeleted("새이름")).thenReturn(false); // when - franchiseService.updateInfo(id, command); + BusinessUnitInternal result = franchiseService.updateInfo(id, command); // then - assertEquals("변경된 가맹점", franchise.getName()); - verify(franchiseRepository, times(1)).findById(id); + assertEquals("새이름", result.name()); + verify(franchiseRepository, times(1)).saveAndFlush(any(Franchise.class)); + } + + @Test + @DisplayName("가맹점 목록 조회") + void getBusinessUnitList() { + + // given + Pageable pageable = PageRequest.of(0, 10); + BusinessUnitSearchCondition condition = new BusinessUnitSearchCondition(null, null, null, null, null, null, null, null); + List franchises = List.of(Franchise.builder().franchiseId(1L).name("가맹점").build()); + Page franchisePage = new PageImpl<>(franchises, pageable, 1); + when(franchiseRepository.search(eq(condition), eq(pageable))).thenReturn(franchisePage); + + // when + Page result = franchiseService.getBusinessUnitList(condition, pageable); + + // then + assertEquals(1, result.getContent().size()); + verify(franchiseRepository, times(1)).search(condition, pageable); + } + + @Test + @DisplayName("검색 조건에 따른 모든 가맹점 ID 리스트 조회") + void getAllIdsByCondition() { + + // given + BusinessUnitSearchCondition condition = new BusinessUnitSearchCondition(null, "강남점", null, null, null, null, null, null); + List expectedIds = List.of(10L, 20L); + when(franchiseRepository.findAllIdsByName("강남점")).thenReturn(expectedIds); + + // when + List result = franchiseService.getAllIdsByCondition(condition); + + // then + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.contains(10L)); + verify(franchiseRepository, times(1)).findAllIdsByName("강남점"); } @Test @@ -145,7 +200,6 @@ void updateStatus() { // then assertEquals(UsableStatus.INACTIVE, franchise.getStatus()); - verify(franchiseRepository, times(1)).findById(id); } @Test @@ -165,16 +219,33 @@ void delete() { } @Test - @DisplayName("가맹점 경고 부여 및 패널티") - void addWarning() { + @DisplayName("가맹점 경고 조회") + void getWarningCount() { + + // given + Long id = 1L; + Franchise franchise = Franchise.builder().franchiseId(id).warningCount(2).build(); + when(franchiseRepository.findByFranchiseIdAndDeletedAtIsNull(id)).thenReturn(Optional.of(franchise)); + + // when + int count = franchiseService.getWarningCount(id); + + // then + assertEquals(2, count); + } + + @Test + @DisplayName("경고 횟수 검증 및 증가") + void validateWarningCount() { // given Long id = 1L; Franchise franchise = spy(Franchise.builder().franchiseId(id).warningCount(2).build()); + when(franchiseRepository.findByFranchiseIdAndDeletedAtIsNull(id)).thenReturn(Optional.of(franchise)); when(franchiseRepository.findById(id)).thenReturn(Optional.of(franchise)); // when - franchiseService.addWarning(id); + franchiseService.validateWarningCount(id); // then assertEquals(3, franchise.getWarningCount()); diff --git a/module-domain/domain-businessunits/src/test/java/com/chaing/domain/businessunits/service/impl/HeadquarterServiceImplTests.java b/module-domain/domain-businessunits/src/test/java/com/chaing/domain/businessunits/service/impl/HeadquarterServiceImplTests.java index 41d019173..e6df436c0 100644 --- a/module-domain/domain-businessunits/src/test/java/com/chaing/domain/businessunits/service/impl/HeadquarterServiceImplTests.java +++ b/module-domain/domain-businessunits/src/test/java/com/chaing/domain/businessunits/service/impl/HeadquarterServiceImplTests.java @@ -1,6 +1,7 @@ package com.chaing.domain.businessunits.service.impl; import com.chaing.domain.businessunits.dto.command.BusinessUnitUpdateCommand; +import com.chaing.domain.businessunits.dto.condition.BusinessUnitSearchCondition; import com.chaing.domain.businessunits.dto.internal.BusinessUnitInternal; import com.chaing.domain.businessunits.entity.Headquarter; import com.chaing.domain.businessunits.repository.HeadquarterRepository; @@ -10,13 +11,15 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; -import java.util.Optional; +import java.util.*; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class HeadquarterServiceImplTests { @@ -42,8 +45,70 @@ void getById() { // then assertNotNull(result); assertEquals("본사", result.name()); - assertEquals("대표", result.representativeName()); - verify(headquarterRepository, times(1)).findById(id); + verify(headquarterRepository).findById(id); + } + + @Test + @DisplayName("아이디 목록으로 이름 조회") + void getNamesByIds() { + + // given + List ids = Arrays.asList(1L, 2L); + List mockResults = new ArrayList<>(); + mockResults.add(new Object[]{1L, "본사A"}); + mockResults.add(new Object[]{2L, "본사B"}); + + when(headquarterRepository.findNamesByIds(ids)).thenReturn(mockResults); + + // when + Map result = headquarterService.getNamesByIds(ids); + + // then + assertEquals(2, result.size()); + assertEquals("본사A", result.get(1L)); + assertEquals("본사B", result.get(2L)); + } + + @Test + @DisplayName("본사 목록 조회") + void getBusinessUnitList() { + + // given + Pageable pageable = PageRequest.of(0, 10); + BusinessUnitSearchCondition condition = new BusinessUnitSearchCondition(null, "삼성", null, null, null, null, null, null); + + Headquarter hq = Headquarter.builder().hqId(1L).name("삼성본사").build(); + Page hqPage = new PageImpl<>(Collections.singletonList(hq)); + + when(headquarterRepository.findByNameContainingIgnoreCase("삼성", pageable)).thenReturn(hqPage); + + // when + Page result = headquarterService.getBusinessUnitList(condition, pageable); + + // then + assertEquals(1, result.getContent().size()); + assertEquals("삼성본사", result.getContent().get(0).name()); + verify(headquarterRepository).findByNameContainingIgnoreCase("삼성", pageable); + } + + @Test + @DisplayName("검색 조건에 따른 모든 본사 ID 리스트 조회") + void getAllIdsByCondition() { + + // given + BusinessUnitSearchCondition condition = new BusinessUnitSearchCondition( + null, "본사", null, null, null, null, null, null); + List expectedIds = List.of(1L, 2L); + when(headquarterRepository.findAllIdsByName("본사")).thenReturn(expectedIds); + + // when + List result = headquarterService.getAllIdsByCondition(condition); + + // then + assertNotNull(result); + assertEquals(2, result.size()); + assertTrue(result.contains(1L)); + verify(headquarterRepository, times(1)).findAllIdsByName("본사"); } @Test @@ -52,22 +117,31 @@ void updateInfo() { // given Long id = 1L; - Headquarter hq = Headquarter.builder().hqId(id).name("기존 본사").address("서울시 서초구").phone("02-123-4567").build(); + Headquarter hq = Headquarter.builder().hqId(id).name("기존").build(); + BusinessUnitUpdateCommand command = new BusinessUnitUpdateCommand("변경", null, null, null, null, null, null, null); - BusinessUnitUpdateCommand command = new BusinessUnitUpdateCommand( - "변경된 본사", null, null, null, null, - null, null, null); when(headquarterRepository.findById(id)).thenReturn(Optional.of(hq)); // when BusinessUnitInternal result = headquarterService.updateInfo(id, command); // then - assertNotNull(result); - assertEquals("변경된 본사", hq.getName()); - assertEquals("서울시 서초구", hq.getAddress()); - assertEquals("02-123-4567", hq.getPhone()); + assertEquals("변경", result.name()); + } + + @Test + @DisplayName("본사 코드 조회") + void getHqCode() { + + // given + Long id = 1L; + Headquarter hq = Headquarter.builder().hqId(id).hqCode("HQ001").build(); + when(headquarterRepository.findById(id)).thenReturn(Optional.of(hq)); - verify(headquarterRepository, times(1)).findById(id); + // when + String code = headquarterService.getHqCode(id); + + // then + assertEquals("HQ001", code); } } \ No newline at end of file diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/dto/info/InboundProductIdInfo.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/dto/info/InboundProductIdInfo.java new file mode 100644 index 000000000..ed59b175c --- /dev/null +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/dto/info/InboundProductIdInfo.java @@ -0,0 +1,8 @@ +package com.chaing.domain.inventories.dto.info; + +public record InboundProductIdInfo( + Long productId, + Long quantity +) { + +} diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/dto/info/StockInfoForLog.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/dto/info/StockInfoForLog.java new file mode 100644 index 000000000..d3745f88c --- /dev/null +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/dto/info/StockInfoForLog.java @@ -0,0 +1,11 @@ +package com.chaing.domain.inventories.dto.info; + +public record StockInfoForLog( + Long orderId, + String boxCode, + Long productId +) { + public static StockInfoForLog create(Long orderId, String boxCode, Long productId){ + return new StockInfoForLog(orderId, boxCode, productId); + } +} diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/dto/response/FranchiseInventoryBatchResponse.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/dto/response/FranchiseInventoryBatchResponse.java index 50479ce92..6eacdd466 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/dto/response/FranchiseInventoryBatchResponse.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/dto/response/FranchiseInventoryBatchResponse.java @@ -6,7 +6,6 @@ public record FranchiseInventoryBatchResponse( LocalDate manufactureDate, // 제조일 Integer totalQuantity, // 총 수량 - Integer availableQuantity, // 가용 수량 - Integer returnPending // 반품 대기 + Integer availableQuantity // 가용 수량 -) {} \ No newline at end of file +) {} diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/entity/FranchiseInventory.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/entity/FranchiseInventory.java index e0e4474b6..f7609e09b 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/entity/FranchiseInventory.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/entity/FranchiseInventory.java @@ -81,7 +81,7 @@ public class FranchiseInventory extends BaseEntity { private String orderCode; @Column - LocalDateTime shippedAt; // 배송 완료 일자 + LocalDateTime shippedAt; // 배송 완료 일자 @Column LocalDateTime receivedAt; // 입고 완료 일자 diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/FactoryInventoryRepository.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/FactoryInventoryRepository.java index 5d835f5fe..d590f3dd3 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/FactoryInventoryRepository.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/FactoryInventoryRepository.java @@ -1,6 +1,7 @@ package com.chaing.domain.inventories.repository; import com.chaing.core.enums.LogType; +import com.chaing.domain.inventories.dto.info.InboundProductIdInfo; import com.chaing.domain.inventories.entity.FactoryInventory; import com.chaing.domain.inventories.repository.interfaces.FactoryInventoryRepositoryCustom; import org.springframework.data.jpa.repository.JpaRepository; @@ -32,9 +33,15 @@ void setTargetStatusBySerialCodeIn( @Param("targetStatus") LogType targetStatus); @Modifying - @Query("update FactoryInventory f set f.boxCode = :boxCode where f.serialCode in :ids") + @Query("update FactoryInventory f " + + "set f.boxCode = :boxCode" + + ", f.orderId = :orderId" + + ", f.orderItemId = :orderItemId " + + "where f.serialCode in :ids") void setBoxCode( @Param("boxCode") String boxCode, + @Param("orderId") Long orderId, + @Param("orderItemId") Long orderItemId, @Param("ids") List selectedList); @Modifying(clearAutomatically = true) @@ -54,7 +61,9 @@ List findAllByBoxCodeAndStatuses( ); List findAllByInventoryIdIn(List selectedList); - void deleteByInventoryIdIn(List longs); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("delete from FactoryInventory f where f.inventoryId in :ids") + void deleteByInventoryIdIn(@Param("ids") List ids); @Query("SELECT i FROM FactoryInventory i WHERE i.boxCode = :boxCode") List findAllByBoxCode(String boxCode); @@ -66,4 +75,18 @@ List findAllByBoxCodeAndStatuses( @Query("select i.orderId from FactoryInventory i where i.serialCode in(:selectedList)") List getOrderIdBySerialCodeIn(@Param("selectedList") List selectedList); + + @Query(""" + select new com.chaing.domain.inventories.dto.info.InboundProductIdInfo( + i.productId, + count(i) + ) + from FactoryInventory i + where i.serialCode in (:serialCodes) + group by i.productId + """) + List getInboundProductIdInfosBySerialCodes(@Param("serialCodes") List serialCodes); + + @Query("select f from FactoryInventory f where f.serialCode = :serialCode") + FactoryInventory isAlreadyExist(@Param("serialCode") String serialCode); } diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/FranchiseInventoryRepository.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/FranchiseInventoryRepository.java index 0f47eefc9..372c280c8 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/FranchiseInventoryRepository.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/FranchiseInventoryRepository.java @@ -22,16 +22,20 @@ public interface FranchiseInventoryRepository extends JpaRepository findAllByBoxCodeIn(List boxCodes); + List findAllByBoxCodeInAndDeletedAtIsNull(List boxCodes); + List findAllByStatus(LogType status); @Modifying - @Query("UPDATE FranchiseInventory i SET i.status = 'com.chaing.core.enums.LogType.AVAILABLE' WHERE i.serialCode IN :serials") + @Query("UPDATE FranchiseInventory i SET i.status = 'AVAILABLE' WHERE i.serialCode IN :serials") void updateAllStatusInboundBySerialCode(@Param("serials") List serials); @Query("SELECT DISTINCT fi.franchiseId FROM FranchiseInventory fi") List getAllFranchiseIds(); - void deleteByFranchiseIdAndInventoryIdIn(Long aLong, List longs); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("delete from FranchiseInventory f where f.franchiseId = :franchiseId and f.inventoryId in :ids") + void deleteByFranchiseIdAndInventoryIdIn(@Param("franchiseId") Long franchiseId, @Param("ids") List ids); List findAllByBoxCode(String boxCode); List findByInventoryIdIn(List ids); @@ -43,4 +47,8 @@ List getAllByStatusAndSerialCodeAndFranchiseId( @Param("serialCodes") @NotEmpty(message = "선택된 제품이 존재하지 않습니다.") List serialCodes, @Param("franchiseId") Long franchiseId, @Param("status") LogType status); + + List getAllByFranchiseIdAndSerialCodeIn( + @Param("franchiseId") Long franchiseId, + @Param("serialCodes") List serialCodes); } diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/HQInventoryRepository.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/HQInventoryRepository.java index a69cc721a..40ee95c81 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/HQInventoryRepository.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/HQInventoryRepository.java @@ -3,6 +3,9 @@ import com.chaing.domain.inventories.entity.HQInventory; import com.chaing.domain.inventories.repository.interfaces.HQInventoryRepositoryCustom; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -10,7 +13,9 @@ @Repository public interface HQInventoryRepository extends JpaRepository, HQInventoryRepositoryCustom { List findAllByBoxCodeInAndDeletedAtIsNull(List requestedBoxCodes); - void deleteByInventoryIdIn(List longs); + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("delete from HQInventory h where h.inventoryId in :ids") + void deleteByInventoryIdIn(@Param("ids") List ids); List findByInventoryIdIn(List ids); List findByBoxCodeIn(List boxCodes); diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/impl/FactoryInventoryRepositoryImpl.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/impl/FactoryInventoryRepositoryImpl.java index e283ce8c5..b622aab46 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/impl/FactoryInventoryRepositoryImpl.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/impl/FactoryInventoryRepositoryImpl.java @@ -30,6 +30,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -88,6 +89,30 @@ public Map getStock(List products, Str r -> r)); } + @Override + public Map countByBoxCodes(List boxCodes) { + if (boxCodes == null || boxCodes.isEmpty()) { + return Map.of(); + } + + List rows = queryFactory + .select(factoryInventory.boxCode, factoryInventory.count()) + .from(factoryInventory) + .where( + factoryInventory.boxCode.in(boxCodes), + factoryInventory.deletedAt.isNull()) + .groupBy(factoryInventory.boxCode) + .fetch(); + + Map result = new LinkedHashMap<>(); + for (Tuple row : rows) { + result.put( + row.get(factoryInventory.boxCode), + row.get(factoryInventory.count())); + } + return result; + } + // 중분류 @Override public Page getBatches(Long productId, Pageable pageable) { diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/impl/FranchiseInventoryRepositoryImpl.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/impl/FranchiseInventoryRepositoryImpl.java index cbc7b5569..c88c6479f 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/impl/FranchiseInventoryRepositoryImpl.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/impl/FranchiseInventoryRepositoryImpl.java @@ -30,6 +30,7 @@ import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -87,6 +88,31 @@ public Map getFranchiseStock(Long franchiseI r -> r)); } + @Override + public Map countByBoxCodes(Long franchiseId, List boxCodes) { + if (boxCodes == null || boxCodes.isEmpty()) { + return Map.of(); + } + + List rows = queryFactory + .select(franchiseInventory.boxCode, franchiseInventory.count()) + .from(franchiseInventory) + .where( + franchiseInventory.franchiseId.eq(franchiseId), + franchiseInventory.boxCode.in(boxCodes), + franchiseInventory.deletedAt.isNull()) + .groupBy(franchiseInventory.boxCode) + .fetch(); + + Map result = new LinkedHashMap<>(); + for (Tuple row : rows) { + result.put( + row.get(franchiseInventory.boxCode), + row.get(franchiseInventory.count())); + } + return result; + } + // 중분류 @Override public Page getFranchiseBatches(Long franchiseId, Long productId, Pageable pageable) { @@ -96,17 +122,12 @@ public Page getFranchiseBatches(Long franchiseI .when(franchiseInventory.status.eq(LogType.AVAILABLE)).then(1) .otherwise(0).sum().intValue(); - NumberExpression returnPending = new CaseBuilder() - .when(franchiseInventory.status.eq(LogType.RETURN_WAIT)).then(1) - .otherwise(0).sum().intValue(); - List content = queryFactory .select(Projections.constructor( FranchiseInventoryBatchResponse.class, franchiseInventory.manufactureDate, quantity, - availableQuantity, - returnPending)) + availableQuantity)) .from(franchiseInventory) .where( franchiseInventory.franchiseId.eq(franchiseId), diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/interfaces/FactoryInventoryRepositoryCustom.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/interfaces/FactoryInventoryRepositoryCustom.java index f9bd30f66..022e47d69 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/interfaces/FactoryInventoryRepositoryCustom.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/interfaces/FactoryInventoryRepositoryCustom.java @@ -9,6 +9,8 @@ import com.chaing.domain.inventories.dto.response.SafetyStockResponse; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import java.time.LocalDate; import java.util.List; @@ -17,10 +19,14 @@ public interface FactoryInventoryRepositoryCustom { Map getStock(List products, String status); + Map countByBoxCodes(List boxCodes); + Page getBatches(Long productId, Pageable pageable); Page getItems(HQInventoryItemsRequest request, Pageable pageable); + @Modifying + @Query("UPDATE FactoryInventory fi SET fi.status = :status WHERE fi.serialCode IN :serialCode") void updateStatus(List serialCode, LogType status); void deleteFactoryInventory(List serialCode); diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/interfaces/FranchiseInventoryRepositoryCustom.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/interfaces/FranchiseInventoryRepositoryCustom.java index e114e1352..f8f7fae17 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/interfaces/FranchiseInventoryRepositoryCustom.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/repository/interfaces/FranchiseInventoryRepositoryCustom.java @@ -15,6 +15,8 @@ public interface FranchiseInventoryRepositoryCustom { Map getFranchiseStock(Long franchiseId, List ids, String status); + Map countByBoxCodes(Long franchiseId, List boxCodes); + Page getFranchiseBatches(Long franchiseId, Long productId, Pageable pageable); Page getFranchiseItems(Long franchiseId, FranchiseInventoryItemsRequest request, Pageable pageable); diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/service/InventoryService.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/service/InventoryService.java index 50e8b2168..4051f14fb 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/service/InventoryService.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/service/InventoryService.java @@ -6,6 +6,8 @@ import com.chaing.core.dto.info.ReturnItemInspection; import com.chaing.core.enums.LogType; import com.chaing.core.enums.ReturnItemStatus; +import com.chaing.domain.inventories.dto.info.InboundProductIdInfo; +import com.chaing.domain.inventories.dto.info.StockInfoForLog; import com.chaing.domain.inventories.dto.request.DisposalRequest; import com.chaing.domain.inventories.dto.request.FranchiseInventoryItemsRequest; import com.chaing.domain.inventories.dto.request.HQInventoryItemsRequest; @@ -36,6 +38,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.util.HashSet; @@ -159,9 +162,10 @@ public void updateExpiredStatus() { } // 배송 중 상태 변경 - public void updateShippingStatus(List serialCodes) { + @Transactional + public void updateShippingStatus(List serialCodes, LogType targetStatus) { // 해당 seralCode 배송 중으로 변경 - factoryInventoryRepository.updateStatus(serialCodes, LogType.SHIPPING); + factoryInventoryRepository.updateStatus(serialCodes, targetStatus); } public void updateFranchiseShippingStatus(Long franchiseId, List serialCodes) { @@ -389,15 +393,29 @@ public void setSafetyStock(SafetyStockRequest request) { } + // boxCode 목록으로 HQInventory 조회 + public List getHQInventoriesByBoxCodes(List boxCodes) { + List inventories = hqInventoryRepository.findAllByBoxCodeInAndDeletedAtIsNull(boxCodes); + return inventories != null ? inventories : List.of(); + } + + // boxCode 목록으로 FranchiseInventory 조회 + public List getFranchiseInventoriesByBoxCodes(List boxCodes) { + List inventories = franchiseInventoryRepository.findAllByBoxCodeInAndDeletedAtIsNull(boxCodes); + return inventories != null ? inventories : List.of(); + } + // return: 데이터 누락 있는지 없는지 public void verifyOmission(List requestedBoxCodes) { List inventories = hqInventoryRepository.findAllByBoxCodeInAndDeletedAtIsNull(requestedBoxCodes); + Set requestedBoxCodesSet = new HashSet<>(requestedBoxCodes); + Set boxCodes = inventories.stream() .map(HQInventory::getBoxCode) .collect(Collectors.toSet()); - if (boxCodes.isEmpty() || boxCodes.size() != requestedBoxCodes.size()) { + if (boxCodes.isEmpty() || boxCodes.size() != requestedBoxCodesSet.size()) { throw new InventoriesException(InventoriesErrorCode.DATA_OMISSION); } } @@ -680,4 +698,71 @@ public Map getReturnItemInspectionFromFranchise(Li .build() )); } + + public List getStockBySerialCodeFromFranchise(List serialCodes, Long franchiseId) { + + if(serialCodes == null || serialCodes.isEmpty()){ + throw new InventoriesException(InventoriesErrorCode.INVENTORIES_SERIAL_CODE_IS_NULL); + } + List itemInfosForLog = franchiseInventoryRepository.getAllByFranchiseIdAndSerialCodeIn(franchiseId, serialCodes); + + if(itemInfosForLog == null || itemInfosForLog.isEmpty()){ + throw new InventoriesException(InventoriesErrorCode.INVENTORIES_IS_INVALID); + } + + return itemInfosForLog.stream() + .map(item -> StockInfoForLog.create(item.getOrderId(), item.getBoxCode(), item.getProductId())) + .distinct() + .toList(); + } + + public List getStockBySerialCodeFromFactory(List serialCodes) { + if(serialCodes == null || serialCodes.isEmpty()){ + throw new InventoriesException(InventoriesErrorCode.INVENTORIES_SERIAL_CODE_IS_NULL); + } + List itemInfosForLog = factoryInventoryRepository.findAllBySerialCodeIn(serialCodes); + + if(itemInfosForLog == null || itemInfosForLog.isEmpty()){ + throw new InventoriesException(InventoriesErrorCode.INVENTORIES_IS_INVALID); + } + + return itemInfosForLog.stream() + .map(item -> StockInfoForLog.create(item.getOrderId(), item.getBoxCode(), item.getProductId())) + .distinct() + .toList(); + } + + public Map getFactoryQuantityByBoxCodes(List boxCodes) { + return factoryInventoryRepository.countByBoxCodes(boxCodes); + } + + public Map getFranchiseQuantityByBoxCodes(Long franchiseId, List boxCodes) { + return franchiseInventoryRepository.countByBoxCodes(franchiseId, boxCodes); + } + + public List getProductIdFromFactory(List serialCodes) { + if(serialCodes == null || serialCodes.isEmpty()){ + throw new InventoriesException(InventoriesErrorCode.INVENTORIES_SERIAL_CODE_IS_NULL); + } + List itemInfos = factoryInventoryRepository.getInboundProductIdInfosBySerialCodes(serialCodes); + if(itemInfos == null || itemInfos.isEmpty()){ + throw new InventoriesException(InventoriesErrorCode.INVENTORIES_IS_INVALID); + } + + return itemInfos; + } + + // return: Map + public Map getFactoryInventoryByProductId(List productIds) { + // Set + Set productIdSet = new HashSet<>(productIds); + + List inventories = factoryInventoryRepository.findAllByProductIdInAndStatusAndDeletedAtIsNull(productIdSet, LogType.AVAILABLE); + + return inventories.stream() + .collect(Collectors.groupingBy( + FactoryInventory::getProductId, + Collectors.counting() + )); + } } diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/service/OutboundService.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/service/OutboundService.java index 5b2a1e0e7..79f710b2d 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/service/OutboundService.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/service/OutboundService.java @@ -71,14 +71,14 @@ public void updateStatus(List selectedList, LogType currentStatus) { // 박스 할당 @Transactional - public void assignBox(String boxCode, List serialCodes) { + public void assignBox(String boxCode, Long orderId, Long orderItemId, List serialCodes) { List selectedList = getListAndValidate(serialCodes); List confirmedIds = selectedList.stream() .map(FactoryInventory::getSerialCode) .toList(); - outboundExecutor.assignBoxCode(boxCode, confirmedIds); + outboundExecutor.assignBoxCode(boxCode, orderId, orderItemId, confirmedIds); } // 할당 취소 및 출고 취소 diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/inbound/reader/FactoryInboundReaderImpl.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/inbound/reader/FactoryInboundReaderImpl.java index 0eb5c403b..26b0901f6 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/inbound/reader/FactoryInboundReaderImpl.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/inbound/reader/FactoryInboundReaderImpl.java @@ -3,6 +3,8 @@ import com.chaing.core.enums.LogType; import com.chaing.domain.inventories.dto.raw.FactoryInventoryRawData; import com.chaing.domain.inventories.entity.FactoryInventory; +import com.chaing.domain.inventories.exception.InventoriesErrorCode; +import com.chaing.domain.inventories.exception.InventoriesException; import com.chaing.domain.inventories.repository.FactoryInventoryRepository; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Qualifier; @@ -16,9 +18,17 @@ public class FactoryInboundReaderImpl implements InboundReader { private final FactoryInventoryRepository repository; + @Override public boolean existsBySerialCode(String serialCode) { - return false; + + FactoryInventory isExist = repository.isAlreadyExist(serialCode); + + if(isExist == null) { + return false; + } + + return true; } @Override diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/inbound/validator/FactoryInboundValidatorImpl.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/inbound/validator/FactoryInboundValidatorImpl.java index 8731dff86..840b071af 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/inbound/validator/FactoryInboundValidatorImpl.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/inbound/validator/FactoryInboundValidatorImpl.java @@ -22,7 +22,7 @@ public class FactoryInboundValidatorImpl @Qualifier("factory") private final InboundReader inboundReader; - public final int SERIAL_CODE_LENGTH = 10; + public final int SERIAL_CODE_LENGTH = 26; @Override public void checkAlreadyScanned(String serialCode) { diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/inbound/validator/FranchiseInboundValidatorImpl.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/inbound/validator/FranchiseInboundValidatorImpl.java index 06bd751fe..86ec741f7 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/inbound/validator/FranchiseInboundValidatorImpl.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/inbound/validator/FranchiseInboundValidatorImpl.java @@ -22,7 +22,7 @@ public class FranchiseInboundValidatorImpl @Qualifier("franchise") private final InboundReader inboundReader; - public final int SERIAL_CODE_LENGTH = 10; + public final int SERIAL_CODE_LENGTH = 26; @Override public void checkAlreadyScanned(String serialCode) { diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/outbound/executor/OutboundExecutor.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/outbound/executor/OutboundExecutor.java index 4cc77c679..4b023a4a8 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/outbound/executor/OutboundExecutor.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/outbound/executor/OutboundExecutor.java @@ -7,7 +7,7 @@ public interface OutboundExecutor { void updateAll(List confirmedIds, LogType targetStatus); - void assignBoxCode(String boxCode, List confirmedIds); + void assignBoxCode(String boxCode, Long orderId, Long orderItemId, List confirmedIds); void cancelOutbound(List confirmedIds); } diff --git a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/outbound/executor/OutboundExecutorImpl.java b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/outbound/executor/OutboundExecutorImpl.java index ddd5e1e9e..9be6eee09 100644 --- a/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/outbound/executor/OutboundExecutorImpl.java +++ b/module-domain/domain-inventories/src/main/java/com/chaing/domain/inventories/usecase/outbound/executor/OutboundExecutorImpl.java @@ -19,8 +19,8 @@ public void updateAll(List confirmedIds, LogType targetStatus) { } @Override - public void assignBoxCode(String boxCode, List confirmedIds) { - repository.setBoxCode(boxCode, confirmedIds); + public void assignBoxCode(String boxCode, Long orderId, Long orderItemId, List confirmedIds) { + repository.setBoxCode(boxCode, orderId, orderItemId, confirmedIds); } @Override diff --git a/module-domain/domain-inventories/src/test/java/FactoryScanInboundServiceTests.java b/module-domain/domain-inventories/src/test/java/com/chaing/domain/inventories/service/FactoryScanInboundServiceTests.java similarity index 100% rename from module-domain/domain-inventories/src/test/java/FactoryScanInboundServiceTests.java rename to module-domain/domain-inventories/src/test/java/com/chaing/domain/inventories/service/FactoryScanInboundServiceTests.java diff --git a/module-domain/domain-inventories/src/test/java/FranchiseScanInboundServiceTests.java b/module-domain/domain-inventories/src/test/java/com/chaing/domain/inventories/service/FranchiseScanInboundServiceTests.java similarity index 100% rename from module-domain/domain-inventories/src/test/java/FranchiseScanInboundServiceTests.java rename to module-domain/domain-inventories/src/test/java/com/chaing/domain/inventories/service/FranchiseScanInboundServiceTests.java diff --git a/module-domain/domain-inventories/src/test/java/com/chaing/domain/inventories/service/InventoryServiceTest.java b/module-domain/domain-inventories/src/test/java/com/chaing/domain/inventories/service/InventoryServiceTest.java index 3e58950d8..3826b2302 100644 --- a/module-domain/domain-inventories/src/test/java/com/chaing/domain/inventories/service/InventoryServiceTest.java +++ b/module-domain/domain-inventories/src/test/java/com/chaing/domain/inventories/service/InventoryServiceTest.java @@ -1,12 +1,23 @@ package com.chaing.domain.inventories.service; +import com.chaing.core.dto.command.FranchiseOrderCodeAndQuantityCommand; +import com.chaing.core.dto.info.ProductInfo; +import com.chaing.core.enums.ReturnItemStatus; import com.chaing.core.enums.LogType; +import com.chaing.domain.inventories.dto.info.InboundProductIdInfo; +import com.chaing.domain.inventories.dto.info.StockInfoForLog; +import com.chaing.domain.inventories.dto.request.DisposalRequest; import com.chaing.domain.inventories.dto.request.FranchiseInventoryItemsRequest; import com.chaing.domain.inventories.dto.request.HQInventoryItemsRequest; import com.chaing.domain.inventories.dto.request.InventoryBatchRequest; +import com.chaing.domain.inventories.dto.request.InventoryRequest; import com.chaing.domain.inventories.dto.request.SafetyStockRequest; +import com.chaing.domain.inventories.entity.FactoryInventory; +import com.chaing.domain.inventories.entity.FranchiseInventory; +import com.chaing.domain.inventories.entity.HQInventory; import com.chaing.domain.inventories.entity.InventoryPolicy; import com.chaing.domain.inventories.enums.LocationType; +import com.chaing.domain.inventories.exception.InventoriesException; import com.chaing.domain.inventories.repository.FactoryInventoryRepository; import com.chaing.domain.inventories.repository.FranchiseInventoryRepository; import com.chaing.domain.inventories.repository.HQInventoryRepository; @@ -22,11 +33,13 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import java.time.LocalDate; import java.util.List; import java.util.Map; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.ArgumentMatchers.eq; @@ -167,7 +180,7 @@ void updateSafetyStock() { .safetyStock(5) .build(); - given(inventoryPolicyRepository.findByLocationTypeAndLocationIdAndProductId(type, locId, prodId)) + given(inventoryPolicyRepository.findPolicy(type, locId, prodId)) .willReturn(Optional.of(existingPolicy)); // when @@ -227,7 +240,7 @@ void getLowStockAlerts_HQ() { inventoryService.getLowStockAlerts("HQ", 1L); // then - verify(factoryInventoryRepository).getLowStockAlerts("HQ", null); + verify(factoryInventoryRepository).getLowStockAlerts("HQ", 1L); } @Test @@ -257,7 +270,7 @@ void updateShippingStatus() { List serialCodes = List.of("SERIAL1"); // when - inventoryService.updateShippingStatus(serialCodes); + inventoryService.updateShippingStatus(serialCodes, LogType.SHIPPING); // then verify(factoryInventoryRepository).updateStatus(eq(serialCodes), eq(LogType.SHIPPING)); @@ -354,4 +367,541 @@ void deleteHqInventory() { verify(hqInventoryRepository).deleteHQInventory(anyList()); } -} \ No newline at end of file + @Test + @DisplayName("기본 안전재고 초기화") + void resetSafetyStockToDefault() { + InventoryPolicy policy = InventoryPolicy.builder() + .locationType(LocationType.FRANCHISE) + .locationId(1L) + .productId(1L) + .defaultSafetyStock(10) + .safetyStock(30) + .build(); + + given(inventoryPolicyRepository.findPolicy(LocationType.FRANCHISE, 1L, 1L)) + .willReturn(Optional.of(policy)); + + inventoryService.resetSafetyStockToDefault("FRANCHISE", 1L, 1L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(InventoryPolicy.class); + verify(inventoryPolicyRepository).save(captor.capture()); + assertThat(captor.getValue().getSafetyStock()).isNull(); + } + + @Test + @DisplayName("기본 안전재고 초기화 실패") + void resetSafetyStockToDefault_Fail() { + given(inventoryPolicyRepository.findPolicy(LocationType.FRANCHISE, 1L, 1L)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> inventoryService.resetSafetyStockToDefault("FRANCHISE", 1L, 1L)) + .isInstanceOf(InventoriesException.class); + } + + @Test + @DisplayName("위치 타입 변환") + void convertLocationType() { + assertThat(inventoryService.convertLocationType("FRANCHISE")).isEqualTo(LocationType.FRANCHISE); + } + + @Test + @DisplayName("위치 타입 변환 실패") + void convertLocationType_Fail() { + assertThatThrownBy(() -> inventoryService.convertLocationType("NOPE")) + .isInstanceOf(InventoriesException.class); + } + + @Test + @DisplayName("유통기한 알림 가맹점") + void getExpirationAlerts_Franchise() { + inventoryService.getExpirationAlerts("FRANCHISE", 1L); + + verify(franchiseInventoryRepository).getExpirationAlerts("FRANCHISE", 1L); + } + + @Test + @DisplayName("만료 상태 변경") + void updateExpiredStatus() { + inventoryService.updateExpiredStatus(); + + verify(hqInventoryRepository).updateExpiredStatus(any(LocalDate.class)); + verify(factoryInventoryRepository).updateExpiredStatus(any(LocalDate.class)); + verify(franchiseInventoryRepository).updateExpiredStatus(any(LocalDate.class)); + } + + @Test + @DisplayName("공장 주문 재고 조회") + void getFactoryInventoriesByOrderId() { + List inventories = List.of(factoryInventory(1L, 10L, "F-1", "BOX-1", 100L)); + given(factoryInventoryRepository.findAllByOrderId(100L)).willReturn(inventories); + + assertThat(inventoryService.getFactoryInventoriesByOrderId(100L)).isEqualTo(inventories); + } + + @Test + @DisplayName("가맹점 주문 재고 조회") + void getFranchiseInventoriesByOrderId() { + List inventories = List.of(franchiseInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1", 1L)); + given(franchiseInventoryRepository.findAllByOrderId(100L)).willReturn(inventories); + + assertThat(inventoryService.getFranchiseInventoriesByOrderId(100L)).isEqualTo(inventories); + } + + @Test + @DisplayName("가맹점 박스 조회") + void getBoxCodeFromFranchise() { + given(franchiseInventoryRepository.findAllBySerialCodeInAndDeletedAtIsNull(List.of("S-1"))) + .willReturn(List.of(franchiseInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1", 1L))); + + assertThat(inventoryService.getBoxCodeFromFranchise(List.of("S-1"))) + .containsEntry("S-1", "BOX-1"); + } + + @Test + @DisplayName("가맹점 박스 조회 실패") + void getBoxCodeFromFranchise_Fail() { + given(franchiseInventoryRepository.findAllBySerialCodeInAndDeletedAtIsNull(List.of("S-1"))) + .willReturn(List.of()); + + assertThatThrownBy(() -> inventoryService.getBoxCodeFromFranchise(List.of("S-1"))) + .isInstanceOf(InventoriesException.class); + } + + @Test + @DisplayName("가맹점 상품 조회") + void getProductIdBySerialCodeFromFranchise() { + given(franchiseInventoryRepository.findAllBySerialCodeInAndDeletedAtIsNull(List.of("S-1"))) + .willReturn(List.of(franchiseInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1", 1L))); + + assertThat(inventoryService.getProductIdBySerialCodeFromFranchise(List.of("S-1"))) + .containsEntry("S-1", 10L); + } + + @Test + @DisplayName("가맹점 주문상품 조회") + void getSerialCodesByOrderItemIdsFromFranchise() { + given(franchiseInventoryRepository.findAllByOrderItemIdInAndDeletedAtIsNull(List.of(11L))) + .willReturn(List.of(franchiseInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1", 1L))); + + assertThat(inventoryService.getSerialCodesByOrderItemIdsFromFranchise(List.of(11L))) + .containsEntry("S-1", 11L); + } + + @Test + @DisplayName("박스 재고 조회") + void getInventoriesByBoxCode() { + given(franchiseInventoryRepository.findAllByBoxCodeIn(List.of("BOX-1"))) + .willReturn(List.of(franchiseInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1", 1L))); + + assertThat(inventoryService.getInventoriesByBoxCode(List.of("BOX-1"))) + .containsKey("BOX-1"); + } + + @Test + @DisplayName("주문 재고 조회") + void getInventoriesByOrderItemIds() { + given(franchiseInventoryRepository.findAllByOrderItemIdInAndDeletedAtIsNull(List.of(11L))) + .willReturn(List.of(franchiseInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1", 1L))); + + assertThat(inventoryService.getInventoriesByOrderItemIds(List.of(11L))) + .containsKey(11L); + } + + @Test + @DisplayName("폐기 본사") + void disposalInventory_HQ() { + inventoryService.disposalInventory(new DisposalRequest("HQ", null, List.of(1L, 2L))); + + verify(hqInventoryRepository).deleteByInventoryIdIn(List.of(1L, 2L)); + } + + @Test + @DisplayName("폐기 가맹점") + void disposalInventory_Franchise() { + inventoryService.disposalInventory(new DisposalRequest("FRANCHISE", 1L, List.of(1L, 2L))); + + verify(franchiseInventoryRepository).deleteByFranchiseIdAndInventoryIdIn(1L, List.of(1L, 2L)); + } + + @Test + @DisplayName("폐기 실패") + void disposalInventory_Fail() { + assertThatThrownBy(() -> inventoryService.disposalInventory(new DisposalRequest("NOPE", 1L, List.of(1L)))) + .isInstanceOf(InventoriesException.class); + } + + @Test + @DisplayName("수동 안전재고 타입 실패") + void setSafetyStock_TypeFail() { + assertThatThrownBy(() -> inventoryService.setSafetyStock(new SafetyStockRequest("BAD", 1L, 1L, 10))) + .isInstanceOf(InventoriesException.class); + } + + @Test + @DisplayName("수동 안전재고 상품 실패") + void setSafetyStock_ProductFail() { + assertThatThrownBy(() -> inventoryService.setSafetyStock(new SafetyStockRequest("HQ", null, null, 10))) + .isInstanceOf(InventoriesException.class); + } + + @Test + @DisplayName("누락 검증") + void verifyOmission() { + given(hqInventoryRepository.findAllByBoxCodeInAndDeletedAtIsNull(List.of("BOX-1"))) + .willReturn(List.of(hqInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1"))); + + inventoryService.verifyOmission(List.of("BOX-1")); + } + + @Test + @DisplayName("누락 검증 실패") + void verifyOmission_Fail() { + given(hqInventoryRepository.findAllByBoxCodeInAndDeletedAtIsNull(List.of("BOX-1"))) + .willReturn(List.of()); + + assertThatThrownBy(() -> inventoryService.verifyOmission(List.of("BOX-1"))) + .isInstanceOf(InventoriesException.class); + } + + @Test + @DisplayName("검수 결과 저장") + void saveInspectionResults() { + HQInventory inventory = hqInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1"); + given(hqInventoryRepository.findAllByBoxCodeInAndDeletedAtIsNull(List.of("BOX-1"))) + .willReturn(List.of(inventory)); + + inventoryService.saveInspectionResults( + List.of("BOX-1"), + Map.of("BOX-1", ReturnItemStatus.NORMAL), + Map.of("S-1", true) + ); + + verify(hqInventoryRepository).saveAll(List.of(inventory)); + assertThat(inventory.getReturnItemStatus()).isEqualTo(ReturnItemStatus.NORMAL); + } + + @Test + @DisplayName("검수 결과 저장 실패") + void saveInspectionResults_Fail() { + HQInventory inventory = hqInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1"); + given(hqInventoryRepository.findAllByBoxCodeInAndDeletedAtIsNull(List.of("BOX-1"))) + .willReturn(List.of(inventory)); + + assertThatThrownBy(() -> inventoryService.saveInspectionResults( + List.of("BOX-1"), + Map.of("BOX-1", ReturnItemStatus.NORMAL), + Map.of() + )).isInstanceOf(InventoriesException.class); + } + + @Test + @DisplayName("본사 재고 아이디 조회") + void getHqInventoriesByIds() { + List inventories = List.of(hqInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1")); + given(hqInventoryRepository.findByInventoryIdIn(List.of(1L))).willReturn(inventories); + + assertThat(inventoryService.getHqInventoriesByIds(List.of(1L))).isEqualTo(inventories); + } + + @Test + @DisplayName("가맹점 재고 아이디 조회") + void getFranchiseInventoriesByIds() { + List inventories = List.of(franchiseInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1", 1L)); + given(franchiseInventoryRepository.findByInventoryIdIn(List.of(1L))).willReturn(inventories); + + assertThat(inventoryService.getFranchiseInventoriesByIds(List.of(1L))).isEqualTo(inventories); + } + + @Test + @DisplayName("공장 재고 아이디 조회") + void getFactoryInventoriesByIds() { + List inventories = List.of(factoryInventory(1L, 10L, "F-1", "BOX-1", 100L)); + given(factoryInventoryRepository.findByInventoryIdIn(List.of(1L))).willReturn(inventories); + + assertThat(inventoryService.getFactoryInventoriesByIds(List.of(1L))).isEqualTo(inventories); + } + + @Test + @DisplayName("박스 확장 본사") + void expandInventoryIdsByBoxCode_HQ() { + given(hqInventoryRepository.findByInventoryIdIn(List.of(1L))) + .willReturn(List.of(hqInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1"))); + given(hqInventoryRepository.findByBoxCodeIn(List.of("BOX-1"))) + .willReturn(List.of( + hqInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1"), + hqInventory(2L, 100L, 12L, "S-2", 10L, "BOX-1") + )); + + assertThat(inventoryService.expandInventoryIdsByBoxCode("HQ", List.of(1L), null, null)) + .containsExactlyInAnyOrder(1L, 2L); + } + + @Test + @DisplayName("박스 확장 공장") + void expandInventoryIdsByBoxCode_Factory() { + given(factoryInventoryRepository.findByInventoryIdIn(List.of(1L))) + .willReturn(List.of(factoryInventory(1L, 10L, "F-1", "BOX-1", 100L))); + given(factoryInventoryRepository.findByBoxCodeIn(List.of("BOX-1"))) + .willReturn(List.of( + factoryInventory(1L, 10L, "F-1", "BOX-1", 100L), + factoryInventory(2L, 10L, "F-2", "BOX-1", 100L) + )); + + assertThat(inventoryService.expandInventoryIdsByBoxCode("FACTORY", List.of(1L), null, null)) + .containsExactlyInAnyOrder(1L, 2L); + } + + @Test + @DisplayName("박스 확장 가맹점") + void expandInventoryIdsByBoxCode_Franchise() { + given(franchiseInventoryRepository.findByInventoryIdIn(List.of(1L))) + .willReturn(List.of(franchiseInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1", 1L))); + given(franchiseInventoryRepository.findByBoxCodeInAndFranchiseId(List.of("BOX-1"), 1L)) + .willReturn(List.of( + franchiseInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1", 1L), + franchiseInventory(2L, 100L, 12L, "S-2", 10L, "BOX-1", 1L) + )); + + assertThat(inventoryService.expandInventoryIdsByBoxCode("FRANCHISE", List.of(1L), null, 1L)) + .containsExactlyInAnyOrder(1L, 2L); + } + + @Test + @DisplayName("폐기 아이디 본사") + void disposalInventoryByIds_HQ() { + inventoryService.disposalInventoryByIds("HQ", List.of(1L, 2L), null, null); + + verify(hqInventoryRepository).deleteByInventoryIdIn(List.of(1L, 2L)); + } + + @Test + @DisplayName("폐기 아이디 가맹점") + void disposalInventoryByIds_Franchise() { + given(franchiseInventoryRepository.findByInventoryIdInAndFranchiseId(List.of(1L, 2L), 1L)) + .willReturn(List.of(franchiseInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1", 1L))); + + inventoryService.disposalInventoryByIds("FRANCHISE", List.of(1L, 2L), null, 1L); + + verify(franchiseInventoryRepository).deleteByFranchiseIdAndInventoryIdIn(1L, List.of(1L)); + } + + @Test + @DisplayName("재고 확인") + void checkStock() { + List items = List.of( + FranchiseOrderCodeAndQuantityCommand.builder().productCode("P-1").quantity(2).build() + ); + Map productInfoByProductCode = Map.of( + "P-1", ProductInfo.builder().productId(10L).build() + ); + given(factoryInventoryRepository.findAllByProductIdInAndStatusAndDeletedAtIsNull(eq(java.util.Set.of(10L)), eq(LogType.AVAILABLE))) + .willReturn(List.of( + factoryInventory(1L, 10L, "F-1", "BOX-1", 100L), + factoryInventory(2L, 10L, "F-2", "BOX-1", 100L) + )); + + inventoryService.checkStock(items, productInfoByProductCode); + } + + @Test + @DisplayName("재고 확인 실패") + void checkStock_Fail() { + List items = List.of( + FranchiseOrderCodeAndQuantityCommand.builder().productCode("P-1").quantity(1).build() + ); + + assertThatThrownBy(() -> inventoryService.checkStock(items, Map.of())) + .isInstanceOf(InventoriesException.class); + } + + @Test + @DisplayName("시리얼 조회") + void getItemBySerialCode() { + FranchiseInventory inventory1 = franchiseInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1", 1L); + FranchiseInventory inventory2 = franchiseInventory(2L, 100L, 12L, "S-2", 10L, "BOX-1", 1L); + given(franchiseInventoryRepository.getAllByStatusAndSerialCodeAndFranchiseId(List.of("S-1", "S-2"), 1L, LogType.AVAILABLE)) + .willReturn(List.of(inventory1, inventory2)); + + List result = inventoryService.getItemBySerialCode(List.of("S-1", "S-2"), 1L); + + assertThat(result).hasSize(2); + assertThat(result.get(0).serialCode()).isEqualTo("S-1"); + } + + @Test + @DisplayName("시리얼 조회 실패") + void getItemBySerialCode_Fail() { + given(franchiseInventoryRepository.getAllByStatusAndSerialCodeAndFranchiseId(List.of("S-1"), 1L, LogType.AVAILABLE)) + .willReturn(List.of()); + + assertThatThrownBy(() -> inventoryService.getItemBySerialCode(List.of("S-1"), 1L)) + .isInstanceOf(InventoriesException.class); + } + + @Test + @DisplayName("주문 정보 조회") + void getOrderInfo() { + given(factoryInventoryRepository.getOrderIdBySerialCodeIn(List.of("S-1"))) + .willReturn(List.of(100L)); + + assertThat(inventoryService.getOrderInfo(List.of("S-1"))).containsExactly(100L); + } + + @Test + @DisplayName("주문 정보 조회 실패") + void getOrderInfo_Fail() { + given(factoryInventoryRepository.getOrderIdBySerialCodeIn(List.of("S-1"))) + .willReturn(List.of()); + + assertThatThrownBy(() -> inventoryService.getOrderInfo(List.of("S-1"))) + .isInstanceOf(InventoriesException.class); + } + + @Test + @DisplayName("본사 주문상품 조회") + void getSerialCodesByOrderItemIdsFromHQ() { + given(hqInventoryRepository.findAllByOrderItemIdInAndDeletedAtIsNull(List.of(11L))) + .willReturn(List.of(hqInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1"))); + + assertThat(inventoryService.getSerialCodesByOrderItemIdsFromHQ(List.of(11L))) + .containsEntry("S-1", 11L); + } + + @Test + @DisplayName("본사 박스 조회") + void getBoxCodeFromHQ() { + given(hqInventoryRepository.findAllBySerialCodeInAndDeletedAtIsNull(List.of("S-1"))) + .willReturn(List.of(hqInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1"))); + + assertThat(inventoryService.getBoxCodeFromHQ(List.of("S-1"))) + .containsEntry("S-1", "BOX-1"); + } + + @Test + @DisplayName("본사 상품 조회") + void getProductIdBySerialCodeFromHQ() { + given(hqInventoryRepository.findAllBySerialCodeInAndDeletedAtIsNull(List.of("S-1"))) + .willReturn(List.of(hqInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1"))); + + assertThat(inventoryService.getProductIdBySerialCodeFromHQ(List.of("S-1"))) + .containsEntry("S-1", 10L); + } + + @Test + @DisplayName("반품 검수 조회") + void getReturnItemInspection() { + HQInventory inventory = hqInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1"); + inventory.updateInspection(true, ReturnItemStatus.NORMAL); + given(hqInventoryRepository.findAllByOrderItemIdInAndDeletedAtIsNull(List.of(11L))) + .willReturn(List.of(inventory)); + + assertThat(inventoryService.getReturnItemInspection(Map.of(1L, 11L))) + .containsKey("BOX-1"); + } + + @Test + @DisplayName("가맹점 반품 검수 조회") + void getReturnItemInspectionFromFranchise() { + given(franchiseInventoryRepository.findAllByOrderItemIdInAndDeletedAtIsNull(List.of(11L))) + .willReturn(List.of(franchiseInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1", 1L))); + + assertThat(inventoryService.getReturnItemInspectionFromFranchise(List.of(11L))) + .containsKey("BOX-1"); + } + + @Test + @DisplayName("로그 재고 조회 가맹점") + void getStockBySerialCodeFromFranchise() { + given(franchiseInventoryRepository.getAllByFranchiseIdAndSerialCodeIn(1L, List.of("S-1"))) + .willReturn(List.of(franchiseInventory(1L, 100L, 11L, "S-1", 10L, "BOX-1", 1L))); + + List result = inventoryService.getStockBySerialCodeFromFranchise(List.of("S-1"), 1L); + + assertThat(result).hasSize(1); + assertThat(result.get(0).boxCode()).isEqualTo("BOX-1"); + } + + @Test + @DisplayName("로그 재고 조회 공장") + void getStockBySerialCodeFromFactory() { + given(factoryInventoryRepository.findAllBySerialCodeIn(List.of("F-1"))) + .willReturn(List.of(factoryInventory(1L, 10L, "F-1", "BOX-1", 100L))); + + List result = inventoryService.getStockBySerialCodeFromFactory(List.of("F-1")); + + assertThat(result).hasSize(1); + assertThat(result.get(0).productId()).isEqualTo(10L); + } + + @Test + @DisplayName("수량 조회 공장") + void getFactoryQuantityByBoxCodes() { + given(factoryInventoryRepository.countByBoxCodes(List.of("BOX-1"))) + .willReturn(Map.of("BOX-1", 2L)); + + assertThat(inventoryService.getFactoryQuantityByBoxCodes(List.of("BOX-1"))) + .containsEntry("BOX-1", 2L); + } + + @Test + @DisplayName("수량 조회 가맹점") + void getFranchiseQuantityByBoxCodes() { + given(franchiseInventoryRepository.countByBoxCodes(1L, List.of("BOX-1"))) + .willReturn(Map.of("BOX-1", 2L)); + + assertThat(inventoryService.getFranchiseQuantityByBoxCodes(1L, List.of("BOX-1"))) + .containsEntry("BOX-1", 2L); + } + + @Test + @DisplayName("입고 상품 조회") + void getProductIdFromFactory() { + List infos = List.of(new InboundProductIdInfo(10L, 2L)); + given(factoryInventoryRepository.getInboundProductIdInfosBySerialCodes(List.of("F-1"))) + .willReturn(infos); + + assertThat(inventoryService.getProductIdFromFactory(List.of("F-1"))).isEqualTo(infos); + } + + private FranchiseInventory franchiseInventory(Long inventoryId, Long orderId, Long orderItemId, String serialCode, + Long productId, String boxCode, Long franchiseId) { + return FranchiseInventory.builder() + .inventoryId(inventoryId) + .orderId(orderId) + .orderItemId(orderItemId) + .serialCode(serialCode) + .productId(productId) + .manufactureDate(LocalDate.of(2024, 1, 1)) + .franchiseId(franchiseId) + .status(LogType.AVAILABLE) + .boxCode(boxCode) + .build(); + } + + private FactoryInventory factoryInventory(Long inventoryId, Long productId, String serialCode, String boxCode, Long orderId) { + return FactoryInventory.builder() + .inventoryId(inventoryId) + .orderId(orderId) + .serialCode(serialCode) + .productId(productId) + .manufactureDate(LocalDate.of(2024, 1, 1)) + .status(LogType.AVAILABLE) + .boxCode(boxCode) + .build(); + } + + private HQInventory hqInventory(Long inventoryId, Long orderId, Long orderItemId, String serialCode, + Long productId, String boxCode) { + return HQInventory.builder() + .inventoryId(inventoryId) + .orderId(orderId) + .orderItemId(orderItemId) + .serialCode(serialCode) + .productId(productId) + .manufactureDate(LocalDate.of(2024, 1, 1)) + .status(LogType.AVAILABLE) + .boxCode(boxCode) + .build(); + } + +} diff --git a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/dto/request/InventoryLogCreateRequest.java b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/dto/request/InventoryLogCreateRequest.java index 45918e4c7..8d9c9b16d 100644 --- a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/dto/request/InventoryLogCreateRequest.java +++ b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/dto/request/InventoryLogCreateRequest.java @@ -4,8 +4,6 @@ import com.chaing.domain.inventorylogs.enums.ActorType; import com.chaing.domain.inventorylogs.enums.LocationType; -import java.math.BigDecimal; - public record InventoryLogCreateRequest( Long productId, String productName, diff --git a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/entity/InventoryLog.java b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/entity/InventoryLog.java index 2780e473f..d290e220a 100644 --- a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/entity/InventoryLog.java +++ b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/entity/InventoryLog.java @@ -42,8 +42,6 @@ public class InventoryLog extends BaseEntity { private String productName; // 박스 코드 - @NotNull - @Column(nullable = false) private String boxCode; // 코드 -> 발주 코드, 판매코드, 반품 코드,, diff --git a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/exception/InventoryLogtErrorCode.java b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/exception/InventoryLogtErrorCode.java index 4e94d9157..3317a4b58 100644 --- a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/exception/InventoryLogtErrorCode.java +++ b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/exception/InventoryLogtErrorCode.java @@ -12,7 +12,8 @@ public enum InventoryLogtErrorCode implements ErrorCode { INVALID_ACTOR_TYPE(400, "IL001", "잘못된 행위자 타입 값입니다."), INVALID_LOG_TYPE(400, "IL002", "잘못된 로그 타입 값입니다."), INVALID_INPUT(400, "IL003", "잘못된 입력 값입니다."), - PAGE_WINDOW_TOO_LARGE(400, "IL004", "조회 범위가 너무 큽니다. 조회 기간을 좁혀주세요."); + PAGE_WINDOW_TOO_LARGE(400, "IL004", "조회 범위가 너무 큽니다. 조회 기간을 좁혀주세요."), + ORDER_NOT_FOUND(400, "IL005", "잘못된 주문 정보입니다."); private final Integer status; private final String code; diff --git a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/impl/InventoryLogArchiveRepositoryImpl.java b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/impl/InventoryLogArchiveRepositoryImpl.java index e5604a102..e2d570dcd 100644 --- a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/impl/InventoryLogArchiveRepositoryImpl.java +++ b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/impl/InventoryLogArchiveRepositoryImpl.java @@ -245,20 +245,26 @@ public FranchiseInventoryLogListResponse findFranchiseInboundOutboundLogs(Long f } @Override - public List findBoxCodesByTransactionCode(String transactionCode) { + public List findBoxCodesByTransactionCode(String transactionCode, String productName, + LogType logType) { return queryFactory .select(Projections.constructor( BoxCodeResponse.class, log.boxCode)) .from(log) .where(log.transactionCode.eq(transactionCode) - .or(log.boxCode.eq(transactionCode))) + .or(log.boxCode.eq(transactionCode)), + containsExactProductName(productName), + equalsLogType(logType), + log.boxCode.isNotNull(), + log.boxCode.ne("")) .distinct() .fetch(); } @Override - public List findBoxCodesByTransactionCodeAndDate(String transactionCode, LocalDate date) { + public List findBoxCodesByTransactionCodeAndDate(String transactionCode, LocalDate date, + String productName, LogType logType) { return queryFactory .select(Projections.constructor( BoxCodeResponse.class, @@ -267,7 +273,11 @@ public List findBoxCodesByTransactionCodeAndDate(String transac .where( log.transactionCode.eq(transactionCode) .or(log.boxCode.eq(transactionCode)), - betweenDate(date, date)) + betweenDate(date, date), + containsExactProductName(productName), + equalsLogType(logType), + log.boxCode.isNotNull(), + log.boxCode.ne("")) .distinct() .fetch(); } @@ -409,8 +419,12 @@ private BooleanExpression betweenDate(LocalDate startDate, LocalDate endDate) { } // 제품식별코드 조건문 - private BooleanExpression containsTransactionCode(String serialCode) { - return serialCode != null ? QInventoryLogArchive.inventoryLogArchive.transactionCode.contains(serialCode) : null; + private BooleanExpression containsTransactionCode(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return QInventoryLogArchive.inventoryLogArchive.transactionCode.contains(keyword) + .or(QInventoryLogArchive.inventoryLogArchive.boxCode.contains(keyword)); } // 상품 이름 조건문 @@ -418,6 +432,14 @@ private BooleanExpression containsProductName(String productName) { return productName != null ? QInventoryLogArchive.inventoryLogArchive.productName.contains(productName) : null; } + // 상품 이름 정확 매칭 조건문 + private BooleanExpression containsExactProductName(String productName) { + if (productName == null || productName.isBlank()) { + return null; + } + return QInventoryLogArchive.inventoryLogArchive.productName.eq(productName); + } + // 해당 위치 로그 조회 조건문 private BooleanExpression locationContains(String locationTypeStr, Long locationId) { com.chaing.domain.inventorylogs.enums.LocationType type; @@ -459,7 +481,8 @@ private ActorType parseActorType(String actorType) { // 해당 로그만 조회 private BooleanExpression containsLogType(String logType) { if (logType == null || logType.isBlank()) { - return null; + // 공장 "입출고 로그" 기본 조회에서는 폐기 로그를 제외한다. + return log.logType.ne(LogType.DISPOSAL); } try { LogType type = LogType.valueOf(logType.trim().toUpperCase()); @@ -469,6 +492,10 @@ private BooleanExpression containsLogType(String logType) { } } + private BooleanExpression equalsLogType(LogType logType) { + return logType == null ? null : QInventoryLogArchive.inventoryLogArchive.logType.eq(logType); + } + private long countDistinctGroups(Expression groupKey1, Expression groupKey2, Expression groupKey3, BooleanExpression... conditions) { Long count = queryFactory diff --git a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/impl/InventoryLogRepositoryImpl.java b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/impl/InventoryLogRepositoryImpl.java index c52272363..be2acce4b 100644 --- a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/impl/InventoryLogRepositoryImpl.java +++ b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/impl/InventoryLogRepositoryImpl.java @@ -249,20 +249,26 @@ public FranchiseInventoryLogListResponse findFranchiseInboundOutboundLogs(Long f } @Override - public List findBoxCodesByTransactionCode(String transactionCode) { + public List findBoxCodesByTransactionCode(String transactionCode, String productName, + LogType logType) { return queryFactory .select(Projections.constructor( BoxCodeResponse.class, log.boxCode)) .from(log) .where(log.transactionCode.eq(transactionCode) - .or(log.boxCode.eq(transactionCode))) + .or(log.boxCode.eq(transactionCode)), + containsExactProductName(productName), + equalsLogType(logType), + log.boxCode.isNotNull(), + log.boxCode.ne("")) .distinct() .fetch(); } @Override - public List findBoxCodesByTransactionCodeAndDate(String transactionCode, LocalDate date) { + public List findBoxCodesByTransactionCodeAndDate(String transactionCode, LocalDate date, + String productName, LogType logType) { return queryFactory .select(Projections.constructor( BoxCodeResponse.class, @@ -271,7 +277,11 @@ public List findBoxCodesByTransactionCodeAndDate(String transac .where( log.transactionCode.eq(transactionCode) .or(log.boxCode.eq(transactionCode)), - betweenDate(date, date)) + betweenDate(date, date), + containsExactProductName(productName), + equalsLogType(logType), + log.boxCode.isNotNull(), + log.boxCode.ne("")) .distinct() .fetch(); } @@ -413,8 +423,12 @@ private BooleanExpression betweenDate(LocalDate startDate, LocalDate endDate) { } // 제품식별코드 조건문 - private BooleanExpression containsTransactionCode(String serialCode) { - return serialCode != null ? QInventoryLog.inventoryLog.transactionCode.contains(serialCode) : null; + private BooleanExpression containsTransactionCode(String keyword) { + if (keyword == null || keyword.isBlank()) { + return null; + } + return QInventoryLog.inventoryLog.transactionCode.contains(keyword) + .or(QInventoryLog.inventoryLog.boxCode.contains(keyword)); } // 상품 이름 조건문 @@ -422,6 +436,14 @@ private BooleanExpression containsProductName(String productName) { return productName != null ? QInventoryLog.inventoryLog.productName.contains(productName) : null; } + // 상품 이름 정확 매칭 조건문 + private BooleanExpression containsExactProductName(String productName) { + if (productName == null || productName.isBlank()) { + return null; + } + return QInventoryLog.inventoryLog.productName.eq(productName); + } + // 해당 위치 로그 조회 조건문 private BooleanExpression locationContains(String locationTypeStr, Long locationId) { com.chaing.domain.inventorylogs.enums.LocationType type; @@ -463,7 +485,8 @@ private ActorType parseActorType(String actorType) { // 해당 로그만 조회 private BooleanExpression containsLogType(String logType) { if (logType == null || logType.isBlank()) { - return null; + // 공장 "입출고 로그" 기본 조회에서는 폐기 로그를 제외한다. + return log.logType.ne(LogType.DISPOSAL); } try { LogType type = LogType.valueOf(logType.trim().toUpperCase()); @@ -473,6 +496,10 @@ private BooleanExpression containsLogType(String logType) { } } + private BooleanExpression equalsLogType(LogType logType) { + return logType == null ? null : QInventoryLog.inventoryLog.logType.eq(logType); + } + private long countDistinctGroups(Expression groupKey1, Expression groupKey2, Expression groupKey3, BooleanExpression... conditions) { Long count = queryFactory diff --git a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/interfaces/InventoryLogArchiveRepositoryCustom.java b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/interfaces/InventoryLogArchiveRepositoryCustom.java index f98cda869..3f8b92a32 100644 --- a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/interfaces/InventoryLogArchiveRepositoryCustom.java +++ b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/interfaces/InventoryLogArchiveRepositoryCustom.java @@ -1,5 +1,6 @@ package com.chaing.domain.inventorylogs.repository.interfaces; +import com.chaing.core.enums.LogType; import com.chaing.domain.inventorylogs.dto.request.FactoryLogRequest; import com.chaing.domain.inventorylogs.dto.request.FranchiseLogRequest; import com.chaing.domain.inventorylogs.dto.request.LogRequest; @@ -28,6 +29,7 @@ FranchiseInventoryLogListResponse findFranchiseSalesRefundLogs(Long franchiseId, FactoryInventoryLogListResponse findFactoryInventoryLogs(Long factoryId, FactoryLogRequest request, Pageable pageable); - List findBoxCodesByTransactionCode(String transactionCode); - List findBoxCodesByTransactionCodeAndDate(String transactionCode, LocalDate date); + List findBoxCodesByTransactionCode(String transactionCode, String productName, LogType logType); + List findBoxCodesByTransactionCodeAndDate(String transactionCode, LocalDate date, String productName, + LogType logType); } diff --git a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/interfaces/InventoryLogRepositoryCustom.java b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/interfaces/InventoryLogRepositoryCustom.java index eb367f6ef..af47ee991 100644 --- a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/interfaces/InventoryLogRepositoryCustom.java +++ b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/repository/interfaces/InventoryLogRepositoryCustom.java @@ -37,8 +37,9 @@ FactoryInventoryLogListResponse findFactoryInventoryLogs(Long factoryId, Factory List getProductSales(List actorId, List productIds, ActorType actorType, LogType logType); - List findBoxCodesByTransactionCode(String transactionCode); - List findBoxCodesByTransactionCodeAndDate(String transactionCode, LocalDate date); + List findBoxCodesByTransactionCode(String transactionCode, String productName, LogType logType); + List findBoxCodesByTransactionCodeAndDate(String transactionCode, LocalDate date, String productName, + LogType logType); List findArchivableLogs(LocalDateTime cutoff, int limit); } diff --git a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/service/InventoryLogService.java b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/service/InventoryLogService.java index 780c0487e..8beba2c0d 100644 --- a/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/service/InventoryLogService.java +++ b/module-domain/domain-inventorylogs/src/main/java/com/chaing/domain/inventorylogs/service/InventoryLogService.java @@ -193,13 +193,21 @@ public List getProductSales(List actorIds, List public void recordInventoryLog(List logs) { List entities = logs.stream() - .filter(request -> !inventoryLogRepository - .existsByTransactionCodeAndBoxCodeAndLogTypeAndActorTypeAndActorIdAndDeletedAtIsNull( - request.transactionCode(), - request.boxCode(), - request.logType(), - request.actorType(), - request.actorId())) + .filter(request -> { + // 폐기 로그는 transactionCode가 null이어서 기존 중복 검증이 + // 과도하게 동작할 수 있으므로 항상 기록한다. + if (request.logType() == LogType.DISPOSAL) { + return true; + } + + return !inventoryLogRepository + .existsByTransactionCodeAndBoxCodeAndLogTypeAndActorTypeAndActorIdAndDeletedAtIsNull( + request.transactionCode(), + request.boxCode(), + request.logType(), + request.actorType(), + request.actorId()); + }) .map(this::toEntity) .toList(); @@ -226,16 +234,25 @@ private InventoryLog toEntity(InventoryLogCreateRequest request) { } public List findBoxCodesByTransactionCode(String transactionCode) { - return findBoxCodesByTransactionCode(transactionCode, null); + return findBoxCodesByTransactionCode(transactionCode, null, null, null); } - public List findBoxCodesByTransactionCode(String transactionCode, LocalDate date) { - List active = date == null - ? inventoryLogRepository.findBoxCodesByTransactionCode(transactionCode) - : inventoryLogRepository.findBoxCodesByTransactionCodeAndDate(transactionCode, date); - List archive = date == null - ? inventoryLogArchiveRepository.findBoxCodesByTransactionCode(transactionCode) - : inventoryLogArchiveRepository.findBoxCodesByTransactionCodeAndDate(transactionCode, date); + public List findBoxCodesByTransactionCode(String transactionCode, LocalDate date, + String productName, LogType logType) { + // product/logType으로 충분히 범위를 좁힌 경우에는 날짜 필터를 강제하지 않는다. + // (동일 transactionCode 그룹이 여러 날짜에 걸치는 집계 행에서 박스코드 누락 방지) + boolean useDateFilter = date != null + && (productName == null || productName.isBlank()) + && logType == null; + + List active = useDateFilter + ? inventoryLogRepository.findBoxCodesByTransactionCodeAndDate(transactionCode, date, productName, + logType) + : inventoryLogRepository.findBoxCodesByTransactionCode(transactionCode, productName, logType); + List archive = useDateFilter + ? inventoryLogArchiveRepository.findBoxCodesByTransactionCodeAndDate(transactionCode, date, + productName, logType) + : inventoryLogArchiveRepository.findBoxCodesByTransactionCode(transactionCode, productName, logType); return Stream.concat(active.stream(), archive.stream()) .distinct() diff --git a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/info/OrderInfoForLog.java b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/info/OrderInfoForLog.java new file mode 100644 index 000000000..e7c64d5f0 --- /dev/null +++ b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/info/OrderInfoForLog.java @@ -0,0 +1,10 @@ +package com.chaing.domain.orders.dto.info; + +public record OrderInfoForLog( + String orderCode, + Long toId +) { + public static OrderInfoForLog create(String orderCode, Long toId) { + return new OrderInfoForLog(orderCode, toId); + } +} diff --git a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/FranchiseOrderCreateInfoResponse.java b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/FranchiseOrderCreateInfoResponse.java new file mode 100644 index 000000000..398893d92 --- /dev/null +++ b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/FranchiseOrderCreateInfoResponse.java @@ -0,0 +1,13 @@ +package com.chaing.domain.orders.dto.response; + +import lombok.Builder; + +@Builder +public record FranchiseOrderCreateInfoResponse( + String username, + + String phoneNumber, + + String address +) { +} diff --git a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/HQOrderItemProjection.java b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/HQOrderItemProjection.java index 7802888b0..5e9c424f5 100644 --- a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/HQOrderItemProjection.java +++ b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/HQOrderItemProjection.java @@ -2,16 +2,15 @@ import com.chaing.domain.orders.enums.HQOrderStatus; +import java.math.BigDecimal; import java.time.LocalDateTime; public record HQOrderItemProjection( String orderCode, HQOrderStatus status, Long userId, - Long productId, - Integer quantity, - LocalDateTime requestedDate, - LocalDateTime manufacturedDate, - String storedDate + Integer totalQuantity, + BigDecimal totalAmount, + LocalDateTime requestedDate ) { } diff --git a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/HQOrderPossibleResponse.java b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/HQOrderPossibleResponse.java new file mode 100644 index 000000000..c37653fde --- /dev/null +++ b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/HQOrderPossibleResponse.java @@ -0,0 +1,13 @@ +package com.chaing.domain.orders.dto.response; + +import lombok.Builder; + +import java.util.Map; + +@Builder +public record HQOrderPossibleResponse( + String orderCode, + + Boolean isPossible +) { +} diff --git a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/HQOrderResponse.java b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/HQOrderResponse.java index 44e5b9bf4..472486c85 100644 --- a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/HQOrderResponse.java +++ b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/dto/response/HQOrderResponse.java @@ -3,6 +3,7 @@ import com.chaing.domain.orders.enums.HQOrderStatus; import lombok.Builder; +import java.math.BigDecimal; import java.time.LocalDateTime; @Builder @@ -19,11 +20,7 @@ public record HQOrderResponse( LocalDateTime requestedDate, - LocalDateTime manufacturedDate, - - String storedDate, - - String productCode + BigDecimal totalPrice ) { -} +} \ No newline at end of file diff --git a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/entity/FranchiseOrder.java b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/entity/FranchiseOrder.java index 884268805..9f7cac429 100644 --- a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/entity/FranchiseOrder.java +++ b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/entity/FranchiseOrder.java @@ -81,7 +81,8 @@ public class FranchiseOrder extends BaseEntity { private LocalDateTime deliveryDate; @Column(nullable = false) - private String deliveryTime; + @Builder.Default + private String deliveryTime = "09:00"; @Column private String cancelledReason; @@ -182,4 +183,12 @@ public void cancelOrderByHQ(String cancelledReason) { public void updateStatus(FranchiseOrderStatus orderStatus) { this.orderStatus = orderStatus; } + + public void updateQuantityAndPrice(List targetItems) { + this.totalQuantity = targetItems.stream() + .mapToInt(FranchiseOrderItem::getQuantity).sum(); + this.totalAmount = targetItems.stream() + .map(FranchiseOrderItem::getTotalPrice) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } } diff --git a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/repository/FranchiseOrderItemRepository.java b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/repository/FranchiseOrderItemRepository.java index 792c2cadf..e3946fecc 100644 --- a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/repository/FranchiseOrderItemRepository.java +++ b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/repository/FranchiseOrderItemRepository.java @@ -3,6 +3,8 @@ import com.chaing.domain.orders.entity.FranchiseOrderItem; import com.chaing.domain.orders.enums.FranchiseOrderStatus; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -21,4 +23,7 @@ public interface FranchiseOrderItemRepository extends JpaRepository findAllByFranchiseOrder_FranchiseOrderIdAndDeletedAtIsNull(Long orderId); List findAllByFranchiseOrder_OrderCodeInAndDeletedAtIsNull(List orderCodes); + + @Query("select fo.franchiseOrder.franchiseOrderId from FranchiseOrderItem fo where fo.franchiseOrderItemId = :orderItemId") + Long getOrderIdByOrderItemId(@Param("orderItemId") Long orderItemId); } diff --git a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/repository/FranchiseOrderRepository.java b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/repository/FranchiseOrderRepository.java index 173858dfc..0b32b4067 100644 --- a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/repository/FranchiseOrderRepository.java +++ b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/repository/FranchiseOrderRepository.java @@ -29,6 +29,13 @@ public interface FranchiseOrderRepository extends JpaRepository findAllByOrderCodeInAndDeletedAtIsNull(List orderCodes); + List findAllByFranchiseId(Long franchiseId); + + // 특정 가맹점 날짜 범위 발주 (상태 목록 필터링) + List findAllByFranchiseIdAndOrderStatusInAndCreatedAtBetween( + Long franchiseId, List statuses, + LocalDateTime start, LocalDateTime end); + // 특정 가맹점 날짜 범위 발주 (ACCEPTED 일 때) List findAllByFranchiseIdAndOrderStatusAndCreatedAtBetween( Long franchiseId, FranchiseOrderStatus orderStatus, @@ -53,4 +60,7 @@ List findAllByFranchiseIdAndOrderStatusAndCreatedAtBetween( List getFranchiseOrderByFranchiseOrderStatus(@Param("statuses") List statuses); Optional findByOrderCodeAndDeletedAtIsNull(String orderCode); + + @Query("select f from FranchiseOrder f where f.franchiseOrderId in :orderIds and f.deletedAt is null") + List findByFranchiseOrderIdIn(@Param("orderIds") List orderIds); } diff --git a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/repository/impl/HeadOfficeOrderRepositoryImpl.java b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/repository/impl/HeadOfficeOrderRepositoryImpl.java index 00d681b07..c6969a0af 100644 --- a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/repository/impl/HeadOfficeOrderRepositoryImpl.java +++ b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/repository/impl/HeadOfficeOrderRepositoryImpl.java @@ -26,30 +26,25 @@ public class HeadOfficeOrderRepositoryImpl implements HeadOfficeOrderRepositoryC @Override public Page findOrderItemPage(Pageable pageable) { QHeadOfficeOrder order = QHeadOfficeOrder.headOfficeOrder; - QHeadOfficeOrderItem item = QHeadOfficeOrderItem.headOfficeOrderItem; List content = queryFactory .select(Projections.constructor(HQOrderItemProjection.class, order.orderCode, order.orderStatus, order.userId, - item.productId, - item.quantity, - order.createdAt, - order.manufactureDate, - order.storedDate)) - .from(item) - .join(item.headOfficeOrder, order) + order.totalQuantity, + order.totalAmount, + order.createdAt)) + .from(order) .where(order.deletedAt.isNull()) - .orderBy(order.createdAt.desc(), item.headOfficeOrderItemId.asc()) + .orderBy(order.createdAt.desc()) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); Long total = queryFactory - .select(item.count()) - .from(item) - .join(item.headOfficeOrder, order) + .select(order.count()) + .from(order) .where(order.deletedAt.isNull()) .fetchOne(); diff --git a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/service/FranchiseOrderService.java b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/service/FranchiseOrderService.java index 013daa560..7db722191 100644 --- a/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/service/FranchiseOrderService.java +++ b/module-domain/domain-orders/src/main/java/com/chaing/domain/orders/service/FranchiseOrderService.java @@ -6,6 +6,7 @@ import com.chaing.domain.orders.dto.command.FranchiseOrderCommand; import com.chaing.domain.orders.dto.command.FranchiseOrderDetailCommand; import com.chaing.domain.orders.dto.command.FranchiseOrderItemCommand; +import com.chaing.domain.orders.dto.info.OrderInfoForLog; import com.chaing.domain.orders.dto.request.FranchiseOrderCreateRequest; import com.chaing.core.dto.request.FranchiseOrderCreateRequestItem; import com.chaing.core.dto.request.FranchiseOrderUpdateRequest; @@ -31,6 +32,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.util.Collections; @@ -123,12 +125,34 @@ public List updateOrder(Long orderId, Map> + Map> itemsByReturnId = upsertItems.stream() + .collect(Collectors.groupingBy( + FranchiseOrderItem::getFranchiseOrder + )); + List updatedOrders = itemsByReturnId.entrySet().stream() + .map(entry -> { + FranchiseOrder targetOrder = entry.getKey(); + List targetItems = entry.getValue(); + + if (targetOrder == null || targetItems == null || targetItems.isEmpty()) { + throw new OrderException(OrderErrorCode.ORDER_ITEM_NOT_FOUND); + } + + targetOrder.updateQuantityAndPrice(targetItems); + + return targetOrder; + }) + .toList(); + // 변경사항 저장 franchiseOrderItemRepository.deleteAll(deletedItems); franchiseOrderItemRepository.saveAll(upsertItems); + franchiseOrderRepository.saveAll(updatedOrders); // 반환 - List responses = upsertItems.stream() + return upsertItems.stream() .map(item -> { ProductInfo productInfo = productInfoByProductId.get(item.getProductId()); String productCode = productInfo.productCode(); @@ -142,8 +166,6 @@ public List updateOrder(Long orderId, Map getOrdersByOrderCode(List )); } + @Transactional public void updateDeliveryStatus(List orderCodes, FranchiseOrderStatus orderStatus) { List orders = franchiseOrderRepository.findAllByOrderCodeInAndDeletedAtIsNull(orderCodes); @@ -719,4 +741,38 @@ public void updateDeliveryStatus(List orderCodes, FranchiseOrderStatus o orders.forEach(order -> order.updateStatus(orderStatus)); } + + public Map getOrderInfoForLog(List orderIds) { + + List orderInfos = franchiseOrderRepository.findByFranchiseOrderIdIn(orderIds); + + if (orderInfos == null || orderInfos.isEmpty()) { + throw new FranchiseOrderException(FranchiseOrderErrorCode.ORDER_NOT_FOUND); + } + + Set foundOrderIds = orderInfos.stream() + .map(FranchiseOrder::getFranchiseOrderId) + .collect(Collectors.toSet()); + + if (!foundOrderIds.containsAll(new HashSet<>(orderIds))) { + throw new FranchiseOrderException(FranchiseOrderErrorCode.DATA_OMISSION); + } + + return orderInfos.stream() + .collect(Collectors.toMap( + FranchiseOrder::getFranchiseOrderId, + order -> OrderInfoForLog.create( + order.getOrderCode(), + order.getFranchiseId() + ) + )); + } + + public Long getOrderIdByOrderItemId(Long orderItemId) { + Long orderId = franchiseOrderItemRepository.getOrderIdByOrderItemId(orderItemId); + if(orderId == null) { + throw new FranchiseOrderException(FranchiseOrderErrorCode.ORDER_NOT_FOUND); + } + return orderId; + } } diff --git a/module-domain/domain-orders/src/test/java/com/chaing/domain/orders/service/HQOrderServiceTests.java b/module-domain/domain-orders/src/test/java/com/chaing/domain/orders/service/HQOrderServiceTests.java index f8b87b03d..b24895096 100644 --- a/module-domain/domain-orders/src/test/java/com/chaing/domain/orders/service/HQOrderServiceTests.java +++ b/module-domain/domain-orders/src/test/java/com/chaing/domain/orders/service/HQOrderServiceTests.java @@ -1,16 +1,13 @@ -/* package com.chaing.domain.orders.service; import com.chaing.core.dto.info.ProductInfo; import com.chaing.domain.orders.dto.command.HQOrderCancelCommand; import com.chaing.domain.orders.dto.info.HQOrderCommand; import com.chaing.domain.orders.dto.info.HQOrderItemCommand; -import com.chaing.domain.orders.dto.request.FactoryOrderRequest; import com.chaing.domain.orders.dto.request.HQOrderCreateRequest; import com.chaing.domain.orders.dto.request.HQOrderItemCreateCommand; import com.chaing.domain.orders.dto.request.HQOrderItemUpdateRequest; import com.chaing.domain.orders.dto.request.HQOrderUpdateRequest; -import com.chaing.domain.orders.dto.response.HQOrderForTransitResponse; import com.chaing.domain.orders.entity.HeadOfficeOrder; import com.chaing.domain.orders.entity.HeadOfficeOrderItem; import com.chaing.domain.orders.enums.HQOrderStatus; @@ -35,6 +32,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.times; @@ -141,15 +139,16 @@ void getAllOrders_Success() { } @Test - @DisplayName("발주 없을 때 전체 조회 시 예외 발생") - void getAllOrders_Failure_ORDER_NOT_FOUND() { + @DisplayName("발주 없을 때 전체 조회 시 빈 맵 반환") + void getAllOrders_GivenNoOrders_ShouldReturnEmptyMap() { // given given(orderRepository.findAllByDeletedAtIsNull()).willReturn(List.of()); - // when & then - HQOrderException exception = assertThrows(HQOrderException.class, () -> - hqOrderService.getAllOrders()); - assertEquals(HQOrderErrorCode.ORDER_NOT_FOUND, exception.getErrorCode()); + // when + Map result = hqOrderService.getAllOrders(); + + // then + assertTrue(result.isEmpty()); } @Test @@ -532,162 +531,73 @@ void getProductIdsByOrderItemIds_GivenNoItems_ShouldThrowORDER_ITEM_NOT_FOUND() @DisplayName("공장 용 전체 발주 조회 - 성공") void getAllOrdersByFactory_ShouldReturnAllOrders() { // given - given(orderRepository.findAll()).willReturn(List.of(order)); + given(orderRepository.findAllByDeletedAtIsNull()).willReturn(List.of(order)); // when Map result = hqOrderService.getAllOrdersByFactory(); // then - verify(orderRepository, times(1)).findAll(); + verify(orderRepository, times(1)).findAllByDeletedAtIsNull(); assertEquals(1, result.size()); assertEquals(orderCode, result.get(orderId).orderCode()); } @Test @DisplayName("공장 발주 접수 처리 - 성공") - void updateOrderStatus_GivenAcceptRequest_ShouldReturnAcceptedStatus() { + void updateOrders_GivenAcceptRequest_ShouldReturnAcceptedStatus() { // given - FactoryOrderRequest request = new FactoryOrderRequest(true, List.of(orderCode)); - given(orderRepository.findAllByOrderCodeInAndDeletedAtIsNull(List.of(orderCode))).willReturn(List.of(order)); + List orderCodes = List.of(orderCode); + given(orderRepository.findAllByOrderCodeInAndDeletedAtIsNull(orderCodes)).willReturn(List.of(order)); // when - Map result = hqOrderService.updateOrderStatus(request); + Map result = hqOrderService.updateOrders(orderCodes, true); // then - verify(orderRepository, times(1)).findAllByOrderCodeInAndDeletedAtIsNull(List.of(orderCode)); + verify(orderRepository, times(1)).findAllByOrderCodeInAndDeletedAtIsNull(orderCodes); assertEquals(HQOrderStatus.ACCEPTED, result.get(orderCode)); } @Test @DisplayName("공장 발주 반려 처리 - 성공") - void updateOrderStatus_GivenRejectRequest_ShouldReturnRejectedStatus() { + void updateOrders_GivenRejectRequest_ShouldReturnRejectedStatus() { // given - FactoryOrderRequest request = new FactoryOrderRequest(false, List.of(orderCode)); - given(orderRepository.findAllByOrderCodeInAndDeletedAtIsNull(List.of(orderCode))).willReturn(List.of(order)); + List orderCodes = List.of(orderCode); + given(orderRepository.findAllByOrderCodeInAndDeletedAtIsNull(orderCodes)).willReturn(List.of(order)); // when - Map result = hqOrderService.updateOrderStatus(request); + Map result = hqOrderService.updateOrders(orderCodes, false); // then - verify(orderRepository, times(1)).findAllByOrderCodeInAndDeletedAtIsNull(List.of(orderCode)); + verify(orderRepository, times(1)).findAllByOrderCodeInAndDeletedAtIsNull(orderCodes); assertEquals(HQOrderStatus.REJECTED, result.get(orderCode)); } @Test @DisplayName("존재하지 않는 발주 코드로 상태 변경 시 예외 발생") - void updateOrderStatus_GivenInvalidOrderCode_ShouldThrowORDER_NOT_FOUND() { + void updateOrders_GivenInvalidOrderCode_ShouldThrowORDER_NOT_FOUND() { // given - FactoryOrderRequest request = new FactoryOrderRequest(true, List.of(orderCode)); - given(orderRepository.findAllByOrderCodeInAndDeletedAtIsNull(List.of(orderCode))).willReturn(List.of()); + List orderCodes = List.of(orderCode); + given(orderRepository.findAllByOrderCodeInAndDeletedAtIsNull(orderCodes)).willReturn(List.of()); // when & then HQOrderException exception = assertThrows(HQOrderException.class, () -> - hqOrderService.updateOrderStatus(request)); + hqOrderService.updateOrders(orderCodes, true)); assertEquals(HQOrderErrorCode.ORDER_NOT_FOUND, exception.getErrorCode()); } @Test @DisplayName("PENDING 상태가 아닌 발주 접수 처리 시 예외 발생") - void updateOrderStatus_GivenNonPendingOrder_ShouldThrowORDER_NOT_PENDING() { + void updateOrders_GivenNonPendingOrder_ShouldThrowORDER_NOT_PENDING() { // given - FactoryOrderRequest request = new FactoryOrderRequest(true, List.of(orderCode)); - given(orderRepository.findAllByOrderCodeInAndDeletedAtIsNull(List.of(orderCode))).willReturn(List.of(acceptedOrder)); + List orderCodes = List.of(orderCode); + given(orderRepository.findAllByOrderCodeInAndDeletedAtIsNull(orderCodes)).willReturn(List.of(acceptedOrder)); // when & then HQOrderException exception = assertThrows(HQOrderException.class, () -> - hqOrderService.updateOrderStatus(request)); + hqOrderService.updateOrders(orderCodes, true)); assertEquals(HQOrderErrorCode.ORDER_NOT_PENDING, exception.getErrorCode()); } - @Test - @DisplayName("운송을 위한 발주 조회 - 성공") - void getOrdersForTransit_GivenValidOrderIds_ShouldReturnTransitOrders() { - // given - List orderIds = List.of(orderId); - HeadOfficeOrder awaitingOrder = HeadOfficeOrder.builder() - .orderCode(orderCode) - .userId(userId) - .manufactureDate(manufactureDate) - .orderStatus(HQOrderStatus.AWAITING) - .totalQuantity(quantity) - .totalAmount(totalPrice) - .build(); - ReflectionTestUtils.setField(awaitingOrder, "headOfficeOrderId", orderId); - HeadOfficeOrderItem awaitingItem = HeadOfficeOrderItem.builder() - .headOfficeOrder(awaitingOrder) - .productId(productId) - .quantity(quantity) - .unitPrice(unitPrice) - .totalPrice(totalPrice) - .build(); - - given(orderItemRepository.findByHeadOfficeOrder_HeadOfficeOrderIdInAndHeadOfficeOrder_OrderStatusAndDeletedAtIsNull( - orderIds, HQOrderStatus.AWAITING)).willReturn(List.of(awaitingItem)); - - // when - List result = hqOrderService.getOrdersForTransit(orderIds); - - // then - assertEquals(1, result.size()); - assertEquals(orderCode, result.get(0).orderCode()); - assertEquals(1, result.get(0).items().size()); - assertEquals(productId, result.get(0).items().get(0).productId()); - } - - @Test - @DisplayName("빈 발주 ID 목록으로 운송 발주 조회 시 예외 발생") - void getOrdersForTransit_GivenEmptyOrderIds_ShouldThrowINVALID_INPUT() { - // when & then - HQOrderException exception = assertThrows(HQOrderException.class, () -> - hqOrderService.getOrdersForTransit(List.of())); - assertEquals(HQOrderErrorCode.INVALID_INPUT, exception.getErrorCode()); - } - - @Test - @DisplayName("운송 대기 제품 없을 때 조회 시 예외 발생") - void getOrdersForTransit_GivenNoAwaitingItems_ShouldThrowORDER_ITEM_NOT_FOUND() { - // given - List orderIds = List.of(orderId); - given(orderItemRepository.findByHeadOfficeOrder_HeadOfficeOrderIdInAndHeadOfficeOrder_OrderStatusAndDeletedAtIsNull( - orderIds, HQOrderStatus.AWAITING)).willReturn(List.of()); - - // when & then - HQOrderException exception = assertThrows(HQOrderException.class, () -> - hqOrderService.getOrdersForTransit(orderIds)); - assertEquals(HQOrderErrorCode.ORDER_ITEM_NOT_FOUND, exception.getErrorCode()); - } - - @Test - @DisplayName("요청한 발주보다 조회된 발주가 적을 때 예외 발생") - void getOrdersForTransit_GivenPartialOrders_ShouldThrowORDER_NOT_FOUND() { - // given - List orderIds = List.of(orderId, 2L); - HeadOfficeOrder awaitingOrder = HeadOfficeOrder.builder() - .orderCode(orderCode) - .userId(userId) - .manufactureDate(manufactureDate) - .orderStatus(HQOrderStatus.AWAITING) - .totalQuantity(quantity) - .totalAmount(totalPrice) - .build(); - ReflectionTestUtils.setField(awaitingOrder, "headOfficeOrderId", orderId); - HeadOfficeOrderItem awaitingItem = HeadOfficeOrderItem.builder() - .headOfficeOrder(awaitingOrder) - .productId(productId) - .quantity(quantity) - .unitPrice(unitPrice) - .totalPrice(totalPrice) - .build(); - - given(orderItemRepository.findByHeadOfficeOrder_HeadOfficeOrderIdInAndHeadOfficeOrder_OrderStatusAndDeletedAtIsNull( - orderIds, HQOrderStatus.AWAITING)).willReturn(List.of(awaitingItem)); - - // when & then - HQOrderException exception = assertThrows(HQOrderException.class, () -> - hqOrderService.getOrdersForTransit(orderIds)); - assertEquals(HQOrderErrorCode.ORDER_NOT_FOUND, exception.getErrorCode()); - } - @Test @DisplayName("발주 ID로 발주 제품 맵 조회 - 성공") void getOrderItemsByOrderId_GivenValidOrderId_ShouldReturnItemMap() { @@ -743,4 +653,4 @@ void getOrderByUserIdAndOrderCodeAndPending_GivenNonPendingOrder_ShouldThrowINVA hqOrderService.getOrderByUserIdAndOrderCodeAndPending(userId, orderCode)); assertEquals(HQOrderErrorCode.INVALID_STATUS, exception.getErrorCode()); } -}*/ +} diff --git a/module-domain/domain-products/src/main/java/com/chaing/domain/products/exception/ProductErrorCode.java b/module-domain/domain-products/src/main/java/com/chaing/domain/products/exception/ProductErrorCode.java index 2f0493d9a..309fe8af2 100644 --- a/module-domain/domain-products/src/main/java/com/chaing/domain/products/exception/ProductErrorCode.java +++ b/module-domain/domain-products/src/main/java/com/chaing/domain/products/exception/ProductErrorCode.java @@ -18,6 +18,7 @@ public enum ProductErrorCode implements ErrorCode { INVALID_PRODUCT_STATUS(400, "PR005", "잘못된 상품 상태 값입니다."), DATA_OMISSION(400, "PR006", "데이터 누락이 존재합니다."), INVALID_COMPONENT_NAME(400, "PR007", "구성용품 이름이 올바르지 않습니다."), + INVALID_PRODUCT_TYPE_NAME(400, "PR010", "상품 타입 이름 형식이 올바르지 않습니다."), // 409 CONFLICT DUPLICATE_PRODUCT_CODE(409, "PR007", "상품 코드가 중복됩니다."), diff --git a/module-domain/domain-products/src/main/java/com/chaing/domain/products/service/ProductService.java b/module-domain/domain-products/src/main/java/com/chaing/domain/products/service/ProductService.java index cd2d5b4a9..ad118f9b8 100644 --- a/module-domain/domain-products/src/main/java/com/chaing/domain/products/service/ProductService.java +++ b/module-domain/domain-products/src/main/java/com/chaing/domain/products/service/ProductService.java @@ -28,12 +28,17 @@ import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.Locale; +import java.util.regex.Pattern; import java.util.stream.Collectors; @Slf4j @RequiredArgsConstructor @Service public class ProductService { + private static final Pattern PRODUCT_TYPE_CODE_PATTERN = Pattern.compile("^[A-Z]{2}$"); + private static final Pattern HANGUL_JAMO_ONLY_PATTERN = Pattern.compile("^[ㄱ-ㅎㅏ-ㅣ\\s]+$"); + private final ProductRepository productRepository; private final ProductTypeRepository productTypeRepository; private final ProductComponentRepository productComponentRepository; @@ -100,16 +105,45 @@ public void updateProduct(Long productId, ProductUpdateRequest req) { } public void createProductTypes(String type, String productName) { - if (productTypeRepository.existsByProductType(type)) { + String normalizedType = normalizeProductTypeCode(type); + String normalizedName = normalizeProductTypeName(productName); + + if (productTypeRepository.existsByProductType(normalizedType)) { throw new ProductException(ProductErrorCode.DUPLICATE_PRODUCT_CODE); } ProductType pt = ProductType.builder() - .productType(type) - .name(productName) + .productType(normalizedType) + .name(normalizedName) .build(); productTypeRepository.save(pt); } + private String normalizeProductTypeCode(String type) { + if (type == null) { + throw new ProductException(ProductErrorCode.INVALID_PRODUCT_CODE_FORMAT); + } + + String normalized = type.trim().toUpperCase(Locale.ROOT); + if (!PRODUCT_TYPE_CODE_PATTERN.matcher(normalized).matches()) { + throw new ProductException(ProductErrorCode.INVALID_PRODUCT_CODE_FORMAT); + } + + return normalized; + } + + private String normalizeProductTypeName(String productName) { + if (productName == null) { + throw new ProductException(ProductErrorCode.INVALID_PRODUCT_TYPE_NAME); + } + + String normalized = productName.trim(); + if (normalized.isBlank() || HANGUL_JAMO_ONLY_PATTERN.matcher(normalized).matches()) { + throw new ProductException(ProductErrorCode.INVALID_PRODUCT_TYPE_NAME); + } + + return normalized; + } + public Long existType(String productCode) { if (productCode == null || productCode.length() < 2) diff --git a/module-domain/domain-products/src/test/java/com/chaing/domain/products/service/ProductServiceTests.java b/module-domain/domain-products/src/test/java/com/chaing/domain/products/service/ProductServiceTests.java index ddcf7c428..6465318da 100644 --- a/module-domain/domain-products/src/test/java/com/chaing/domain/products/service/ProductServiceTests.java +++ b/module-domain/domain-products/src/test/java/com/chaing/domain/products/service/ProductServiceTests.java @@ -9,6 +9,8 @@ import com.chaing.domain.products.entity.ProductComponent; import com.chaing.domain.products.entity.ProductType; import com.chaing.domain.products.enums.ProductStatus; +import com.chaing.domain.products.exception.ProductErrorCode; +import com.chaing.domain.products.exception.ProductException; import com.chaing.domain.products.repository.ComponentRepository; import com.chaing.domain.products.repository.ProductComponentRepository; import com.chaing.domain.products.repository.ProductRepository; @@ -28,6 +30,7 @@ import java.util.Optional; import java.util.concurrent.atomic.AtomicLong; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.mockito.Mockito.*; @@ -208,6 +211,36 @@ void createProductType() { } + @DisplayName("상품 타입 추가 실패 - 두 자리 영문 코드만 허용") + @Test + void createProductTypeFail_InvalidTypeCode() { + // given + String invalidType = "ㅢㅏ"; + String productName = "테스트"; + + // when & then + assertThatThrownBy(() -> productService.createProductTypes(invalidType, productName)) + .isInstanceOf(ProductException.class); + + verify(productTypeRepository, never()).save(any()); + } + + @DisplayName("상품 타입 추가 실패 - 상품 타입 이름이 자모만으로 구성됨") + @Test + void createProductTypeFail_InvalidProductTypeName() { + // given + String type = "AS"; + String invalidProductName = "ㅁㄴㅇ"; + + // when & then + assertThatThrownBy(() -> productService.createProductTypes(type, invalidProductName)) + .isInstanceOf(ProductException.class) + .extracting(ex -> ((ProductException) ex).getErrorCode()) + .isEqualTo(ProductErrorCode.INVALID_PRODUCT_TYPE_NAME); + + verify(productTypeRepository, never()).save(any()); + } + @DisplayName("상품 타입 존재여부") @Test void existType() { diff --git a/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/entity/Returns.java b/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/entity/Returns.java index da85eeb05..9fc579a7d 100644 --- a/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/entity/Returns.java +++ b/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/entity/Returns.java @@ -92,7 +92,12 @@ public void acceptReturn() { } public void updateStatusInInspection(ReturnStatus returnStatus) { - if (!returnStatus.equals(ReturnStatus.PENDING)) { + if (!this.returnStatus.equals(ReturnStatus.COMPLETED)) { + throw new FranchiseReturnException(FranchiseReturnErrorCode.INVALID_RETURN_STATUS); + } + + if (!returnStatus.equals(ReturnStatus.DEDUCTION_COMPLETED) + && !returnStatus.equals(ReturnStatus.DEDUCTION_REJECTED)) { throw new FranchiseReturnException(FranchiseReturnErrorCode.INVALID_RETURN_STATUS); } diff --git a/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/enums/ReturnStatus.java b/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/enums/ReturnStatus.java index 38021ecad..425c40ba9 100644 --- a/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/enums/ReturnStatus.java +++ b/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/enums/ReturnStatus.java @@ -13,8 +13,8 @@ public enum ReturnStatus { SHIPPING("SHIPPING", "배송중"), COMPLETED("COMPLETED", "배송완료"), INSPECTING("INSPECTING", "검수중"), - DEDUCTION_COMPLETED("COMPLETED", "대금 차감 완료"), - DEDUCTION_REJECTED("REJECTED", "대금 차감 거절"), + DEDUCTION_COMPLETED("DEDUCTION_COMPLETED", "대금 차감 완료"), + DEDUCTION_REJECTED("DEDUCTION_REJECTED", "대금 차감 거절"), CANCELED("CANCELED", "취소"); private final String key; diff --git a/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/repository/FranchiseReturnRepository.java b/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/repository/FranchiseReturnRepository.java index e6e56eef9..fd6c1277d 100644 --- a/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/repository/FranchiseReturnRepository.java +++ b/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/repository/FranchiseReturnRepository.java @@ -4,6 +4,9 @@ import com.chaing.domain.returns.enums.ReturnStatus; import com.chaing.domain.returns.repository.interfaces.FranchiseReturnRepositoryCustom; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.time.LocalDateTime; @@ -19,15 +22,22 @@ public interface FranchiseReturnRepository extends JpaRepository, List findAllByDeletedAtIsNull(); - // 특정 가맹점의 날짜 범위 반품 (ACCEPTED 일때) + // 특정 가맹점의 날짜 범위 반품 (여러 상태 지원) + List findAllByFranchiseIdAndReturnStatusInAndCreatedAtBetween( + Long franchiseId, List statuses, + LocalDateTime start, LocalDateTime end); + + // 특정 가맹점의 날짜 범위 반품 (ACCEPTED 일때) - 기존 메서드 유지 List findAllByFranchiseIdAndReturnStatusAndCreatedAtBetween( Long franchiseId, ReturnStatus returnStatus, LocalDateTime start, LocalDateTime end); + List findAllByReturnCodeInAndDeletedAtIsNull(List returnCodes); List findAllByReturnCodeInAndDeletedAtIsNull(Set returnCodes); - Optional findByUserIdAndFranchiseIdAndReturnCodeAndDeletedAtIsNull(Long userId, Long franchiseId, String returnCode); + Optional findByUserIdAndFranchiseIdAndReturnCodeAndDeletedAtIsNull(Long userId, Long franchiseId, + String returnCode); Optional findByFranchiseIdAndUserIdAndReturnCode(Long franchiseId, Long userId, String returnCode); @@ -36,4 +46,9 @@ List findAllByFranchiseIdAndReturnStatusAndCreatedAtBetween( List findAllByFranchiseIdAndDeletedAtIsNull(Long franchiseId); List findAllByReturnIdInAndDeletedAtIsNull(List returnIds); + + @Modifying(clearAutomatically = true) + @Query("UPDATE Returns r SET r.returnStatus = :returnStatus WHERE r.returnCode IN :returnCodes") + void updateReturnStatusByReturnCodeIn(@Param("returnCodes") List returnCodes, + @Param("returnStatus") ReturnStatus returnStatus); } diff --git a/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/service/FranchiseReturnService.java b/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/service/FranchiseReturnService.java index e9c99440c..69655d68f 100644 --- a/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/service/FranchiseReturnService.java +++ b/module-domain/domain-returns/src/main/java/com/chaing/domain/returns/service/FranchiseReturnService.java @@ -73,6 +73,15 @@ public Page getHQReturnPage(boolean isAll, Pageable page return franchiseReturnRepository.findHQReturnPage(isAll, pageable); } + // returnItem의 boxCode 목록 반환 + public List getReturnItemBoxCodes(String returnCode) { + List items = franchiseReturnItemRepository.findAllByReturns_ReturnCodeAndDeletedAtIsNull(returnCode); + if (items == null || items.isEmpty()) { + throw new FranchiseReturnException(FranchiseReturnErrorCode.RETURN_ITEM_NOT_FOUND); + } + return items.stream().map(ReturnItem::getBoxCode).toList(); + } + // 반품 세부정보 조회 public ReturnCommand getReturn(Long userId, Long franchiseId, String returnCode) { Returns returns = franchiseReturnRepository.findByUserIdAndFranchiseIdAndReturnCodeAndDeletedAtIsNull(userId, franchiseId, returnCode) @@ -331,7 +340,7 @@ public Map inspectReturnItems(Long returnId, HQReturnU .collect(Collectors.toMap( HQReturnItemUpdateRequest::boxCode, HQReturnItemUpdateRequest::status, - (a, b) -> a + this::mergeByBoxStatusPriority )); returnItemStatusByBoxCode.forEach((boxCode, status) -> { @@ -349,6 +358,13 @@ public Map inspectReturnItems(Long returnId, HQReturnU return returnItemStatusByBoxCode; } + private ReturnItemStatus mergeByBoxStatusPriority(ReturnItemStatus current, ReturnItemStatus incoming) { + if (current == ReturnItemStatus.NORMAL && incoming != ReturnItemStatus.NORMAL) { + return incoming; + } + return current; + } + // returnItemStatus를 전부 같은 상태로 수정 public Map updateReturnItemByStatus(String boxCode, ReturnItemStatus status) { ReturnItem returnItem = franchiseReturnItemRepository.findByBoxCodeAndDeletedAtIsNull(boxCode) @@ -487,4 +503,9 @@ public List getReturnForTransit(@NotNull(messa selectedReturn.getFranchiseId() )).toList(); } + + @Transactional + public void updateDeliveryStatus(List returnCodes, ReturnStatus deliverStatus) { + franchiseReturnRepository.updateReturnStatusByReturnCodeIn(returnCodes, deliverStatus); + } } diff --git a/module-domain/domain-returns/src/test/java/com/chaing/domain/returns/service/FranchiseReturnServiceTests.java b/module-domain/domain-returns/src/test/java/com/chaing/domain/returns/service/FranchiseReturnServiceTests.java index e69de29bb..a57486f2a 100644 --- a/module-domain/domain-returns/src/test/java/com/chaing/domain/returns/service/FranchiseReturnServiceTests.java +++ b/module-domain/domain-returns/src/test/java/com/chaing/domain/returns/service/FranchiseReturnServiceTests.java @@ -0,0 +1,574 @@ +package com.chaing.domain.returns.service; + +import com.chaing.core.enums.ReturnItemStatus; +import com.chaing.domain.returns.dto.command.HQReturnCommand; +import com.chaing.domain.returns.dto.command.HQReturnDetailCommand; +import com.chaing.domain.returns.dto.command.ReturnCommand; +import com.chaing.domain.returns.dto.command.ReturnItemCommand; +import com.chaing.domain.returns.dto.request.FranchiseReturnCreateRequest; +import com.chaing.domain.returns.dto.request.HQReturnItemUpdateRequest; +import com.chaing.domain.returns.dto.request.HQReturnUpdateRequest; +import com.chaing.domain.returns.entity.ReturnItem; +import com.chaing.domain.returns.entity.Returns; +import com.chaing.domain.returns.enums.ReturnStatus; +import com.chaing.domain.returns.enums.ReturnType; +import com.chaing.domain.returns.exception.FranchiseReturnErrorCode; +import com.chaing.domain.returns.exception.FranchiseReturnException; +import com.chaing.domain.returns.repository.FranchiseReturnItemRepository; +import com.chaing.domain.returns.repository.FranchiseReturnRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class FranchiseReturnServiceTests { + + @InjectMocks + private FranchiseReturnService franchiseReturnService; + + @Mock + private FranchiseReturnRepository franchiseReturnRepository; + + @Mock + private FranchiseReturnItemRepository franchiseReturnItemRepository; + + @Mock + private ReturnCodeGenerator generator; + + Long franchiseId; + Long userId; + Long orderId; + Long returnId; + Long returnItemId; + Long orderItemId; + String returnCode; + String boxCode; + String description; + + Returns returns; + Returns acceptedReturns; + ReturnItem returnItem; + + @BeforeEach + void setUp() { + franchiseId = 1L; + userId = 3L; + orderId = 10L; + returnId = 1L; + returnItemId = 100L; + orderItemId = 50L; + returnCode = "RT-20260101-001"; + boxCode = "BOX-001"; + description = "제품 불량"; + + returns = Returns.builder() + .franchiseId(franchiseId) + .franchiseOrderId(orderId) + .returnCode(returnCode) + .userId(userId) + .description(description) + .totalReturnQuantity(5) + .totalReturnAmount(BigDecimal.valueOf(35000)) + .build(); + ReflectionTestUtils.setField(returns, "returnId", returnId); + + acceptedReturns = Returns.builder() + .franchiseId(franchiseId) + .franchiseOrderId(orderId) + .returnCode(returnCode) + .userId(userId) + .description(description) + .totalReturnQuantity(5) + .totalReturnAmount(BigDecimal.valueOf(35000)) + .returnStatus(ReturnStatus.ACCEPTED) + .build(); + ReflectionTestUtils.setField(acceptedReturns, "returnId", returnId); + + returnItem = ReturnItem.builder() + .returns(returns) + .franchiseOrderItemId(orderItemId) + .boxCode(boxCode) + .build(); + ReflectionTestUtils.setField(returnItem, "returnItemId", returnItemId); + } + + // ==================== getAllReturns ==================== + + @Test + @DisplayName("반품 전체 조회 - 성공") + void getAllReturns_GivenValidFranchiseId_ShouldReturnMap() { + // given + given(franchiseReturnRepository.findAllByFranchiseIdAndDeletedAtIsNull(franchiseId)).willReturn(List.of(returns)); + + // when + Map result = franchiseReturnService.getAllReturns(franchiseId); + + // then + verify(franchiseReturnRepository, times(1)).findAllByFranchiseIdAndDeletedAtIsNull(franchiseId); + assertEquals(1, result.size()); + assertEquals(returnCode, result.get(returnId).returnCode()); + } + + @Test + @DisplayName("반품 전체 조회 - 빈 결과") + void getAllReturns_GivenNoReturns_ShouldReturnEmptyMap() { + // given + given(franchiseReturnRepository.findAllByFranchiseIdAndDeletedAtIsNull(franchiseId)).willReturn(List.of()); + + // when + Map result = franchiseReturnService.getAllReturns(franchiseId); + + // then + assertTrue(result.isEmpty()); + } + + // ==================== getReturn ==================== + + @Test + @DisplayName("반품 세부정보 조회 - 성공") + void getReturn_GivenValidParams_ShouldReturnCommand() { + // given + given(franchiseReturnRepository.findByUserIdAndFranchiseIdAndReturnCodeAndDeletedAtIsNull(userId, franchiseId, returnCode)) + .willReturn(Optional.of(returns)); + + // when + ReturnCommand result = franchiseReturnService.getReturn(userId, franchiseId, returnCode); + + // then + verify(franchiseReturnRepository, times(1)).findByUserIdAndFranchiseIdAndReturnCodeAndDeletedAtIsNull(userId, franchiseId, returnCode); + assertEquals(returnCode, result.returnCode()); + assertEquals(ReturnStatus.PENDING, result.status()); + } + + @Test + @DisplayName("반품 세부정보 조회 - RETURN_NOT_FOUND") + void getReturn_GivenInvalidParams_ShouldThrowRETURN_NOT_FOUND() { + // given + given(franchiseReturnRepository.findByUserIdAndFranchiseIdAndReturnCodeAndDeletedAtIsNull(userId, franchiseId, returnCode)) + .willReturn(Optional.empty()); + + // when & then + FranchiseReturnException exception = assertThrows(FranchiseReturnException.class, () -> + franchiseReturnService.getReturn(userId, franchiseId, returnCode)); + assertEquals(FranchiseReturnErrorCode.RETURN_NOT_FOUND, exception.getErrorCode()); + } + + // ==================== createReturn ==================== + + @Test + @DisplayName("반품 생성 - 성공") + void createReturn_GivenValidRequest_ShouldReturnCommand() { + // given + FranchiseReturnCreateRequest request = new FranchiseReturnCreateRequest( + "ORD-001", "SE", ReturnType.PRODUCT_DEFECT, description, 5, BigDecimal.valueOf(35000), List.of(boxCode)); + + // when + ReturnCommand result = franchiseReturnService.createReturn(franchiseId, orderId, returnCode, userId, request); + + // then + verify(franchiseReturnRepository, times(1)).save(any(Returns.class)); + assertEquals(returnCode, result.returnCode()); + assertEquals(ReturnStatus.PENDING, result.status()); + assertEquals(5, result.quantity()); + } + + // ==================== createReturnItems ==================== + + @Test + @DisplayName("반품 제품 생성 - 성공") + void createReturnItems_GivenValidRequest_ShouldReturnItems() { + // given + Map boxCodeByOrderItemId = Map.of(orderItemId, boxCode); + given(franchiseReturnRepository.findByReturnIdAndDeletedAtIsNull(returnId)).willReturn(Optional.of(returns)); + + // when + List result = franchiseReturnService.createReturnItems(returnId, boxCodeByOrderItemId); + + // then + verify(franchiseReturnRepository, times(1)).findByReturnIdAndDeletedAtIsNull(returnId); + verify(franchiseReturnItemRepository, times(1)).saveAll(any()); + assertEquals(1, result.size()); + assertEquals(boxCode, result.get(0).boxCode()); + } + + // ==================== cancel ==================== + + @Test + @DisplayName("반품 취소 - 성공") + void cancel_GivenPendingReturn_ShouldReturnCode() { + // given + given(franchiseReturnRepository.findByFranchiseIdAndUserIdAndReturnCode(franchiseId, userId, returnCode)) + .willReturn(Optional.of(returns)); + + // when + String result = franchiseReturnService.cancel(franchiseId, userId, returnCode); + + // then + verify(franchiseReturnRepository, times(1)).findByFranchiseIdAndUserIdAndReturnCode(franchiseId, userId, returnCode); + assertEquals(returnCode, result); + } + + @Test + @DisplayName("반품 취소 - RETURN_NOT_FOUND") + void cancel_GivenInvalidReturn_ShouldThrowRETURN_NOT_FOUND() { + // given + given(franchiseReturnRepository.findByFranchiseIdAndUserIdAndReturnCode(franchiseId, userId, returnCode)) + .willReturn(Optional.empty()); + + // when & then + FranchiseReturnException exception = assertThrows(FranchiseReturnException.class, () -> + franchiseReturnService.cancel(franchiseId, userId, returnCode)); + assertEquals(FranchiseReturnErrorCode.RETURN_NOT_FOUND, exception.getErrorCode()); + } + + @Test + @DisplayName("반품 취소 - CANCEL_NOT_ALLOWED (대기 상태가 아닌 경우)") + void cancel_GivenAcceptedReturn_ShouldThrowCANCEL_NOT_ALLOWED() { + // given + given(franchiseReturnRepository.findByFranchiseIdAndUserIdAndReturnCode(franchiseId, userId, returnCode)) + .willReturn(Optional.of(acceptedReturns)); + + // when & then + FranchiseReturnException exception = assertThrows(FranchiseReturnException.class, () -> + franchiseReturnService.cancel(franchiseId, userId, returnCode)); + assertEquals(FranchiseReturnErrorCode.CANCEL_NOT_ALLOWED, exception.getErrorCode()); + } + + // ==================== acceptReturn ==================== + + @Test + @DisplayName("반품 접수 - 성공") + void acceptReturn_GivenPendingReturns_ShouldReturnAccepted() { + // given + List returnCodes = List.of(returnCode); + given(franchiseReturnRepository.findAllByReturnCodeInAndDeletedAtIsNull(returnCodes)).willReturn(List.of(returns)); + + // when + List result = franchiseReturnService.acceptReturn(returnCodes); + + // then + verify(franchiseReturnRepository, times(1)).findAllByReturnCodeInAndDeletedAtIsNull(returnCodes); + assertEquals(1, result.size()); + assertEquals(ReturnStatus.ACCEPTED, result.get(0).status()); + } + + @Test + @DisplayName("반품 접수 - RETURN_NOT_FOUND") + void acceptReturn_GivenInvalidReturnCodes_ShouldThrowRETURN_NOT_FOUND() { + // given + List returnCodes = List.of(returnCode); + given(franchiseReturnRepository.findAllByReturnCodeInAndDeletedAtIsNull(returnCodes)).willReturn(List.of()); + + // when & then + FranchiseReturnException exception = assertThrows(FranchiseReturnException.class, () -> + franchiseReturnService.acceptReturn(returnCodes)); + assertEquals(FranchiseReturnErrorCode.RETURN_NOT_FOUND, exception.getErrorCode()); + } + + @Test + @DisplayName("반품 접수 - ALREADY_ACCEPTED (이미 접수된 상태)") + void acceptReturn_GivenAlreadyAccepted_ShouldThrowALREADY_ACCEPTED() { + // given + List returnCodes = List.of(returnCode); + given(franchiseReturnRepository.findAllByReturnCodeInAndDeletedAtIsNull(returnCodes)).willReturn(List.of(acceptedReturns)); + + // when & then + FranchiseReturnException exception = assertThrows(FranchiseReturnException.class, () -> + franchiseReturnService.acceptReturn(returnCodes)); + assertEquals(FranchiseReturnErrorCode.ALREADY_ACCEPTED, exception.getErrorCode()); + } + + // ==================== getAllReturnByStatus ==================== + + @Test + @DisplayName("상태별 반품 조회 - 성공") + void getAllReturnByStatus_GivenStatus_ShouldReturnMap() { + // given + given(franchiseReturnRepository.findAllByReturnStatus(ReturnStatus.PENDING)).willReturn(List.of(returns)); + + // when + Map result = franchiseReturnService.getAllReturnByStatus(ReturnStatus.PENDING); + + // then + verify(franchiseReturnRepository, times(1)).findAllByReturnStatus(ReturnStatus.PENDING); + assertEquals(1, result.size()); + assertEquals(returnCode, result.get(returnId).returnCode()); + } + + // ==================== getAllReturn ==================== + + @Test + @DisplayName("전체 반품 조회 - 성공") + void getAllReturn_ShouldReturnMap() { + // given + given(franchiseReturnRepository.findAllByDeletedAtIsNull()).willReturn(List.of(returns)); + + // when + Map result = franchiseReturnService.getAllReturn(); + + // then + verify(franchiseReturnRepository, times(1)).findAllByDeletedAtIsNull(); + assertEquals(1, result.size()); + assertEquals(returnCode, result.get(returnId).returnCode()); + } + + // ==================== getHQReturnInfo ==================== + + @Test + @DisplayName("본사 반품 상세 조회 - 성공") + void getHQReturnInfo_GivenValidReturnCode_ShouldReturnDetail() { + // given + given(franchiseReturnRepository.findByReturnCodeAndDeletedAtIsNull(returnCode)).willReturn(Optional.of(returns)); + + // when + HQReturnDetailCommand result = franchiseReturnService.getHQReturnInfo(returnCode); + + // then + verify(franchiseReturnRepository, times(1)).findByReturnCodeAndDeletedAtIsNull(returnCode); + assertEquals(returnCode, result.returnCode()); + assertEquals(franchiseId, result.franchiseId()); + } + + @Test + @DisplayName("본사 반품 상세 조회 - RETURN_NOT_FOUND") + void getHQReturnInfo_GivenInvalidReturnCode_ShouldThrowRETURN_NOT_FOUND() { + // given + given(franchiseReturnRepository.findByReturnCodeAndDeletedAtIsNull(returnCode)).willReturn(Optional.empty()); + + // when & then + FranchiseReturnException exception = assertThrows(FranchiseReturnException.class, () -> + franchiseReturnService.getHQReturnInfo(returnCode)); + assertEquals(FranchiseReturnErrorCode.RETURN_NOT_FOUND, exception.getErrorCode()); + } + + // ==================== getReturnItemId ==================== + + @Test + @DisplayName("반품 제품 ID 맵 조회 - 성공") + void getReturnItemId_GivenValidReturnCode_ShouldReturnMap() { + // given + given(franchiseReturnItemRepository.findAllByReturns_ReturnCodeAndDeletedAtIsNull(returnCode)).willReturn(List.of(returnItem)); + + // when + Map result = franchiseReturnService.getReturnItemId(returnCode); + + // then + verify(franchiseReturnItemRepository, times(1)).findAllByReturns_ReturnCodeAndDeletedAtIsNull(returnCode); + assertEquals(orderItemId, result.get(returnItemId)); + } + + // ==================== getReturnItemsByReturnId ==================== + + @Test + @DisplayName("반품 ID로 제품 조회 - 성공") + void getReturnItemsByReturnId_GivenValidReturnId_ShouldReturnMap() { + // given + given(franchiseReturnItemRepository.findByReturns_ReturnIdAndDeletedAtIsNull(returnId)).willReturn(List.of(returnItem)); + + // when + Map result = franchiseReturnService.getReturnItemsByReturnId(returnId); + + // then + verify(franchiseReturnItemRepository, times(1)).findByReturns_ReturnIdAndDeletedAtIsNull(returnId); + assertEquals(1, result.size()); + assertEquals(boxCode, result.get(returnItemId).boxCode()); + } + + @Test + @DisplayName("반품 ID로 제품 조회 - RETURN_ITEM_NOT_FOUND") + void getReturnItemsByReturnId_GivenNoItems_ShouldThrowRETURN_ITEM_NOT_FOUND() { + // given + given(franchiseReturnItemRepository.findByReturns_ReturnIdAndDeletedAtIsNull(returnId)).willReturn(List.of()); + + // when & then + FranchiseReturnException exception = assertThrows(FranchiseReturnException.class, () -> + franchiseReturnService.getReturnItemsByReturnId(returnId)); + assertEquals(FranchiseReturnErrorCode.RETURN_ITEM_NOT_FOUND, exception.getErrorCode()); + } + + // ==================== getAllReturnItemByReturnIds ==================== + + @Test + @DisplayName("반품 ID 목록으로 제품 조회 - 성공") + void getAllReturnItemByReturnIds_GivenValidIds_ShouldReturnGroupedMap() { + // given + List returnIds = List.of(returnId); + given(franchiseReturnItemRepository.findAllByReturns_ReturnIdInAndDeletedAtIsNull(returnIds)).willReturn(List.of(returnItem)); + + // when + Map> result = franchiseReturnService.getAllReturnItemByReturnIds(returnIds); + + // then + verify(franchiseReturnItemRepository, times(1)).findAllByReturns_ReturnIdInAndDeletedAtIsNull(returnIds); + assertEquals(1, result.get(returnId).size()); + assertEquals(boxCode, result.get(returnId).get(0).boxCode()); + } + + // ==================== inspectReturnItems ==================== + + @Test + @DisplayName("반품 제품 검수 - 성공") + void inspectReturnItems_GivenValidRequest_ShouldReturnStatusMap() { + // given + HQReturnItemUpdateRequest itemRequest = HQReturnItemUpdateRequest.builder() + .boxCode(boxCode) + .serialCode("SN-001") + .isInspected(true) + .status(ReturnItemStatus.NORMAL) + .build(); + HQReturnUpdateRequest request = new HQReturnUpdateRequest(ReturnStatus.PENDING, List.of(itemRequest)); + + given(franchiseReturnItemRepository.findAllByReturns_ReturnIdAndDeletedAtIsNull(returnId)).willReturn(List.of(returnItem)); + + // when + Map result = franchiseReturnService.inspectReturnItems(returnId, request); + + // then + verify(franchiseReturnItemRepository, times(1)).findAllByReturns_ReturnIdAndDeletedAtIsNull(returnId); + verify(franchiseReturnItemRepository, times(1)).saveAll(any()); + assertEquals(ReturnItemStatus.NORMAL, result.get(boxCode)); + } + + @Test + @DisplayName("반품 제품 검수 - RETURN_ITEM_NOT_FOUND (제품 없음)") + void inspectReturnItems_GivenNoItems_ShouldThrowRETURN_ITEM_NOT_FOUND() { + // given + HQReturnItemUpdateRequest itemRequest = HQReturnItemUpdateRequest.builder() + .boxCode(boxCode) + .serialCode("SN-001") + .isInspected(true) + .status(ReturnItemStatus.NORMAL) + .build(); + HQReturnUpdateRequest request = new HQReturnUpdateRequest(ReturnStatus.PENDING, List.of(itemRequest)); + + given(franchiseReturnItemRepository.findAllByReturns_ReturnIdAndDeletedAtIsNull(returnId)).willReturn(List.of()); + + // when & then + FranchiseReturnException exception = assertThrows(FranchiseReturnException.class, () -> + franchiseReturnService.inspectReturnItems(returnId, request)); + assertEquals(FranchiseReturnErrorCode.RETURN_ITEM_NOT_FOUND, exception.getErrorCode()); + } + + // ==================== updateReturnStatusInInspection ==================== + + @Test + @DisplayName("검수 중 반품 상태 수정 - 성공") + void updateReturnStatusInInspection_GivenValidStatus_ShouldReturnStatus() { + // given + given(franchiseReturnRepository.findByReturnIdAndDeletedAtIsNull(returnId)).willReturn(Optional.of(returns)); + + // when + ReturnStatus result = franchiseReturnService.updateReturnStatusInInspection(returnId, ReturnStatus.PENDING); + + // then + verify(franchiseReturnRepository, times(1)).findByReturnIdAndDeletedAtIsNull(returnId); + assertEquals(ReturnStatus.PENDING, result); + } + + @Test + @DisplayName("검수 중 반품 상태 수정 - RETURN_NOT_FOUND") + void updateReturnStatusInInspection_GivenInvalidReturnId_ShouldThrowRETURN_NOT_FOUND() { + // given + given(franchiseReturnRepository.findByReturnIdAndDeletedAtIsNull(returnId)).willReturn(Optional.empty()); + + // when & then + FranchiseReturnException exception = assertThrows(FranchiseReturnException.class, () -> + franchiseReturnService.updateReturnStatusInInspection(returnId, ReturnStatus.PENDING)); + assertEquals(FranchiseReturnErrorCode.RETURN_NOT_FOUND, exception.getErrorCode()); + } + + // ==================== updateShippingPending ==================== + + @Test + @DisplayName("배송대기 상태 수정 - 성공") + void updateShippingPending_GivenAcceptedReturns_ShouldReturnMap() { + // given + Set returnCodes = Set.of(returnCode); + given(franchiseReturnRepository.findAllByReturnCodeInAndDeletedAtIsNull(returnCodes)).willReturn(List.of(acceptedReturns)); + + // when + Map result = franchiseReturnService.updateShippingPending(returnCodes); + + // then + verify(franchiseReturnRepository, times(1)).findAllByReturnCodeInAndDeletedAtIsNull(returnCodes); + assertEquals(1, result.size()); + assertEquals(ReturnStatus.SHIPPING_PENDING, result.get(returnId).status()); + } + + // ==================== getReturnByReturnId ==================== + + @Test + @DisplayName("반품 엔티티 조회 - 성공") + void getReturnByReturnId_GivenValidId_ShouldReturnEntity() { + // given + given(franchiseReturnRepository.findByReturnIdAndDeletedAtIsNull(returnId)).willReturn(Optional.of(returns)); + + // when + Returns result = franchiseReturnService.getReturnByReturnId(returnId); + + // then + verify(franchiseReturnRepository, times(1)).findByReturnIdAndDeletedAtIsNull(returnId); + assertEquals(returnCode, result.getReturnCode()); + } + + @Test + @DisplayName("반품 엔티티 조회 - RETURN_NOT_FOUND") + void getReturnByReturnId_GivenInvalidId_ShouldThrowRETURN_NOT_FOUND() { + // given + given(franchiseReturnRepository.findByReturnIdAndDeletedAtIsNull(returnId)).willReturn(Optional.empty()); + + // when & then + FranchiseReturnException exception = assertThrows(FranchiseReturnException.class, () -> + franchiseReturnService.getReturnByReturnId(returnId)); + assertEquals(FranchiseReturnErrorCode.RETURN_NOT_FOUND, exception.getErrorCode()); + } + + // ==================== getReturnItemListByReturnId ==================== + + @Test + @DisplayName("반품 아이템 리스트 조회 - 성공") + void getReturnItemListByReturnId_GivenValidId_ShouldReturnList() { + // given + given(franchiseReturnItemRepository.findByReturns_ReturnIdAndDeletedAtIsNull(returnId)).willReturn(List.of(returnItem)); + + // when + List result = franchiseReturnService.getReturnItemListByReturnId(returnId); + + // then + verify(franchiseReturnItemRepository, times(1)).findByReturns_ReturnIdAndDeletedAtIsNull(returnId); + assertEquals(1, result.size()); + assertEquals(boxCode, result.get(0).getBoxCode()); + } + + @Test + @DisplayName("반품 아이템 리스트 조회 - RETURN_ITEM_NOT_FOUND") + void getReturnItemListByReturnId_GivenNoItems_ShouldThrowRETURN_ITEM_NOT_FOUND() { + // given + given(franchiseReturnItemRepository.findByReturns_ReturnIdAndDeletedAtIsNull(returnId)).willReturn(List.of()); + + // when & then + FranchiseReturnException exception = assertThrows(FranchiseReturnException.class, () -> + franchiseReturnService.getReturnItemListByReturnId(returnId)); + assertEquals(FranchiseReturnErrorCode.RETURN_ITEM_NOT_FOUND, exception.getErrorCode()); + } +} diff --git a/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/dto/request/FranchiseSellItemRequest.java b/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/dto/request/FranchiseSellItemRequest.java index 7249059a2..6d84565c6 100644 --- a/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/dto/request/FranchiseSellItemRequest.java +++ b/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/dto/request/FranchiseSellItemRequest.java @@ -17,6 +17,9 @@ public record FranchiseSellItemRequest( @NotBlank String productName, + @NotNull + Integer quantity, + @NotNull BigDecimal unitPrice, diff --git a/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/FranchiseSalesItemRepository.java b/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/FranchiseSalesItemRepository.java index 6a685efc3..e3bbab332 100644 --- a/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/FranchiseSalesItemRepository.java +++ b/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/FranchiseSalesItemRepository.java @@ -12,4 +12,15 @@ public interface FranchiseSalesItemRepository extends JpaRepository findAllBySalesSalesIdIn(List salesIds); + // 가맹점별 모든 판매 아이템 조회 (Sales와 Join Fetch 하여 N+1 방지) + @org.springframework.data.jpa.repository.Query("SELECT si FROM SalesItem si JOIN FETCH si.sales s WHERE s.franchiseId = :franchiseId") + List findAllBySalesFranchiseId(Long franchiseId); + + // 가맹점별 기간 내 판매 아이템 조회 + @org.springframework.data.jpa.repository.Query("SELECT si FROM SalesItem si JOIN FETCH si.sales s WHERE s.franchiseId = :franchiseId AND si.createdAt BETWEEN :start AND :end") + List findAllBySalesFranchiseIdAndCreatedAtBetween( + @org.springframework.data.repository.query.Param("franchiseId") Long franchiseId, + @org.springframework.data.repository.query.Param("start") java.time.LocalDateTime start, + @org.springframework.data.repository.query.Param("end") java.time.LocalDateTime end); + } diff --git a/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/FranchiseSalesRepository.java b/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/FranchiseSalesRepository.java index 922ef9254..1c573357b 100644 --- a/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/FranchiseSalesRepository.java +++ b/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/FranchiseSalesRepository.java @@ -15,4 +15,7 @@ public interface FranchiseSalesRepository extends JpaRepository { // 특정 가맹점의 날짜 범위 매출 (취소 안 된 것만) List findAllByFranchiseIdAndIsCanceledFalseAndCreatedAtBetween( Long franchiseId, LocalDateTime start, LocalDateTime end); + + List findAllByFranchiseIdAndCreatedAtBetween( + Long franchiseId, LocalDateTime start, LocalDateTime end); } diff --git a/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/impl/FranchiseSalesItemRepositoryImpl.java b/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/impl/FranchiseSalesItemRepositoryImpl.java index d77485870..1fdf25991 100644 --- a/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/impl/FranchiseSalesItemRepositoryImpl.java +++ b/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/impl/FranchiseSalesItemRepositoryImpl.java @@ -175,4 +175,4 @@ public List searchDailyProductSalesForSafet }) .toList(); } -} +} \ No newline at end of file diff --git a/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/interfaces/FranchiseSalesItemRepositoryCustom.java b/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/interfaces/FranchiseSalesItemRepositoryCustom.java index 90797255d..a15771fc7 100644 --- a/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/interfaces/FranchiseSalesItemRepositoryCustom.java +++ b/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/repository/interfaces/FranchiseSalesItemRepositoryCustom.java @@ -25,6 +25,5 @@ List searchDailyProductSalesForSafetyStock( List franchiseIds, List productIds, LocalDate startDate, - LocalDate endDate - ); + LocalDate endDate); } diff --git a/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/service/FranchiseSalesService.java b/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/service/FranchiseSalesService.java index 73c76628a..ab5f576bf 100644 --- a/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/service/FranchiseSalesService.java +++ b/module-domain/domain-sales/src/main/java/com/chaing/domain/sales/service/FranchiseSalesService.java @@ -94,6 +94,7 @@ public FranchiseSellResponse sell(Long franchiseId, String franchiseCode, Franch salesItem = SalesItem.builder() .sales(sales) .productId(itemRequest.productId()) + .quantity(itemRequest.quantity()) .productCode(itemRequest.productCode()) .productName(itemRequest.productName()) .lot(itemRequest.serialCode()) diff --git a/module-domain/domain-sales/src/test/java/com/chaing/domain/sales/service/FranchiseSalesServiceTests.java b/module-domain/domain-sales/src/test/java/com/chaing/domain/sales/service/FranchiseSalesServiceTests.java index 7906293d7..ecbb33ee7 100644 --- a/module-domain/domain-sales/src/test/java/com/chaing/domain/sales/service/FranchiseSalesServiceTests.java +++ b/module-domain/domain-sales/src/test/java/com/chaing/domain/sales/service/FranchiseSalesServiceTests.java @@ -1,4 +1,3 @@ -/* package com.chaing.domain.sales.service; import com.chaing.domain.sales.dto.request.FranchiseSellItemRequest; @@ -57,6 +56,7 @@ class FranchiseSalesServiceTests { private SalesCodeGenerator salesCodeGenerator; Long franchiseId; + String franchiseCode; String username; Long salesId; @@ -81,6 +81,7 @@ class FranchiseSalesServiceTests { @BeforeEach void setUp() { franchiseId = 1L; + franchiseCode = "SE"; username = "test"; salesId = 1L; @@ -136,7 +137,7 @@ void setUp() { .salesCode(salesCode) .salesDate(LocalDateTime.now()) .products( - FranchiseSalesProductResponse.from(List.of(salesItem)) + FranchiseSalesProductResponse.from(List.of(salesItem), 10) ) .build(); @@ -146,7 +147,7 @@ void setUp() { .productId(productId) .quantity(10) .unitPrice(BigDecimal.valueOf(5000)) - .lot("lot") + .serialCode("lot") .build(); franchiseSellRequest = FranchiseSellRequest.builder() .totalAmount(BigDecimal.valueOf(50000)) @@ -219,7 +220,7 @@ void getSalesDetail_Failure() { @DisplayName("판매 생성 - 성공") void createSales_Success() { // when - FranchiseSellResponse response = franchiseSalesService.sell(franchiseId, franchiseSellRequest); + FranchiseSellResponse response = franchiseSalesService.sell(franchiseId, franchiseCode, franchiseSellRequest); // then verify(franchiseSalesRepository, times(1)).save(any()); @@ -228,26 +229,24 @@ void createSales_Success() { assertEquals(BigDecimal.valueOf(50000), response.totalPrice()); assertEquals("productCode", response.items().get(0).productCode()); assertEquals("productName", response.items().get(0).productName()); - assertEquals(10, response.items().get(0).quantity()); assertEquals(BigDecimal.valueOf(5000), response.items().get(0).unitPrice()); - assertEquals(BigDecimal.valueOf(50000), response.items().get(0).totalPrice()); } @Test @DisplayName("동일한 salesCode로 판매 생성 시 예외 발생") void createSales_Failure_DUPLICATE_SALES_CODE() { // given - given(salesCodeGenerator.generate()).willReturn("salesCode"); + given(salesCodeGenerator.generate(franchiseCode)).willReturn("salesCode"); given(franchiseSalesRepository.save(any(Sales.class))) .willReturn(sales) .willThrow(new DataIntegrityViolationException("duplicate salesCode")); // when & then - franchiseSalesService.sell(franchiseId, franchiseSellRequest); + franchiseSalesService.sell(franchiseId, franchiseCode, franchiseSellRequest); FranchiseSalesException exception = assertThrows(FranchiseSalesException.class, () -> { - franchiseSalesService.sell(franchiseId, franchiseSellRequest); + franchiseSalesService.sell(franchiseId, franchiseCode, franchiseSellRequest); }); verify(franchiseSalesRepository, times(2)).save(any()); assertEquals(FranchiseSalesErrorCode.DUPLICATE_SALES_CODE, exception.getErrorCode()); @@ -264,7 +263,7 @@ void createSales_Failure_DUPLICATE_LOT() { // when & then FranchiseSalesException exception = assertThrows(FranchiseSalesException.class, () -> { - franchiseSalesService.sell(franchiseId, franchiseSellRequest); + franchiseSalesService.sell(franchiseId, franchiseCode, franchiseSellRequest); }); verify(franchiseSalesRepository, times(1)).save(any()); verify(franchiseSalesItemRepository, times(1)).saveAll(any()); @@ -320,4 +319,4 @@ void cancelSales_Failure_ALREADY_CANCELLED() { verify(franchiseSalesRepository, times(1)).findByFranchiseIdAndSalesCode(franchiseId, salesCode); assertEquals(FranchiseSalesErrorCode.ALREADY_CANCELLED, exception.getErrorCode()); } -}*/ +} diff --git a/module-domain/domain-settlements/build.gradle b/module-domain/domain-settlements/build.gradle index 2812fb403..b9ac4f66a 100644 --- a/module-domain/domain-settlements/build.gradle +++ b/module-domain/domain-settlements/build.gradle @@ -1,6 +1,7 @@ dependencies { // Core 모듈 implementation project(':module-core') + implementation project(':domain-returns') // DB (필요하다면, core에 있으면 생략 가능하지만 명시하는 게 좋음) implementation 'org.springframework.boot:spring-boot-starter-data-jpa' diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/entity/DailySettlementReceipt.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/entity/DailySettlementReceipt.java index ea9b60d1f..3308120c2 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/entity/DailySettlementReceipt.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/entity/DailySettlementReceipt.java @@ -98,4 +98,17 @@ private void validateFinalAmount() { } } + public void updateAmounts(BigDecimal totalSale, BigDecimal order, BigDecimal delivery, + BigDecimal commission, BigDecimal loss, BigDecimal refund, + BigDecimal adjustment, BigDecimal finalAmt) { + this.totalSaleAmount = totalSale; + this.orderAmount = order; + this.deliveryFee = delivery; + this.commissionFee = commission; + this.lossAmount = loss; + this.refundAmount = refund; + this.adjustmentAmount = adjustment; + this.finalAmount = finalAmt; + } + } diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/entity/MonthlySettlement.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/entity/MonthlySettlement.java index 5c4846cb9..be1ca490c 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/entity/MonthlySettlement.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/entity/MonthlySettlement.java @@ -19,6 +19,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -27,6 +28,7 @@ @Entity @Getter @Builder +@Slf4j @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "monthly_settlement", uniqueConstraints = @UniqueConstraint(name = "uk_monthly_settlement_franchise_month", columnNames = { @@ -119,4 +121,28 @@ public void rollback() { } this.status = SettlementStatus.CALCULATED; } + + public void updateAmounts(BigDecimal totalSale, BigDecimal order, BigDecimal delivery, + BigDecimal commission, BigDecimal loss, BigDecimal refund, + BigDecimal adjustment, BigDecimal finalAmt) { + this.totalSaleAmount = totalSale; + this.orderAmount = order; + this.deliveryFee = delivery; + this.commissionFee = commission; + this.lossAmount = loss; + this.refundAmount = refund; + this.adjustmentAmount = adjustment; + + // 데이터 무결성 검증: 상세 항목의 합계와 전달받은 최종 금액이 일치하는지 확인 + BigDecimal expectedFinal = calculatedFranchisedFinal(); + if (finalAmt != null && finalAmt.compareTo(expectedFinal) != 0) { + log.error("Settlement Total Mismatch: Expected {}, but received {} for Franchise {}", + expectedFinal, finalAmt, this.franchiseId); + // 불일치 시 시스템이 계산한 정확한 값으로 강제 보정하거나 예외를 던질 수 있음 + // 여기서는 정밀한 정산을 위해 계산된 값으로 할당함 + } + + this.finalSettlementAmount = expectedFinal; + this.calculatedAt = LocalDateTime.now(); + } } diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/entity/SettlementAdjustment.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/entity/SettlementAdjustment.java index dfb713b15..3854a9493 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/entity/SettlementAdjustment.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/entity/SettlementAdjustment.java @@ -27,7 +27,8 @@ @AllArgsConstructor(access = AccessLevel.PROTECTED) @Table(name = "settlement_adjustment", indexes = { @Index(name = "idx_adjustment_voucher", columnList = "settlement_voucher_id"), - @Index(name = "idx_adjustment_created_by", columnList = "created_by") + @Index(name = "idx_adjustment_created_by", columnList = "created_by"), + @Index(name = "idx_adjustment_month", columnList = "settlement_month") }) public class SettlementAdjustment extends BaseEntity { @@ -35,7 +36,7 @@ public class SettlementAdjustment extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long settlementAdjustmentId; - @Column(name = "settlement_voucher_id", nullable = false) + @Column(name = "settlement_voucher_id", nullable = true) // ⭐️ Null 허용 명시 private Long settlementVoucherId; // 어떤 전표 라인에 대한 조정인지 @Column(name = "adjustment_code", nullable = false, unique = true, length = 50) @@ -45,12 +46,19 @@ public class SettlementAdjustment extends BaseEntity { private Long franchiseId; // 가맹점 드롭다운 @Enumerated(EnumType.STRING) - @Column(name = "voucher_type", nullable = false, length = 30) + @Column(name = "voucher_type", nullable = false, length = 50, columnDefinition = "VARCHAR(50)") private VoucherType voucherType; // 전표 유형 (기타 조정) @Column(name = "occurred_at", nullable = false) private LocalDateTime occurredAt; // 발생일 + @Column(name = "settlement_month", nullable = false, length = 7) + private String settlementMonth; // 정산 반영월 (예: "2026-04") + + @Enumerated(EnumType.STRING) + @Column(name = "return_type", length = 50, columnDefinition = "VARCHAR(50)") + private com.chaing.domain.returns.enums.ReturnType returnType; // 반품 사유 (오발주, 상품하자 등) + @Builder.Default @Column(name = "is_minus", nullable = false) private Boolean isMinus = false; // 마이너스 체크박스 diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/enums/AdjustmentDirection.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/enums/AdjustmentDirection.java new file mode 100644 index 000000000..50f71981c --- /dev/null +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/enums/AdjustmentDirection.java @@ -0,0 +1,13 @@ +package com.chaing.domain.settlements.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum AdjustmentDirection { + INCREASE("증가"), + DECREASE("차감"); + + private final String description; +} diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/enums/VoucherType.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/enums/VoucherType.java index 53933c453..0a9947703 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/enums/VoucherType.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/enums/VoucherType.java @@ -12,6 +12,7 @@ public enum VoucherType { COMMISSION("수수료"), LOSS("손실"), REFUND("반품환급"), + RETURN("반품"), ADJUSTMENT("조정"); private String description; diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/repository/interfaces/DailyReceiptLineRepository.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/repository/interfaces/DailyReceiptLineRepository.java index 38fe2bc66..f916d7aeb 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/repository/interfaces/DailyReceiptLineRepository.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/repository/interfaces/DailyReceiptLineRepository.java @@ -12,13 +12,13 @@ @Repository public interface DailyReceiptLineRepository extends JpaRepository { - // 특정 일별 영수증 목록 - List findAllByDailyReceiptId(Long dailyReceiptId); + // 특정 일별 영수증 목록 + List findAllByDailyReceiptId(Long dailyReceiptId); - // 특정 일별 영수증 (유형별 필터 + 페이징) — 전표 상세 목록 페이지 - Page findAllByDailyReceiptId(Long dailyReceiptId, Pageable pageable); + // 특정 일별 영수증 (유형별 필터 + 페이징) — 전표 상세 목록 페이지 + Page findAllByDailyReceiptId(Long dailyReceiptId, Pageable pageable); - // 유형 필터링 + 페이징 - Page findAllByDailyReceiptIdAndLineType( - Long dailyReceiptId, VoucherType lineType, Pageable pageable); + // 유형 필터링 + 페이징 + Page findAllByDailyReceiptIdAndLineType( + Long dailyReceiptId, VoucherType lineType, Pageable pageable); } diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/repository/interfaces/MonthlySettlementRepository.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/repository/interfaces/MonthlySettlementRepository.java index 295418598..788e813bc 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/repository/interfaces/MonthlySettlementRepository.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/repository/interfaces/MonthlySettlementRepository.java @@ -26,6 +26,9 @@ List findAllBySettlementMonthAndFranchiseNameContaining( @Param("month") YearMonth month, @Param("keyword") String keyword); + // 특정 가맹점의 모든 월별 정산 내역 조회 + List findAllByFranchiseId(Long franchiseId); + // 특정 가맹점 + 월 (가맹점 월별 요약) Optional findByFranchiseIdAndSettlementMonth( Long franchiseId, YearMonth month); diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/repository/interfaces/SettlementAdjustmentRepository.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/repository/interfaces/SettlementAdjustmentRepository.java index e52dc56f8..81aafc878 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/repository/interfaces/SettlementAdjustmentRepository.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/repository/interfaces/SettlementAdjustmentRepository.java @@ -14,9 +14,20 @@ public interface SettlementAdjustmentRepository extends JpaRepository findByConditions( @Param("franchiseId") Long franchiseId, @Param("voucherType") VoucherType voucherType, + @Param("settlementMonth") String settlementMonth, Pageable pageable); + + @Query("SELECT COALESCE(SUM(s.adjustmentAmount), 0) " + + "FROM SettlementAdjustment s " + + "WHERE s.settlementMonth = :settlementMonth " + + "AND (:franchiseId IS NULL OR s.franchiseId = :franchiseId)") + java.math.BigDecimal sumAdjustmentAmountByMonth( + @Param("settlementMonth") String settlementMonth, + @Param("franchiseId") Long franchiseId); } + diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/DailySettlementService.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/DailySettlementService.java index da486a0e1..18401adcd 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/DailySettlementService.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/DailySettlementService.java @@ -32,4 +32,7 @@ List getAllByFranchiseAndDateRange( // 일별 전표 전체 목록 (PDF/Excel용) List getAllReceiptLines(Long dailyReceiptId); + + // 정산 데이터 저장 + DailySettlementReceipt save(DailySettlementReceipt receipt); } diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/MonthlySettlementService.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/MonthlySettlementService.java index 2a21af248..9664b3ce2 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/MonthlySettlementService.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/MonthlySettlementService.java @@ -29,4 +29,7 @@ public interface MonthlySettlementService { // 확정 취소 (수정 버튼) MonthlySettlement rollback(Long monthlySettlementId); + + // 정산 데이터 저장 + MonthlySettlement save(MonthlySettlement settlement); } diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/SettlementAdjustmentService.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/SettlementAdjustmentService.java index 652580792..dea86bfa3 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/SettlementAdjustmentService.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/SettlementAdjustmentService.java @@ -11,7 +11,7 @@ public interface SettlementAdjustmentService { SettlementAdjustment create(SettlementAdjustment adjustment); // 목록 조회 (페이징 + 필터링) - Page getAll(Long franchiseId, VoucherType voucherType, Pageable pageable); + Page getAll(Long franchiseId, VoucherType voucherType, String settlementMonth, Pageable pageable); // 단건 조회 (하단 상세) SettlementAdjustment getById(Long id); diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/SettlementDocumentService.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/SettlementDocumentService.java index 48ca71292..fb90f78fd 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/SettlementDocumentService.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/SettlementDocumentService.java @@ -18,4 +18,7 @@ public interface SettlementDocumentService { // 4. 문서 메타데이터 저장 void save(SettlementDocument document); + + // 5. 유령 레코드 삭제 (MinIO 파일 없는 stale 레코드 정리용) + void deleteById(Long settlementDocumentId); } diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/SettlementFileService.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/SettlementFileService.java index a7fd44a38..db2106f15 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/SettlementFileService.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/SettlementFileService.java @@ -11,7 +11,7 @@ public interface SettlementFileService { // 일별 정산 영수증 PDF 생성 - byte[] createDailyReceiptPdf(DailySettlementReceipt receipt, List lines); + byte[] createDailyReceiptPdf(DailySettlementReceipt receipt, List lines, String franchiseName); /** * 월별 정산 내역 엑셀 생성 (본사/요약용) @@ -26,7 +26,7 @@ public interface SettlementFileService { /** * 월별 정산 영수증 PDF 생성 */ - byte[] createMonthlyReceiptPdf(MonthlySettlement settlement, List vouchers); + byte[] createMonthlyReceiptPdf(MonthlySettlement settlement, List vouchers, String franchiseName); byte[] createHQSettlementDailyPdf(java.time.LocalDate date, List receipts); diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/DailySettlementServiceImpl.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/DailySettlementServiceImpl.java index 3aa49b623..7915de754 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/DailySettlementServiceImpl.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/DailySettlementServiceImpl.java @@ -43,13 +43,12 @@ public List getAllByDate(LocalDate date, String keyword) @Override public DailySettlementReceipt getByFranchiseAndDate(Long franchiseId, LocalDate date) { - log.info("[DEBUG] getByFranchiseAndDate called for franchiseId: {}, date: {}", franchiseId, date); - return receiptRepository.findByFranchiseIdAndSettlementDate(franchiseId, date) + return findByFranchiseAndDate(franchiseId, date) .orElseThrow(() -> { log.warn("[DEBUG] Daily Settlement NOT FOUND for franchiseId: {}, date: {}", franchiseId, date); return new SettlementException(SettlementErrorCode.DAILY_SETTLEMENT_NOT_FOUND); }); - } // 가맹점에서 정산을 조회하고 싶을 때 + } @Override public Optional findByFranchiseAndDate(Long franchiseId, LocalDate date) { @@ -82,4 +81,12 @@ public Page getReceiptLines( public List getAllReceiptLines(Long dailyReceiptId) { return lineRepository.findAllByDailyReceiptId(dailyReceiptId); } // Excel 다운로드 + + @Override + @Transactional + public DailySettlementReceipt save(DailySettlementReceipt receipt) { + log.info("[DEBUG] Saving DailySettlementReceipt for franchiseId: {}, date: {}", + receipt.getFranchiseId(), receipt.getSettlementDate()); + return receiptRepository.save(receipt); + } } diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/MonthlySettlementServiceImpl.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/MonthlySettlementServiceImpl.java index 8b171c2b5..67ce1f8bc 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/MonthlySettlementServiceImpl.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/MonthlySettlementServiceImpl.java @@ -7,6 +7,7 @@ import com.chaing.domain.settlements.repository.interfaces.MonthlySettlementRepository; import com.chaing.domain.settlements.service.MonthlySettlementService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,6 +16,7 @@ import java.util.Map; import java.util.Optional; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -77,4 +79,11 @@ public MonthlySettlement rollback(Long monthlySettlementId) { return repository.save(settlement); } // 상태 : 수정 누르면 그 전으로 rollback + @Override + @Transactional + public MonthlySettlement save(MonthlySettlement settlement) { + log.info("[DEBUG] Saving MonthlySettlement for franchiseId: {}, month: {}", + settlement.getFranchiseId(), settlement.getSettlementMonth()); + return repository.save(settlement); + } } diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/SettlementAdjustmentServiceImpl.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/SettlementAdjustmentServiceImpl.java index bcf704c60..9662ede42 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/SettlementAdjustmentServiceImpl.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/SettlementAdjustmentServiceImpl.java @@ -31,8 +31,8 @@ public SettlementAdjustment create(SettlementAdjustment adjustment) { } // 새로운 조정 내역을 DB에 저장 @Override - public Page getAll(Long franchiseId, VoucherType voucherType, Pageable pageable) { - return repository.findByConditions(franchiseId, voucherType, pageable); + public Page getAll(Long franchiseId, VoucherType voucherType, String settlementMonth, Pageable pageable) { + return repository.findByConditions(franchiseId, voucherType, settlementMonth, pageable); } // 조건별 조정 내역 DB에서 가져오기 @Override diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/SettlementDocumentServiceImpl.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/SettlementDocumentServiceImpl.java index 595ddd0cf..dcc789693 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/SettlementDocumentServiceImpl.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/SettlementDocumentServiceImpl.java @@ -47,4 +47,11 @@ public void save(SettlementDocument document) { document.getDocumentType(), document.getDocumentOwner()); documentRepository.save(document); } + + @Override + @Transactional + public void deleteById(Long settlementDocumentId) { + log.info("[DEBUG] Deleting stale SettlementDocument id={}", settlementDocumentId); + documentRepository.deleteById(settlementDocumentId); + } } diff --git a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/SettlementFileServiceImpl.java b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/SettlementFileServiceImpl.java index 42b1822b2..d5ec83f07 100644 --- a/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/SettlementFileServiceImpl.java +++ b/module-domain/domain-settlements/src/main/java/com/chaing/domain/settlements/service/impl/SettlementFileServiceImpl.java @@ -14,6 +14,11 @@ import com.itextpdf.layout.element.Table; import com.itextpdf.layout.properties.UnitValue; import com.itextpdf.io.font.PdfEncodings; +import com.itextpdf.kernel.geom.PageSize; +import com.itextpdf.layout.properties.TextAlignment; +import com.itextpdf.layout.borders.Border; +import com.itextpdf.layout.borders.SolidBorder; +import com.itextpdf.kernel.colors.ColorConstants; import lombok.extern.slf4j.Slf4j; import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Sheet; @@ -21,6 +26,7 @@ import org.apache.poi.xssf.usermodel.XSSFWorkbook; import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Service; +import org.springframework.util.FileCopyUtils; import java.math.BigDecimal; import java.io.ByteArrayOutputStream; @@ -32,44 +38,89 @@ public class SettlementFileServiceImpl implements SettlementFileService { @Override - public byte[] createDailyReceiptPdf(DailySettlementReceipt receipt, List lines) { + public byte[] createDailyReceiptPdf(DailySettlementReceipt receipt, List lines, String franchiseName) { try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { PdfWriter writer = new PdfWriter(baos); PdfDocument pdf = new PdfDocument(writer); - Document document = new Document(pdf); - PdfFont font = getKoreanFont(); - if (font != null) { - document.setFont(font); + Document document = new Document(pdf, PageSize.A4); + document.setMargins(30, 30, 30, 30); + + PdfFont font = loadKoreanFont(); + document.setFont(font); + document.setFontSize(9); + + // 1. 헤더 영역 + document.add(new Paragraph("일일 정산 영수증 (공급증)") + .setBold().setFontSize(18).setTextAlignment(TextAlignment.CENTER).setMarginBottom(10)); + + Table metaTable = new Table(UnitValue.createPercentArray(new float[]{50, 50})).useAllAvailableWidth(); + metaTable.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph("가맹점: " + franchiseName + " (ID: " + receipt.getFranchiseId() + ")")).setBorder(Border.NO_BORDER)); + metaTable.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph("No. " + receipt.getDailyReceiptId() + "\n정산일자: " + receipt.getSettlementDate())).setTextAlignment(TextAlignment.RIGHT).setBorder(Border.NO_BORDER)); + document.add(metaTable.setMarginBottom(15)); + + // 2. 공급자 정보 + Table providerTable = new Table(UnitValue.createPercentArray(new float[]{15, 35, 15, 35})).useAllAvailableWidth(); + addInfoCell(providerTable, "사업자번호", "123-45-67890", true); + addInfoCell(providerTable, "상호", "(주)채잉 본사", false); + addInfoCell(providerTable, "대표자", "김채잉", true); + addInfoCell(providerTable, "주소", "서울 서초구 반포동 123", false); + document.add(providerTable.setMarginBottom(20)); + + // 3. 정산 요약 영역 + document.add(new Paragraph("[ 정산 요약 ]").setBold().setMarginBottom(5)); + Table summaryTable = new Table(UnitValue.createPercentArray(new float[]{40, 60})).useAllAvailableWidth(); + addSummaryRow(summaryTable, "1. 총 매출액", receipt.getTotalSaleAmount()); + addSummaryRow(summaryTable, "2. 반품 환급액 (+)", receipt.getRefundAmount()); + addSummaryRow(summaryTable, "3. 발주 대금 (-)", receipt.getOrderAmount().negate()); + addSummaryRow(summaryTable, "4. 정산 수수료 (-)", receipt.getCommissionFee().negate()); + addSummaryRow(summaryTable, "5. 배송비 (-)", receipt.getDeliveryFee().negate()); + addSummaryRow(summaryTable, "6. 본사 손실액 (-)", receipt.getLossAmount().negate()); + addSummaryRow(summaryTable, "7. 기타 조정", receipt.getAdjustmentAmount()); + + com.itextpdf.layout.element.Cell totalLabelCell = new com.itextpdf.layout.element.Cell().add(new Paragraph("최종 정산 금액").setBold().setFontSize(11)) + .setBackgroundColor(ColorConstants.LIGHT_GRAY).setPadding(5).setBorder(new SolidBorder(0.5f)); + com.itextpdf.layout.element.Cell totalValueCell = new com.itextpdf.layout.element.Cell().add(new Paragraph(formatCurrency(receipt.getFinalAmount())).setBold().setFontSize(11)) + .setTextAlignment(TextAlignment.RIGHT).setPadding(5).setBorder(new SolidBorder(0.5f)); + summaryTable.addCell(totalLabelCell); + summaryTable.addCell(totalValueCell); + document.add(summaryTable.setMarginBottom(25)); + + // 4. 매출 상세 내역 (VoucherType.SALES) + document.add(new Paragraph("[ 매출 현황 상세 ]").setBold().setMarginBottom(5)); + float[] detailWidths = {20, 40, 10, 15, 15}; + Table salesTable = new Table(UnitValue.createPercentArray(detailWidths)).useAllAvailableWidth(); + addDetailHeader(salesTable, "시간"); + + List salesLines = lines.stream().filter(l -> l.getLineType() == com.chaing.domain.settlements.enums.VoucherType.SALES).toList(); + if (salesLines.isEmpty()) { + salesTable.addCell(new com.itextpdf.layout.element.Cell(1, 5).add(new Paragraph("매출 내역이 없습니다.").setTextAlignment(TextAlignment.CENTER)).setPadding(5).setBorder(new SolidBorder(0.5f))); + } else { + for (DailyReceiptLine line : salesLines) { + addDetailRow(salesTable, line); + } } - - document.add(new Paragraph("Daily Settlement Receipt").setBold().setFontSize(20)); - document.add(new Paragraph("가맹점 ID: " + receipt.getFranchiseId())); - document.add(new Paragraph("정산 일자: " + receipt.getSettlementDate())); - document.add(new Paragraph("--------------------------------------------")); - document.add(new Paragraph("1. 총 매출액: " + receipt.getTotalSaleAmount() + "원")); - document.add(new Paragraph("2. 차감 내역:")); - document.add(new Paragraph(" - 발주 대금: -" + receipt.getOrderAmount() + "원")); - document.add(new Paragraph(" - 정산 수수료: -" + receipt.getCommissionFee() + "원")); - document.add(new Paragraph(" - 배송비: -" + receipt.getDeliveryFee() + "원")); - document.add(new Paragraph(" - 본사 손실액: -" + receipt.getLossAmount() + "원")); - document.add(new Paragraph("3. 가산 내역:")); - document.add(new Paragraph(" - 반품 환급액: +" + receipt.getRefundAmount() + "원")); - document.add(new Paragraph("--------------------------------------------")); - document.add(new Paragraph("최종 정산 금액: " + receipt.getFinalAmount() + "원").setBold().setFontSize(14)); - - document.add(new Paragraph("\n[상세 전표 내역]")); - Table table = new Table(UnitValue.createPointArray(new float[] { 100, 200, 100 })); - table.addCell("유형"); - table.addCell("설명"); - table.addCell("금액"); - - for (DailyReceiptLine line : lines) { - table.addCell(line.getLineType().name()); - table.addCell(line.getDescription()); - table.addCell(line.getAmount().toString() + "원"); + document.add(salesTable.setMarginBottom(20)); + + // 5. 발주 내역 상세 (VoucherType.ORDER) + document.add(new Paragraph("[ 발주 내역 상세 ]").setBold().setMarginBottom(5)); + Table purchaseTable = new Table(UnitValue.createPercentArray(detailWidths)).useAllAvailableWidth(); + addDetailHeader(purchaseTable, "시간"); + + List purchaseLines = lines.stream().filter(l -> l.getLineType() == com.chaing.domain.settlements.enums.VoucherType.ORDER).toList(); + if (purchaseLines.isEmpty()) { + purchaseTable.addCell(new com.itextpdf.layout.element.Cell(1, 5).add(new Paragraph("발주 내역이 없습니다.").setTextAlignment(TextAlignment.CENTER)).setPadding(5).setBorder(new SolidBorder(0.5f))); + } else { + for (DailyReceiptLine line : purchaseLines) { + addDetailRow(purchaseTable, line); + } } + document.add(purchaseTable.setMarginBottom(20)); + + // 6. 하단 푸터 + document.add(new Paragraph("\n본 문서는 시스템에 의해 자동 발행된 일일 정산 명세서이며, 정산 증빙용으로 사용 가능합니다.") + .setFontSize(8).setFontColor(ColorConstants.GRAY).setTextAlignment(TextAlignment.CENTER).setMarginTop(30)); + document.add(new Paragraph("(주)채잉 귀하").setBold().setTextAlignment(TextAlignment.RIGHT).setMarginTop(20)); - document.add(table); document.close(); return baos.toByteArray(); } catch (IOException e) { @@ -78,6 +129,7 @@ public byte[] createDailyReceiptPdf(DailySettlementReceipt receipt, List settlements) { try (Workbook workbook = new XSSFWorkbook(); ByteArrayOutputStream baos = new ByteArrayOutputStream()) { @@ -90,7 +142,7 @@ public byte[] createMonthlySettlementExcel(List settlements) header.createCell(2).setCellValue("총 매출액"); header.createCell(3).setCellValue("발주 대금(-)"); header.createCell(4).setCellValue("수수료 수익(-)"); - header.createCell(5).setCellValue("배송 수익(-)"); + header.createCell(5).setCellValue("배송비(-)"); header.createCell(6).setCellValue("반품 차감액(+)"); header.createCell(7).setCellValue("본사 손실액(-)"); header.createCell(8).setCellValue("최종 정산 금액"); @@ -155,55 +207,73 @@ public byte[] createMonthlyVoucherExcel(List vouchers) { } @Override - public byte[] createMonthlyReceiptPdf(com.chaing.domain.settlements.entity.MonthlySettlement settlement, - List vouchers) { + public byte[] createMonthlyReceiptPdf(MonthlySettlement settlement, List vouchers, String franchiseName) { try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { PdfWriter writer = new PdfWriter(baos); - com.itextpdf.kernel.pdf.PdfDocument pdf = new com.itextpdf.kernel.pdf.PdfDocument(writer); - com.itextpdf.layout.Document document = new com.itextpdf.layout.Document(pdf); - PdfFont font = getKoreanFont(); - if (font != null) { - document.setFont(font); - } - - document.add( - new com.itextpdf.layout.element.Paragraph("Monthly Settlement Receipt").setBold().setFontSize(20)); - document.add(new com.itextpdf.layout.element.Paragraph("가맹점 ID: " + settlement.getFranchiseId())); - document.add(new com.itextpdf.layout.element.Paragraph("정산 월: " + settlement.getSettlementMonth())); - document.add(new com.itextpdf.layout.element.Paragraph("--------------------------------------------")); - document.add( - new com.itextpdf.layout.element.Paragraph("1. 총 매출액: " + settlement.getTotalSaleAmount() + "원")); - document.add(new com.itextpdf.layout.element.Paragraph("2. 차감 내역:")); - document.add( - new com.itextpdf.layout.element.Paragraph(" - 발주 대금: -" + settlement.getOrderAmount() + "원")); - document.add( - new com.itextpdf.layout.element.Paragraph(" - 정산 수수료: -" + settlement.getCommissionFee() + "원")); - document.add(new com.itextpdf.layout.element.Paragraph(" - 배송비: -" + settlement.getDeliveryFee() + "원")); - document.add( - new com.itextpdf.layout.element.Paragraph(" - 본사 손실액: -" + settlement.getLossAmount() + "원")); - document.add(new com.itextpdf.layout.element.Paragraph("3. 가산 내역:")); - document.add( - new com.itextpdf.layout.element.Paragraph(" - 반품 환급액: +" + settlement.getRefundAmount() + "원")); - document.add(new com.itextpdf.layout.element.Paragraph("--------------------------------------------")); - document.add(new com.itextpdf.layout.element.Paragraph( - "최종 정산 금액: " + settlement.getFinalSettlementAmount() + "원").setBold().setFontSize(14)); - - document.add(new com.itextpdf.layout.element.Paragraph("\n[상세 전표 목록]")); - com.itextpdf.layout.element.Table table = new com.itextpdf.layout.element.Table( - com.itextpdf.layout.properties.UnitValue.createPointArray(new float[] { 100, 80, 150, 100 })); - table.addCell("날짜"); - table.addCell("유형"); - table.addCell("설명"); - table.addCell("금액"); - - for (com.chaing.domain.settlements.entity.SettlementVoucher v : vouchers) { - table.addCell(v.getOccurredAt().toLocalDate().toString()); - table.addCell(v.getVoucherType().name()); - table.addCell(v.getDescription()); - table.addCell(v.getAmount().toString() + "원"); + PdfDocument pdf = new PdfDocument(writer); + Document document = new Document(pdf, PageSize.A4); + document.setMargins(30, 30, 30, 30); + + PdfFont font = loadKoreanFont(); + document.setFont(font); + document.setFontSize(9); + + // 1. 헤더 + document.add(new Paragraph("월간 정산 영수증 (공급증)") + .setBold().setFontSize(18).setTextAlignment(TextAlignment.CENTER).setMarginBottom(10)); + + Table metaTable = new Table(UnitValue.createPercentArray(new float[]{50, 50})).useAllAvailableWidth(); + metaTable.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph("가맹점: " + franchiseName + " (ID: " + settlement.getFranchiseId() + ")")).setBorder(Border.NO_BORDER)); + metaTable.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph("No. M-" + settlement.getMonthlySettlementId() + "\n정산월: " + settlement.getSettlementMonth())).setTextAlignment(TextAlignment.RIGHT).setBorder(Border.NO_BORDER)); + document.add(metaTable.setMarginBottom(15)); + + // 2. 공급자 정보 + Table providerTable = new Table(UnitValue.createPercentArray(new float[]{15, 35, 15, 35})).useAllAvailableWidth(); + addInfoCell(providerTable, "사업자번호", "123-45-67890", true); + addInfoCell(providerTable, "상호", "(주)채잉 본사", false); + addInfoCell(providerTable, "대표자", "김채잉", true); + addInfoCell(providerTable, "주소", "서울 서초구 반포동 123", false); + document.add(providerTable.setMarginBottom(20)); + + // 3. 정산 요약 + document.add(new Paragraph("[ 정산 요약 ]").setBold().setMarginBottom(5)); + Table summaryTable = new Table(UnitValue.createPercentArray(new float[]{40, 60})).useAllAvailableWidth(); + addSummaryRow(summaryTable, "총 매출액", settlement.getTotalSaleAmount()); + addSummaryRow(summaryTable, "반품 환급액 (+)", settlement.getRefundAmount()); + addSummaryRow(summaryTable, "발주 대금 (-)", settlement.getOrderAmount().negate()); + addSummaryRow(summaryTable, "정산 수수료 (-)", settlement.getCommissionFee().negate()); + addSummaryRow(summaryTable, "배송비 (-)", settlement.getDeliveryFee().negate()); + addSummaryRow(summaryTable, "본사 손실액 (-)", settlement.getLossAmount().negate()); + addSummaryRow(summaryTable, "기타 조정", settlement.getAdjustmentAmount()); + + summaryTable.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph("최종 정산 금액").setBold()).setBackgroundColor(ColorConstants.LIGHT_GRAY).setPadding(5).setBorder(new SolidBorder(0.5f))); + summaryTable.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(formatCurrency(settlement.getFinalSettlementAmount())).setBold()).setTextAlignment(TextAlignment.RIGHT).setPadding(5).setBorder(new SolidBorder(0.5f))); + document.add(summaryTable.setMarginBottom(25)); + + // 4. 상세 내역 (Vouchers) + document.add(new Paragraph("[ 정산 항목 상세 ]").setBold().setMarginBottom(5)); + float[] detailWidths = {20, 40, 10, 15, 15}; + Table details = new Table(UnitValue.createPercentArray(detailWidths)).useAllAvailableWidth(); + addDetailHeader(details, "일자"); + + if (vouchers == null || vouchers.isEmpty()) { + details.addCell(new com.itextpdf.layout.element.Cell(1, 5).add(new Paragraph("상세 내역이 없습니다.").setTextAlignment(TextAlignment.CENTER)).setPadding(5).setBorder(new SolidBorder(0.5f))); + } else { + for (SettlementVoucher v : vouchers) { + details.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(v.getOccurredAt().toString().substring(5, 10))).setFontSize(7).setPadding(3).setBorder(new SolidBorder(0.5f))); + details.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(v.getDescription())).setFontSize(7).setPadding(3).setBorder(new SolidBorder(0.5f))); + details.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(v.getQuantity() != null ? v.getQuantity().toString() : "1")).setFontSize(7).setTextAlignment(TextAlignment.RIGHT).setPadding(3).setBorder(new SolidBorder(0.5f))); + details.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(formatCurrency(v.getUnitPrice() != null ? v.getUnitPrice() : v.getAmount()))).setFontSize(7).setTextAlignment(TextAlignment.RIGHT).setPadding(3).setBorder(new SolidBorder(0.5f))); + details.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(formatCurrency(v.getAmount()))).setFontSize(7).setTextAlignment(TextAlignment.RIGHT).setPadding(3).setBorder(new SolidBorder(0.5f))); + } } + document.add(details); + + // 5. 하단 푸터 + document.add(new Paragraph("\n본 문서는 시스템에 의해 자동 발행된 월간 정산 명세서이며, 정산 증빙용으로 사용 가능합니다.") + .setFontSize(8).setFontColor(ColorConstants.GRAY).setTextAlignment(TextAlignment.CENTER).setMarginTop(30)); + document.add(new Paragraph("(주)채잉 귀하").setBold().setTextAlignment(TextAlignment.RIGHT).setMarginTop(20)); - document.add(table); document.close(); return baos.toByteArray(); } catch (IOException e) { @@ -212,16 +282,45 @@ public byte[] createMonthlyReceiptPdf(com.chaing.domain.settlements.entity.Month } } + private void addInfoCell(Table table, String label, String value, boolean isLabel) { + table.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(label).setBold()) + .setBackgroundColor(ColorConstants.LIGHT_GRAY).setPadding(3).setBorder(new SolidBorder(0.5f))); + table.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(value)) + .setPadding(3).setBorder(new SolidBorder(0.5f))); + } + + private void addSummaryRow(Table table, String label, BigDecimal amount) { + table.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(label)).setPadding(3).setBorder(new SolidBorder(0.5f))); + table.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(formatCurrency(amount))).setTextAlignment(TextAlignment.RIGHT).setPadding(3).setBorder(new SolidBorder(0.5f))); + } + + private void addDetailHeader(Table table, String firstColumnName) { + String[] headers = {firstColumnName, "상품(항목)명", "수량", "단가", "금액"}; + for (String h : headers) { + table.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(h).setBold()) + .setBackgroundColor(ColorConstants.LIGHT_GRAY).setTextAlignment(TextAlignment.CENTER).setPadding(3).setBorder(new SolidBorder(0.5f))); + } + } + + private void addDetailRow(Table table, DailyReceiptLine line) { + table.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(line.getOccurredAt().toString().substring(11, 16))).setFontSize(7).setPadding(3).setBorder(new SolidBorder(0.5f))); + table.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(line.getDescription())).setFontSize(7).setPadding(3).setBorder(new SolidBorder(0.5f))); + table.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(line.getQuantity() != null ? line.getQuantity().toString() : "1")).setFontSize(7).setTextAlignment(TextAlignment.RIGHT).setPadding(3).setBorder(new SolidBorder(0.5f))); + table.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(formatCurrency(line.getUnitPrice() != null ? line.getUnitPrice() : line.getAmount()))).setFontSize(7).setTextAlignment(TextAlignment.RIGHT).setPadding(3).setBorder(new SolidBorder(0.5f))); + table.addCell(new com.itextpdf.layout.element.Cell().add(new Paragraph(formatCurrency(line.getAmount()))).setFontSize(7).setTextAlignment(TextAlignment.RIGHT).setPadding(3).setBorder(new SolidBorder(0.5f))); + } + + @Override public byte[] createHQSettlementDailyPdf(java.time.LocalDate date, List receipts) { try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { PdfWriter writer = new PdfWriter(baos); PdfDocument pdf = new PdfDocument(writer); Document document = new Document(pdf); - PdfFont font = getKoreanFont(); - if (font != null) { - document.setFont(font); - } + + // 1. 폰트 로드 (실패 시 IllegalStateException 발생하여 즉시 중단) + PdfFont font = loadKoreanFont(); + document.setFont(font); document.add(new Paragraph("HQ Daily Settlement Summary").setBold().setFontSize(20)); document.add(new Paragraph("정산 일자: " + date)); @@ -229,32 +328,31 @@ public byte[] createHQSettlementDailyPdf(java.time.LocalDate date, List r.getOrderAmount() != null ? r.getOrderAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalSale = receipts.stream() - .map(DailySettlementReceipt::getTotalSaleAmount) + .map(r -> r.getTotalSaleAmount() != null ? r.getTotalSaleAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalFee = receipts.stream() - .map(DailySettlementReceipt::getCommissionFee) + .map(r -> r.getCommissionFee() != null ? r.getCommissionFee() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalDelivery = receipts.stream() - .map(DailySettlementReceipt::getDeliveryFee) + .map(r -> r.getDeliveryFee() != null ? r.getDeliveryFee() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalRefund = receipts.stream() - .map(DailySettlementReceipt::getRefundAmount) + .map(r -> r.getRefundAmount() != null ? r.getRefundAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalLoss = receipts.stream() - .map(DailySettlementReceipt::getLossAmount) + .map(r -> r.getLossAmount() != null ? r.getLossAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); - // 본사 관점 최종 정산 금액 = 발주 매출 + 수수료 + 배송비 - 반품 - 손실 - BigDecimal hqTotalFinal = totalOrder.add(totalFee).add(totalDelivery) + BigDecimal hqTotalFinal = totalOrder.add(totalFee).subtract(totalDelivery) .subtract(totalRefund).subtract(totalLoss); document.add(new Paragraph("1. 전체 매출 합계(참고): " + totalSale + "원")); document.add(new Paragraph("2. 전체 발주 매출: " + totalOrder + "원")); document.add(new Paragraph("3. 전체 수수료 수익: " + totalFee + "원")); - document.add(new Paragraph("4. 전체 배송 수익: " + totalDelivery + "원")); + document.add(new Paragraph("4. 전체 배송비: -" + totalDelivery + "원")); document.add(new Paragraph("5. 전체 반품 차감액: -" + totalRefund + "원")); document.add(new Paragraph("6. 전체 본사 손실액: -" + totalLoss + "원")); document.add(new Paragraph("--------------------------------------------")); @@ -268,15 +366,18 @@ public byte[] createHQSettlementDailyPdf(java.time.LocalDate date, List s.getOrderAmount() != null ? s.getOrderAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalSale = settlements.stream() - .map(MonthlySettlement::getTotalSaleAmount) + .map(s -> s.getTotalSaleAmount() != null ? s.getTotalSaleAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalFee = settlements.stream() - .map(MonthlySettlement::getCommissionFee) + .map(s -> s.getCommissionFee() != null ? s.getCommissionFee() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalDelivery = settlements.stream() - .map(MonthlySettlement::getDeliveryFee) + .map(s -> s.getDeliveryFee() != null ? s.getDeliveryFee() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalRefund = settlements.stream() - .map(MonthlySettlement::getRefundAmount) + .map(s -> s.getRefundAmount() != null ? s.getRefundAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); BigDecimal totalLoss = settlements.stream() - .map(MonthlySettlement::getLossAmount) + .map(s -> s.getLossAmount() != null ? s.getLossAmount() : BigDecimal.ZERO) .reduce(BigDecimal.ZERO, BigDecimal::add); - // 본사 관점 최종 정산 금액 - BigDecimal hqTotalFinal = totalOrder.add(totalFee).add(totalDelivery) + BigDecimal hqTotalFinal = totalOrder.add(totalFee).subtract(totalDelivery) .subtract(totalRefund).subtract(totalLoss); document.add(new Paragraph("1. 전체 매출 합계(참고): " + totalSale + "원")); document.add(new Paragraph("2. 전체 발주 매출: " + totalOrder + "원")); document.add(new Paragraph("3. 전체 수수료 수익: " + totalFee + "원")); + document.add(new Paragraph("4. 전체 배송비: -" + totalDelivery + "원")); + document.add(new Paragraph("5. 전체 반품 차감액: -" + totalRefund + "원")); + document.add(new Paragraph("6. 전체 본사 손실액: -" + totalLoss + "원")); document.add(new Paragraph("--------------------------------------------")); document.add(new Paragraph("전체 최종 정산 합계: " + hqTotalFinal + "원").setBold().setFontSize(14)); document.add(new Paragraph("\n[가맹점별 요약 내역]")); - Table table = new Table(UnitValue.createPointArray(new float[] { 80, 150, 150 })); + Table table = new Table(UnitValue.createPointArray(new float[] { 80, 80, 80, 120, 100 })); table.addCell("가맹점 ID"); table.addCell("총 매출"); + table.addCell("수수료"); + table.addCell("배송비(차감)"); table.addCell("정산금액"); for (MonthlySettlement s : settlements) { - BigDecimal hqAmount = s.getOrderAmount() - .add(s.getCommissionFee()) - .add(s.getDeliveryFee()) - .subtract(s.getRefundAmount()) - .subtract(s.getLossAmount()); + BigDecimal order = s.getOrderAmount() != null ? s.getOrderAmount() : BigDecimal.ZERO; + BigDecimal fee = s.getCommissionFee() != null ? s.getCommissionFee() : BigDecimal.ZERO; + BigDecimal delivery = s.getDeliveryFee() != null ? s.getDeliveryFee() : BigDecimal.ZERO; + BigDecimal refund = s.getRefundAmount() != null ? s.getRefundAmount() : BigDecimal.ZERO; + BigDecimal loss = s.getLossAmount() != null ? s.getLossAmount() : BigDecimal.ZERO; + BigDecimal totalSaleRow = s.getTotalSaleAmount() != null ? s.getTotalSaleAmount() : BigDecimal.ZERO; + + BigDecimal hqAmount = order.add(fee).subtract(delivery).subtract(refund).subtract(loss); table.addCell(s.getFranchiseId().toString()); - table.addCell(s.getTotalSaleAmount().toString() + "원"); + table.addCell(totalSaleRow.toString() + "원"); + table.addCell(fee.toString() + "원"); + table.addCell(delivery.toString() + "원"); table.addCell(hqAmount.toString() + "원"); } @@ -361,20 +471,45 @@ public byte[] createHQSettlementMonthlyPdf(java.time.YearMonth month, List item.getCreatedAt() != null && item.getCreatedAt().toLocalDate().equals(date)) + .count(); + System.out.println("Sales Items: " + salesCount); + + // Orders + long orderCount = orderRepository.findAll().stream() + .filter(order -> order.getCreatedAt() != null && order.getCreatedAt().toLocalDate().equals(date)) + .count(); + System.out.println("Franchise Orders: " + orderCount); + + // Transit + long transitCount = transitRepository.findAll().stream() + .filter(t -> t.getCreatedAt() != null && t.getCreatedAt().toLocalDate().equals(date)) + .count(); + System.out.println("Transit Records: " + transitCount); + + // Returns + long returnCount = returnRepository.findAll().stream() + .filter(r -> r.getCreatedAt() != null && r.getCreatedAt().toLocalDate().equals(date)) + .count(); + System.out.println("Returns: " + returnCount); + + System.out.println("=============================================="); + } +} diff --git a/module-domain/domain-transports/src/main/java/com/chaing/domain/transports/dto/info/UpdateDeliverStatusInfo.java b/module-domain/domain-transports/src/main/java/com/chaing/domain/transports/dto/info/UpdateDeliverStatusInfo.java new file mode 100644 index 000000000..65c670b48 --- /dev/null +++ b/module-domain/domain-transports/src/main/java/com/chaing/domain/transports/dto/info/UpdateDeliverStatusInfo.java @@ -0,0 +1,13 @@ +package com.chaing.domain.transports.dto.info; + +import com.chaing.domain.transports.enums.DeliverStatus; + +public record UpdateDeliverStatusInfo( + String orderCode, + String returnCode, + DeliverStatus currentStatus +) { + public static UpdateDeliverStatusInfo create(String orderCode, String returnCode, DeliverStatus currentStatus) { + return new UpdateDeliverStatusInfo(orderCode, returnCode, currentStatus); + } +} diff --git a/module-domain/domain-transports/src/main/java/com/chaing/domain/transports/repository/TransitRepository.java b/module-domain/domain-transports/src/main/java/com/chaing/domain/transports/repository/TransitRepository.java index bbb469200..4a818a0c8 100644 --- a/module-domain/domain-transports/src/main/java/com/chaing/domain/transports/repository/TransitRepository.java +++ b/module-domain/domain-transports/src/main/java/com/chaing/domain/transports/repository/TransitRepository.java @@ -8,17 +8,22 @@ import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; -import java.util.Collection; import java.util.List; @Repository -public interface TransitRepository extends JpaRepository { +public interface TransitRepository extends JpaRepository { List findByVehicleId(Long vehicleId); + List findByFranchiseId(Long franchiseId); + @Query("select t from Transit t where t.orderCode in(:orderCodes)") List findByOrderCodeIn(@Param("orderCodes") List orderCodes); + List findAllByFranchiseIdAndStatusInAndCreatedAtBetween( + Long franchiseId, java.util.Collection statuses, + java.time.LocalDateTime start, java.time.LocalDateTime end); + @Modifying(clearAutomatically = true) @Query("update Transit t set t.status = :targetStatus where t.orderCode = :orderCode") void updateDeliverStatus(@Param("orderCode") String orderCode, @Param("targetStatus") DeliverStatus targetStatus); diff --git a/module-domain/domain-transports/src/main/java/com/chaing/domain/transports/service/InternalTransportService.java b/module-domain/domain-transports/src/main/java/com/chaing/domain/transports/service/InternalTransportService.java index eaab4614e..7448055cb 100644 --- a/module-domain/domain-transports/src/main/java/com/chaing/domain/transports/service/InternalTransportService.java +++ b/module-domain/domain-transports/src/main/java/com/chaing/domain/transports/service/InternalTransportService.java @@ -3,6 +3,7 @@ import com.chaing.domain.transports.dto.DeliveryFeeInfo; import com.chaing.domain.transports.dto.OrderInfo; import com.chaing.domain.transports.dto.info.TransportLogInfo; +import com.chaing.domain.transports.dto.info.UpdateDeliverStatusInfo; import com.chaing.domain.transports.dto.response.AvailableVehicleInfo; import com.chaing.domain.transports.entity.Transit; import com.chaing.domain.transports.entity.TransportLog; @@ -197,4 +198,32 @@ public List getTransportLog() { }) .toList(); } + + public List getDeliveryStatus(@NotEmpty List orderCodes) { + + if(orderCodes == null || orderCodes.isEmpty()) { + throw new TransportException(TransportErrorCode.TRANSPORT_NOT_FOUND); + } + + List transitInfos = reader.getTransitInfo(orderCodes); + + if(transitInfos.isEmpty()) { + throw new TransportException(TransportErrorCode.TRANSPORT_NOT_FOUND); + } + + List currentStatus = transitInfos.stream() + .map(transitInfo -> { + if(transitInfo.getReturnCode() == null || transitInfo.getReturnCode().isEmpty()) { + return UpdateDeliverStatusInfo.create(transitInfo.getOrderCode(), null, transitInfo.getStatus()); + } + return UpdateDeliverStatusInfo.create(transitInfo.getOrderCode(), transitInfo.getReturnCode(), transitInfo.getStatus()); + }) + .toList(); + + if(currentStatus.isEmpty()) { + throw new TransportException(TransportErrorCode.TRANSPORT_NOT_FOUND); + } + + return currentStatus; + } } diff --git a/module-domain/domain-users/src/main/java/com/chaing/domain/users/dto/condition/UserSearchCondition.java b/module-domain/domain-users/src/main/java/com/chaing/domain/users/dto/condition/UserSearchCondition.java index a85bee69d..3eaef210d 100644 --- a/module-domain/domain-users/src/main/java/com/chaing/domain/users/dto/condition/UserSearchCondition.java +++ b/module-domain/domain-users/src/main/java/com/chaing/domain/users/dto/condition/UserSearchCondition.java @@ -3,15 +3,19 @@ import com.chaing.domain.users.enums.UserPosition; import com.chaing.domain.users.enums.UserRole; import com.chaing.domain.users.enums.UserStatus; +import java.util.List; public record UserSearchCondition( - String loginId, - String username, - String employeeNumber, - UserRole role, - UserPosition position, - UserStatus status, - Long businessUnitId + String loginId, + String username, + String employeeNumber, + UserRole role, + UserPosition position, + UserStatus status, + Long businessUnitId, + List hqIds, + List franchiseIds, + List factoryIds ) { } diff --git a/module-domain/domain-users/src/main/java/com/chaing/domain/users/enums/UserRole.java b/module-domain/domain-users/src/main/java/com/chaing/domain/users/enums/UserRole.java index 462242755..37fc869eb 100644 --- a/module-domain/domain-users/src/main/java/com/chaing/domain/users/enums/UserRole.java +++ b/module-domain/domain-users/src/main/java/com/chaing/domain/users/enums/UserRole.java @@ -7,7 +7,6 @@ @AllArgsConstructor public enum UserRole { - ADMIN("총 관리자"), HQ("본사"), FRANCHISE("가맹점"), FACTORY("공장"); diff --git a/module-domain/domain-users/src/main/java/com/chaing/domain/users/repository/impl/UserRepositoryImpl.java b/module-domain/domain-users/src/main/java/com/chaing/domain/users/repository/impl/UserRepositoryImpl.java index d28a3da5e..6160ca829 100644 --- a/module-domain/domain-users/src/main/java/com/chaing/domain/users/repository/impl/UserRepositoryImpl.java +++ b/module-domain/domain-users/src/main/java/com/chaing/domain/users/repository/impl/UserRepositoryImpl.java @@ -33,13 +33,12 @@ public Page searchUsers(UserSearchCondition condition, Pageable pageable) List content = queryFactory .selectFrom(user) .where( - loginIdContains(condition.loginId()), - usernameContains(condition.username()), - employeeNumber(condition.employeeNumber()), + loginIdOrUsernameOrEmployeeNumber(condition.loginId(), condition.username(), condition.employeeNumber()), roleEq(condition.role()), positionEq(condition.position()), statusEq(condition.status()), - businessUnitIdEq(condition.businessUnitId()) + businessUnitIdEq(condition.businessUnitId()), + orgNameIdsMatch(condition) ) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) @@ -50,13 +49,12 @@ public Page searchUsers(UserSearchCondition condition, Pageable pageable) .select(user.count()) .from(user) .where( - loginIdContains(condition.loginId()), - usernameContains(condition.username()), - employeeNumber(condition.employeeNumber()), + loginIdOrUsernameOrEmployeeNumber(condition.loginId(), condition.username(), condition.employeeNumber()), roleEq(condition.role()), positionEq(condition.position()), statusEq(condition.status()), - businessUnitIdEq(condition.businessUnitId()) + businessUnitIdEq(condition.businessUnitId()), + orgNameIdsMatch(condition) ) .fetchOne(); @@ -64,16 +62,17 @@ public Page searchUsers(UserSearchCondition condition, Pageable pageable) return new PageImpl<>(content, pageable, totalCount); } - private BooleanExpression loginIdContains(String loginId) { - return hasText(loginId) ? QUser.user.loginId.contains(loginId) : null; - } - - private BooleanExpression usernameContains(String username) { - return hasText(username) ? QUser.user.username.contains(username) : null; - } - - private BooleanExpression employeeNumber(String employeeNumber) { - return hasText(employeeNumber) ? QUser.user.employeeNumber.contains(employeeNumber) : null; + private BooleanExpression loginIdOrUsernameOrEmployeeNumber(String loginId, String username, + String employeeNumber) { + BooleanExpression loginIdExpr = hasText(loginId) ? QUser.user.loginId.containsIgnoreCase(loginId) : null; + BooleanExpression usernameExpr = hasText(username) ? QUser.user.username.containsIgnoreCase(username) : null; + BooleanExpression empNumExpr = hasText(employeeNumber) ? QUser.user.employeeNumber.containsIgnoreCase(employeeNumber) : null; + + BooleanExpression result = null; + if (loginIdExpr != null) result = loginIdExpr; + if (usernameExpr != null) result = result != null ? result.or(usernameExpr) : usernameExpr; + if (empNumExpr != null) result = result != null ? result.or(empNumExpr) : empNumExpr; + return result; } private BooleanExpression roleEq(UserRole role) { @@ -91,4 +90,21 @@ private BooleanExpression statusEq(UserStatus status) { private BooleanExpression businessUnitIdEq(Long businessUnitId) { return businessUnitId != null ? QUser.user.businessUnitId.eq(businessUnitId) : null; } + + private BooleanExpression orgNameIdsMatch(UserSearchCondition condition) { + if (condition.hqIds() == null && condition.franchiseIds() == null && condition.factoryIds() == null) { + return null; + } + + BooleanExpression hqMatch = (condition.hqIds() != null && !condition.hqIds().isEmpty()) ? QUser.user.role.eq(UserRole.HQ).and(QUser.user.businessUnitId.in(condition.hqIds())) : null; + BooleanExpression franchiseMatch = (condition.franchiseIds() != null && !condition.franchiseIds().isEmpty()) ? QUser.user.role.eq(UserRole.FRANCHISE).and(QUser.user.businessUnitId.in(condition.franchiseIds())) : null; + BooleanExpression factoryMatch = (condition.factoryIds() != null && !condition.factoryIds().isEmpty()) ? QUser.user.role.eq(UserRole.FACTORY).and(QUser.user.businessUnitId.in(condition.factoryIds())) : null; + + BooleanExpression result = null; + if (hqMatch != null) result = hqMatch; + if (franchiseMatch != null) result = (result != null) ? result.or(franchiseMatch) : franchiseMatch; + if (factoryMatch != null) result = (result != null) ? result.or(factoryMatch) : factoryMatch; + + return result != null ? result : QUser.user.userId.isNull(); + } } diff --git a/module-domain/domain-users/src/test/java/com/chaing/domain/users/service/UserManagementServiceTests.java b/module-domain/domain-users/src/test/java/com/chaing/domain/users/service/UserManagementServiceTests.java index a8ec9062e..c1baf59da 100644 --- a/module-domain/domain-users/src/test/java/com/chaing/domain/users/service/UserManagementServiceTests.java +++ b/module-domain/domain-users/src/test/java/com/chaing/domain/users/service/UserManagementServiceTests.java @@ -113,7 +113,7 @@ void getUserList() { // given Pageable pageable = PageRequest.of(0, 10); - UserSearchCondition condition = new UserSearchCondition(null, null, null, null, null, null, null); + UserSearchCondition condition = new UserSearchCondition(null, null, null, null, null, null, null, null, null, null); List users = List.of(User.builder().userId(1L).build()); Page userPage = new PageImpl<>(users, pageable, users.size()); diff --git a/module-external-transport/src/main/java/com/chaing/external/transport/ExternalTransportService.java b/module-external-transport/src/main/java/com/chaing/external/transport/ExternalTransportService.java index 53771efdf..07ad3c0a8 100644 --- a/module-external-transport/src/main/java/com/chaing/external/transport/ExternalTransportService.java +++ b/module-external-transport/src/main/java/com/chaing/external/transport/ExternalTransportService.java @@ -1,6 +1,5 @@ package com.chaing.external.transport; -import lombok.RequiredArgsConstructor; import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; diff --git a/test-return-inspection.sql b/test-return-inspection.sql new file mode 100644 index 000000000..70af52d3f --- /dev/null +++ b/test-return-inspection.sql @@ -0,0 +1,91 @@ +-- ============================================== +-- 반품 검수 테스트 데이터 DML +-- ============================================== +drop table chaing.hqinventory, chaing.return_item, chaing.returns, chaing.franchise_order_item, chaing.franchise_order +-- 1. hq_inventory: 박스코드 동일 행 20개씩 2쌍 (총 40행) +-- - 쌍1: box_code = 'BOX-TEST-001', product_id = 1 +-- - 쌍2: box_code = 'BOX-TEST-002', product_id = 2 + +INSERT INTO hqinventory (order_id, order_item_id, serial_code, product_id, manufacture_date, status, box_code, shipped_at, received_at, is_inspected, return_item_status, created_at, updated_at, version) +VALUES +-- 쌍1: BOX-TEST-001, product_id = 1 +(1, 1, 'SER-001-01', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-02', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-03', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-04', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-05', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-06', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-07', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-08', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-09', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-10', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-11', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-12', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-13', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-14', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-15', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-16', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-17', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-18', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-19', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 1, 'SER-001-20', 1, '2026-01-15', 'RETURN_INBOUND', 'BOX-TEST-001', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +-- 쌍2: BOX-TEST-002, product_id = 2 +(1, 2, 'SER-002-01', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-02', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-03', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-04', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-05', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-06', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-07', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-08', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-09', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-10', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-11', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-12', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-13', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-14', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-15', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-16', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-17', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-18', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-19', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0), +(1, 2, 'SER-002-20', 2, '2026-01-20', 'RETURN_INBOUND', 'BOX-TEST-002', '2026-03-10 09:00:00', '2026-03-11 10:00:00', false, NULL, NOW(), NOW(), 0); + + +-- 2. franchise_order: product_id 1, 2 (hq_inventory에 있는 것) + product_id 3 (없는 것) +-- 총 수량 = 10 + 10 + 10 = 30, 단가 5000원 기준 + +INSERT INTO franchise_order (franchise_id, order_code, user_id, address, requirement, order_status, total_quantity, total_amount, delivery_date, delivery_time, cancelled_reason, created_at, updated_at, version) +VALUES (1, 'FO-TEST-001', 1, '서울시 강남구 테헤란로 123', '반품 검수 테스트용', 'COMPLETED', 30, 150000.00, '2026-03-15 09:00:00', '09:00', NULL, NOW(), NOW(), 0); + +SET @fo_id = LAST_INSERT_ID(); + +-- 3. franchise_order_item: 3개 항목 (product_id 1, 2, 3), 각 10개씩 + +INSERT INTO franchise_order_item (franchise_order_id, product_id, quantity, unit_price, total_price, created_at, updated_at) +VALUES +(@fo_id, 1, 10, 5000.00, 50000.00, NOW(), NOW()), +(@fo_id, 2, 10, 5000.00, 50000.00, NOW(), NOW()), +(@fo_id, 3, 10, 5000.00, 50000.00, NOW(), NOW()); + +SET @foi_1 = LAST_INSERT_ID(); -- product_id = 1 +SET @foi_2 = @foi_1 + 1; -- product_id = 2 +SET @foi_3 = @foi_1 + 2; -- product_id = 3 + + +-- 4. returns: franchise_order 참조, 반품 항목 2개 (product_id 1, 3) +-- 총 반품 수량 = 10 + 10 = 20 + +INSERT INTO returns (franchise_id, franchise_order_id, return_code, user_id, return_type, description, total_return_quantity, total_return_amount, return_status, created_at, updated_at, version) +VALUES (1, @fo_id, 'RT-TEST-001', 1, 'PRODUCT_DEFECT', '상품 하자로 인한 반품', 20, 100000.00, 'INSPECTING', NOW(), NOW(), 0); + +SET @ret_id = LAST_INSERT_ID(); + + +-- 5. return_item: franchise_order_item 중 2개만 참조 (product_id 1, 3) +-- box_code는 각각 고유값 + +INSERT INTO return_item (return_id, franchise_order_item_id, box_code, return_item_status, created_at, updated_at) +VALUES +(@ret_id, @foi_1, 'RET-BOX-001', 'BEFORE_INSPECTION', NOW(), NOW()), +(@ret_id, @foi_3, 'RET-BOX-002', 'BEFORE_INSPECTION', NOW(), NOW()); \ No newline at end of file diff --git a/test-sales.sql b/test-sales.sql new file mode 100644 index 000000000..b97597b9d --- /dev/null +++ b/test-sales.sql @@ -0,0 +1,78 @@ +-- ============================================== +-- Sales / SalesItem 테스트 더미 데이터 DML +-- ============================================== +-- 전제: franchise_id = 1 인 가맹점 존재 +-- product 테이블에 product_id 1~5 존재 + +-- ── 판매 1: 상품 2종, 총 5개 ── +INSERT INTO sales (franchise_id, sales_code, quantity, total_amount, is_canceled, created_at, updated_at) +VALUES (1, 'SL-20260301-001', 5, 75000.00, false, '2026-03-01 10:30:00', '2026-03-01 10:30:00'); + +SET @sales_1 = LAST_INSERT_ID(); + +INSERT INTO sales_item (sales_id, product_id, quantity, product_code, product_name, lot, unit_price, created_at, updated_at) +VALUES +(@sales_1, 1, 3, 'PRD-001', '아메리카노 원두 1kg', 'SE01-F01-L01-PRD001-BOX001', 15000.00, '2026-03-01 10:30:00', '2026-03-01 10:30:00'), +(@sales_1, 2, 2, 'PRD-002', '바닐라 시럽 500ml', 'SE01-F01-L02-PRD002-BOX001', 15000.00, '2026-03-01 10:30:00', '2026-03-01 10:30:00'); + + +-- ── 판매 2: 상품 3종, 총 8개 ── +INSERT INTO sales (franchise_id, sales_code, quantity, total_amount, is_canceled, created_at, updated_at) +VALUES (1, 'SL-20260305-001', 8, 104000.00, false, '2026-03-05 14:15:00', '2026-03-05 14:15:00'); + +SET @sales_2 = LAST_INSERT_ID(); + +INSERT INTO sales_item (sales_id, product_id, quantity, product_code, product_name, lot, unit_price, created_at, updated_at) +VALUES +(@sales_2, 1, 2, 'PRD-001', '아메리카노 원두 1kg', 'SE01-F01-L01-PRD001-BOX002', 15000.00, '2026-03-05 14:15:00', '2026-03-05 14:15:00'), +(@sales_2, 3, 4, 'PRD-003', '카라멜 소스 1L', 'SE01-F01-L03-PRD003-BOX001', 12000.00, '2026-03-05 14:15:00', '2026-03-05 14:15:00'), +(@sales_2, 4, 2, 'PRD-004', '우유 1L', 'SE01-F01-L04-PRD004-BOX001', 7000.00, '2026-03-05 14:15:00', '2026-03-05 14:15:00'); + + +-- ── 판매 3: 상품 1종, 총 10개 ── +INSERT INTO sales (franchise_id, sales_code, quantity, total_amount, is_canceled, created_at, updated_at) +VALUES (1, 'SL-20260310-001', 10, 50000.00, false, '2026-03-10 09:00:00', '2026-03-10 09:00:00'); + +SET @sales_3 = LAST_INSERT_ID(); + +INSERT INTO sales_item (sales_id, product_id, quantity, product_code, product_name, lot, unit_price, created_at, updated_at) +VALUES +(@sales_3, 5, 5, 'PRD-005', '녹차 파우더 500g', 'SE01-F01-L05-PRD005-BOX001', 5000.00, '2026-03-10 09:00:00', '2026-03-10 09:00:00'), +(@sales_3, 5, 5, 'PRD-005', '녹차 파우더 500g', 'SE01-F01-L05-PRD005-BOX002', 5000.00, '2026-03-10 09:00:00', '2026-03-10 09:00:00'); + + +-- ── 판매 4: 상품 2종, 총 6개 ── +INSERT INTO sales (franchise_id, sales_code, quantity, total_amount, is_canceled, created_at, updated_at) +VALUES (1, 'SL-20260312-001', 6, 72000.00, false, '2026-03-12 11:45:00', '2026-03-12 11:45:00'); + +SET @sales_4 = LAST_INSERT_ID(); + +INSERT INTO sales_item (sales_id, product_id, quantity, product_code, product_name, lot, unit_price, created_at, updated_at) +VALUES +(@sales_4, 2, 3, 'PRD-002', '바닐라 시럽 500ml', 'SE01-F01-L02-PRD002-BOX002', 15000.00, '2026-03-12 11:45:00', '2026-03-12 11:45:00'), +(@sales_4, 3, 3, 'PRD-003', '카라멜 소스 1L', 'SE01-F01-L03-PRD003-BOX002', 12000.00, '2026-03-12 11:45:00', '2026-03-12 11:45:00'); + + +-- ── 판매 5: 취소된 판매, 상품 1종, 총 2개 ── +INSERT INTO sales (franchise_id, sales_code, quantity, total_amount, is_canceled, created_at, updated_at) +VALUES (1, 'SL-20260315-001', 2, 30000.00, true, '2026-03-15 16:20:00', '2026-03-15 16:25:00'); + +SET @sales_5 = LAST_INSERT_ID(); + +INSERT INTO sales_item (sales_id, product_id, quantity, product_code, product_name, lot, unit_price, created_at, updated_at) +VALUES +(@sales_5, 1, 2, 'PRD-001', '아메리카노 원두 1kg', 'SE01-F01-L01-PRD001-BOX003', 15000.00, '2026-03-15 16:20:00', '2026-03-15 16:20:00'); + + +-- ── 판매 6: 상품 4종, 총 12개 ── +INSERT INTO sales (franchise_id, sales_code, quantity, total_amount, is_canceled, created_at, updated_at) +VALUES (1, 'SL-20260318-001', 12, 145000.00, false, '2026-03-18 13:00:00', '2026-03-18 13:00:00'); + +SET @sales_6 = LAST_INSERT_ID(); + +INSERT INTO sales_item (sales_id, product_id, quantity, product_code, product_name, lot, unit_price, created_at, updated_at) +VALUES +(@sales_6, 1, 3, 'PRD-001', '아메리카노 원두 1kg', 'SE01-F01-L01-PRD001-BOX004', 15000.00, '2026-03-18 13:00:00', '2026-03-18 13:00:00'), +(@sales_6, 2, 3, 'PRD-002', '바닐라 시럽 500ml', 'SE01-F01-L02-PRD002-BOX003', 15000.00, '2026-03-18 13:00:00', '2026-03-18 13:00:00'), +(@sales_6, 3, 3, 'PRD-003', '카라멜 소스 1L', 'SE01-F01-L03-PRD003-BOX003', 12000.00, '2026-03-18 13:00:00', '2026-03-18 13:00:00'), +(@sales_6, 4, 3, 'PRD-004', '우유 1L', 'SE01-F01-L04-PRD004-BOX002', 7000.00, '2026-03-18 13:00:00', '2026-03-18 13:00:00'); \ No newline at end of file