+
-- 패키지 : 언더스코어(`_`)나 대문자를 섞지 않고 소문자를 사용하여 작성합니다.
-- 클래스 : 클래스 이름은 명사나 명사절로 지으며, 대문자 카멜표기법(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’과의 차이가 커서 가독성이 높아진다.
+
+
+
+
+
-
-URL
-
-### URL
-URL은 RESTful API 설계 가이드에 따라 작성합니다.
+### ⚡ ERD
-- HTTP Method로 구분할 수 있는 get, put 등의 행위는 url에 표현하지 않습니다.
-- 마지막에 `/` 를 포함하지 않습니다.
-- `_` 대신 `-`를 사용합니다.
-- 소문자를 사용합니다.
-- 확장자는 포함하지 않습니다.
+
+ERD
+
-### 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 받아옵니다.
+
+
+
-### 2. Etc
+### ⚡API 테스트케이스
-준수해야 할 규칙은 다음과 같습니다.
+
+API 테스트케이스
+
-> 1. develop branch에서의 작업은 원칙적으로 금지합니다. 단, README 작성은 develop branch에서 수행합니다.
-> 2. commit, push, merge, pull request 등 모든 작업은 오류 없이 정상적으로 실행되는 지 확인 후 수행합니다.
+
+
+
+
+
+
+
+
+### ⚡ API 문서
+
-Branch
+API 문서
-### 1. Branch
+
+
+
+
+
+
+
+
+
+
-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
+
+- **주석**: 한 줄 주석은 `//`, 여러 줄은 `/* ... */` 사용
+- **파일 구조**: 소스파일당 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 제목에는 기능의 대표적인 설명을 적고 내용에는 세부적인 내용 및 작업 진행 상황을 작성합니다.
+
-### 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