From d5ed642c80e92207cb91f7d0a4fa6d71528f704d Mon Sep 17 00:00:00 2001 From: GA Pro Date: Fri, 15 May 2026 00:25:18 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- HANDOFF.md | 45 ++++- README.md | 54 +++++- .../accounting/CloseJournalReq.java | 15 ++ .../ContractAccountingController.java | 47 +++++ .../accounting/GenerateEntriesReq.java | 11 ++ .../income/AgentAnnualIncomeController.java | 47 +++++ .../regulatory/RegulatoryLimitController.java | 8 + .../accounting/ContractAccountingService.java | 83 +++++++++ .../income/AgentAnnualIncomeService.java | 35 ++++ .../regulatory/RegulatoryLimitService.java | 18 ++ .../src/main/resources/application-local.yml | 2 +- .../main/resources/application-trading_ai.yml | 2 +- .../java/com/ga/batch/config/BatchConfig.java | 22 ++- .../step/AccountingEntryGenerateStep.java | 73 ++++++++ .../step/YearEndIncomeAggregateStep.java | 58 ++++++ .../main/resources/application-trading_ai.yml | 2 +- .../ga/common/code/AccountCodeController.java | 56 ++++++ .../com/ga/common/code/AccountCodeMapper.java | 18 ++ .../ga/common/code/AccountCodeService.java | 50 ++++++ .../com/ga/common/code/AccountCodeVO.java | 19 ++ .../V63__설계사_연말사업소득_집계.sql | 25 +++ .../migration/V64__계약_회계분개_엔트리.sql | 24 +++ .../db/migration/V65__P5W1_test_data.sql | 122 +++++++++++++ .../db/migration/V66__account_code_마스터.sql | 26 +++ .../db/migration/V67__원천세_분기신고.sql | 23 +++ .../db/migration/V68__부가세_분기신고.sql | 22 +++ .../db/migration/V69__P5W2_test_data.sql | 87 +++++++++ .../V70__rule_audit_columns_보강.sql | 29 +++ .../migration/V71__payment_세무컬럼_보강.sql | 13 ++ .../migration/V72__P5W1_EXECUTE_권한_보강.sql | 29 +++ .../mapper/common/AccountCodeMapper.xml | 70 ++++++++ .../ContractAccountingEntryMapper.java | 36 ++++ .../income/AgentAnnualIncomeMapper.java | 25 +++ .../tax/WithholdingTaxReportMapper.java | 28 +++ .../ContractAccountingEntryExcelVO.java | 23 +++ .../ContractAccountingEntryResp.java | 28 +++ .../ContractAccountingEntrySaveReq.java | 29 +++ .../ContractAccountingEntrySearchParam.java | 20 +++ .../accounting/ContractAccountingEntryVO.java | 28 +++ .../vo/income/AgentAnnualIncomeExcelVO.java | 21 +++ .../core/vo/income/AgentAnnualIncomeResp.java | 25 +++ .../vo/income/AgentAnnualIncomeSaveReq.java | 17 ++ .../income/AgentAnnualIncomeSearchParam.java | 13 ++ .../core/vo/income/AgentAnnualIncomeVO.java | 29 +++ .../com/ga/core/vo/rule/ChargebackRuleVO.java | 5 + .../com/ga/core/vo/rule/CommissionRateVO.java | 2 + .../ga/core/vo/rule/ExceptionTypeCodeVO.java | 6 + .../com/ga/core/vo/rule/OverrideRuleVO.java | 5 + .../com/ga/core/vo/rule/PayoutRuleVO.java | 2 + .../vo/tax/WithholdingTaxReportExcelVO.java | 22 +++ .../core/vo/tax/WithholdingTaxReportResp.java | 28 +++ .../vo/tax/WithholdingTaxReportSaveReq.java | 16 ++ .../tax/WithholdingTaxReportSearchParam.java | 15 ++ .../core/vo/tax/WithholdingTaxReportVO.java | 27 +++ .../ContractAccountingEntryMapper.xml | 119 ++++++++++++ .../mapper/income/AgentAnnualIncomeMapper.xml | 80 +++++++++ .../resources/mapper/settle/PaymentMapper.xml | 47 +++++ ga-frontend/src/App.tsx | 16 ++ ga-frontend/src/api/accounting.ts | 48 +++++ ga-frontend/src/api/income.ts | 44 +++++ ga-frontend/src/api/regulatory.ts | 2 + ga-frontend/src/api/vat.ts | 47 +++++ ga-frontend/src/api/withholdingTax.ts | 50 ++++++ .../src/components/common/DataGrid.tsx | 9 +- .../accounting/AccountingJournalList.tsx | 169 ++++++++++++++++++ .../pages/income/AgentAnnualIncomeList.tsx | 115 ++++++++++++ .../src/pages/rule/RegulatoryLimitList.tsx | 19 +- ga-frontend/src/pages/tax/VatReportList.tsx | 154 ++++++++++++++++ .../pages/tax/WithholdingTaxReportList.tsx | 156 ++++++++++++++++ 69 files changed, 2634 insertions(+), 26 deletions(-) create mode 100644 ga-api/src/main/java/com/ga/api/controller/accounting/CloseJournalReq.java create mode 100644 ga-api/src/main/java/com/ga/api/controller/accounting/ContractAccountingController.java create mode 100644 ga-api/src/main/java/com/ga/api/controller/accounting/GenerateEntriesReq.java create mode 100644 ga-api/src/main/java/com/ga/api/controller/income/AgentAnnualIncomeController.java create mode 100644 ga-api/src/main/java/com/ga/api/service/accounting/ContractAccountingService.java create mode 100644 ga-api/src/main/java/com/ga/api/service/income/AgentAnnualIncomeService.java create mode 100644 ga-batch/src/main/java/com/ga/batch/settlement/step/AccountingEntryGenerateStep.java create mode 100644 ga-batch/src/main/java/com/ga/batch/settlement/step/YearEndIncomeAggregateStep.java create mode 100644 ga-common/src/main/java/com/ga/common/code/AccountCodeController.java create mode 100644 ga-common/src/main/java/com/ga/common/code/AccountCodeMapper.java create mode 100644 ga-common/src/main/java/com/ga/common/code/AccountCodeService.java create mode 100644 ga-common/src/main/java/com/ga/common/code/AccountCodeVO.java create mode 100644 ga-common/src/main/resources/db/migration/V63__설계사_연말사업소득_집계.sql create mode 100644 ga-common/src/main/resources/db/migration/V64__계약_회계분개_엔트리.sql create mode 100644 ga-common/src/main/resources/db/migration/V65__P5W1_test_data.sql create mode 100644 ga-common/src/main/resources/db/migration/V66__account_code_마스터.sql create mode 100644 ga-common/src/main/resources/db/migration/V67__원천세_분기신고.sql create mode 100644 ga-common/src/main/resources/db/migration/V68__부가세_분기신고.sql create mode 100644 ga-common/src/main/resources/db/migration/V69__P5W2_test_data.sql create mode 100644 ga-common/src/main/resources/db/migration/V70__rule_audit_columns_보강.sql create mode 100644 ga-common/src/main/resources/db/migration/V71__payment_세무컬럼_보강.sql create mode 100644 ga-common/src/main/resources/db/migration/V72__P5W1_EXECUTE_권한_보강.sql create mode 100644 ga-common/src/main/resources/mapper/common/AccountCodeMapper.xml create mode 100644 ga-core/src/main/java/com/ga/core/mapper/accounting/ContractAccountingEntryMapper.java create mode 100644 ga-core/src/main/java/com/ga/core/mapper/income/AgentAnnualIncomeMapper.java create mode 100644 ga-core/src/main/java/com/ga/core/mapper/tax/WithholdingTaxReportMapper.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntryExcelVO.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntryResp.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntrySaveReq.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntrySearchParam.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntryVO.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeExcelVO.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeResp.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeSaveReq.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeSearchParam.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeVO.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportExcelVO.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportResp.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportSaveReq.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportSearchParam.java create mode 100644 ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportVO.java create mode 100644 ga-core/src/main/resources/mapper/accounting/ContractAccountingEntryMapper.xml create mode 100644 ga-core/src/main/resources/mapper/income/AgentAnnualIncomeMapper.xml create mode 100644 ga-frontend/src/api/accounting.ts create mode 100644 ga-frontend/src/api/income.ts create mode 100644 ga-frontend/src/api/vat.ts create mode 100644 ga-frontend/src/api/withholdingTax.ts create mode 100644 ga-frontend/src/pages/accounting/AccountingJournalList.tsx create mode 100644 ga-frontend/src/pages/income/AgentAnnualIncomeList.tsx create mode 100644 ga-frontend/src/pages/tax/VatReportList.tsx create mode 100644 ga-frontend/src/pages/tax/WithholdingTaxReportList.tsx diff --git a/HANDOFF.md b/HANDOFF.md index f23532f..c7eb93f 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -118,14 +118,53 @@ ### 3-6. P1 정산 통합 (batch-engineer 완료) - ga-batch 8 Step에 TaxCalculator / DeductionService / RegulatoryLimitChecker / InstallmentPlanGenerator / ChargebackCalculator 통합 완료 -## 4. 잔여 작업 (P5/P6) +## 3-7. P5 Wave 1 — agent + contract 회계 진입점 (V63~V66) -### P5 — 회계/분석 보강 (다음 라운드) +| 항목 | 마이그레이션 | 상태 | +|---|---|---| +| agent_annual_income 테이블 | V63 | 완료 | +| contract_accounting_entry 테이블 | V64 | 완료 | +| P5-W1 테스트 데이터 + 메뉴 INSERT (ANNUAL_INCOME / ACCOUNTING_JOURNAL) | V65 | 완료 | +| account_code 마스터 + 4건 초기 데이터 | V66 | 완료 | +| rule 감사 컬럼 보강 (commission_rate/payout_rule/override_rule 등) | V70 | 완료 | +| payment 세무 컬럼 보강 (income_tax_amount/local_tax_amount/vat_amount/net_amount) | V71 | 완료 | + +핵심 산출물: +- `agent_annual_income` (agentId+settleYear 복합PK, 연도별 사업소득 집계) +- `contract_accounting_entry` (분개 엔트리, debit/credit 계정코드 + journalNo 마감) +- `account_code` 마스터 (account_type DEBIT/CREDIT/BOTH 구분, 초기 4건: 8350/2530/1130/4110) +- `AgentAnnualIncomeService` + `AgentAnnualIncomeController` (`/api/agent-annual-incomes`) +- `ContractAccountingService` + `ContractAccountingController` (`/api/accounting-entries`) +- `AccountCodeService` + `AccountCodeController` (`/api/common/account-codes`) +- `YearEndIncomeAggregateStep` (Step 10, 12월 마감 시만 실행, settleMonth.endsWith("12") 조건) +- `AccountingEntryGenerateStep` (Step 9, 매월 정산 후 분개 생성, ledger UNION 기반 contract_id 확보, journal_no=NULL 미전기) +- 프론트 화면: `AgentAnnualIncomeList` (`/commission/annual-income`) + `AccountingJournalList` (`/commission/accounting-journal`) + +통합 검증 결과 (2026-05-15): +- Flyway: V63/V64/V65/V66/V70/V71 적용 확인 (현재 ga schema V71) +- GET /api/agent-annual-incomes → 200 OK +- POST /api/agent-annual-incomes/regenerate?year=2025 → 200 OK, affected=0 (payment 데이터 없음, 정상) +- GET /api/accounting-entries → 200 OK +- GET /api/common/account-codes?accountType=DEBIT → 200 OK, 2 items + +보정 사항 (통합 검증 중 발견): +- App.tsx 라우트와 V65 menu_path 모두 `/commission/annual-income`, `/commission/accounting-journal`로 일치 +- ANNUAL_INCOME EXECUTE 권한 누락 → DB 직접 INSERT로 보정 +- payment 세무 컬럼 미적용 (V23 체크섬 충돌 이력) → V71로 idempotent 보강 + +운영 DB 적용: ga-api 재시작 → Flyway V18~V71 일괄 자동 적용. + +## 4. 잔여 작업 (P5-W2/W3 + P6) + +### P5-W2 — rule + payout_rule + payment (V67~V69 예정) | 항목 | 설명 | |---|---| -| 회계 전표 자동 분개 | 수수료 지급액 → 분개 엔트리 자동 생성 | | 원천세 신고 자료 | 분기별 사업소득세 집계 + 국세청 포맷 | | 부가세 신고 자료 | tax_invoice 기반 매출/매입 집계 | + +### P5-W3 — 결산 / 이상치 / 연말 명세서 +| 항목 | 설명 | +|---|---| | 결산 | period_close 와 별개의 월/분기/연 회계 마감 | | 이상치·예측 KPI | 13M/25M 유지율 이상 탐지 + 차월 예측 | | 연말 지급명세서 | 설계사 연 사업소득 세무 자료 PDF | diff --git a/README.md b/README.md index f4ab1b5..0a7b330 100644 --- a/README.md +++ b/README.md @@ -44,8 +44,8 @@ V1~V17 마이그레이션 + 테스트 데이터(보험사 5 / 상품 20 / 설계 `192.168.0.60:55432/trading_ai/ga` 스키마에 적용되어 있습니다. > **주의**: 운영 DB 에는 현재 V1~V17(또는 V22 까지)만 적용되어 있습니다. -> V18~V55 는 ga-api 를 재시작하면 Flyway 가 자동으로 순서대로 적용합니다. -> 재시작 전까지 P2~P4 신규 도메인 API 는 테이블이 없어 동작하지 않습니다. +> V18~V61 은 ga-api 를 재시작하면 Flyway 가 자동으로 순서대로 적용합니다. +> 재시작 전까지 P2~P5-W1 신규 도메인 API 는 테이블이 없어 동작하지 않습니다. #### 1) Backend (ga-api) 실행 @@ -111,6 +111,8 @@ PW : admin1234! | 정산 결과 | `/settle` | 월 요약 + 확정/보류 | | 정산 배치 | `/settle/batch` | 배치 실행 + 진행률 폴링 | | 지급 관리 | `/payments` | 지급 상태 추적 | +| 연말 사업소득 명세서 | `/settle/annual-income` | 설계사별 연간 사업소득 집계 + 재집계 (P5-W1) | +| 분개장 | `/settle/accounting-journal` | 회계 자동분개 엔트리 + 마감 처리 (P5-W1) | | 사용자 관리 | `/system/users` | 비번 초기화/잠금 해제 | | 역할/권한 | `/system/roles` | 권한 매트릭스 (메뉴 × 6권한) | | 공통코드 | `/system/codes` | 그룹별 코드 조회 | @@ -274,7 +276,23 @@ UPDATE ga.users SET password = '$2b$10$...' WHERE login_id = 'admin'; - **첨부파일 (P4-B-8)**: attachment — 도메인 무관 공통 첨부 (소속/경로/타입) - **출금신청 (P4-B-9)**: withdraw_request — 결재 + 펌뱅킹 송신 + 결과 반영 -### 정산 배치 8 Step +### P5 Wave 1 — 회계 진입점 (V63~V66) +- **연말 사업소득 명세서** (`/settle/annual-income`): 설계사별 귀속연도 사업소득 집계, 재집계 버튼 + - `GET /api/agent-annual-incomes` — 목록 조회 (settleYear/agentId/orgId 필터) + - `GET /api/agent-annual-incomes/{agentId}/{settleYear}` — 단건 상세 + - `POST /api/agent-annual-incomes/regenerate?year=YYYY` — 연도 전체 재집계 (EXECUTE 권한) +- **분개장** (`/settle/accounting-journal`): 수수료 지급 자동분개 엔트리, 마감(journalNo 채번) 기능 + - `GET /api/accounting-entries` — 분개 목록 (settleMonth/journalNo/status 필터) + - `POST /api/accounting-entries/generate` — 정산월 기준 분개 자동생성 (settleMonth or paymentIds) + - `PUT /api/accounting-entries/close` — 분개 마감 처리 (entryIds + journalNo) +- **계정코드 마스터** (공통 드롭다운용) + - `GET /api/common/account-codes?accountType=DEBIT|CREDIT|BOTH` — 활성 계정코드 목록 + - `GET /api/common/account-codes/all` — 전체 (시스템관리용) + - `POST /api/common/account-codes` — 등록 + - `PUT /api/common/account-codes/{accountCode}` — 수정 + - `DELETE /api/common/account-codes/{accountCode}` — 삭제 + +### 정산 배치 10 Step 1. **receiveData** — 보험사별 DataReceiver → raw_commission_data 적재 2. **transformData** — MappingEngine 으로 raw → 표준 원장 (TRIM/UPPER/DATE/DIVIDE/CODE_MAP) 3. **reconcile** — 보험사 통보액 vs 시스템 계산액 대사 @@ -283,6 +301,8 @@ UPDATE ga.users SET password = '$2b$10$...' WHERE login_id = 'admin'; 6. **chargebackException** — 실효 계약 환수 자동생성 7. **calcOverride** — 조직 트리 상향 탐색 → 상위 직급에 분배 8. **aggregate** — 설계사별 통합 집계 → settle_master UPSERT (3.3% 세금) +9. **accountingEntryGenerate** — recruit_ledger+maintain_ledger → contract_accounting_entry INSERT +10. **yearEndIncomeAggregate** — 12월 마감 시만 실행: payment 합산 → agent_annual_income UPSERT --- @@ -382,9 +402,25 @@ public class FooController { | V53 | 출금신청 — withdraw_request | | V54 | P4 메뉴 INSERT (P4-A + P4-B 14개 메뉴) | | V55 | P4 공통코드 추가 (approval_type, complaint_type, license_type 등) | +| V56 | agent 세무 컬럼 보강 (tax_type/business_no/vat_type — V23 체크섬 충돌 보정) | +| V57 | admin 계정 초기화 | +| V58 | 1200%룰 메뉴 권한 | +| V59 | 신규 메뉴 권한 | +| V60 | statement 메뉴 권한 | +| V61 | installment_plan 메뉴 권한 | +| V62 | 추가 메뉴 권한 | +| V63 | P5-W1: agent_annual_income 테이블 (설계사 연말 사업소득 집계) | +| V64 | P5-W1: contract_accounting_entry 테이블 (회계 분개 엔트리) | +| V65 | P5-W1: 테스트 데이터 + 메뉴 INSERT (ANNUAL_INCOME / ACCOUNTING_JOURNAL) | +| V66 | P5-W1: account_code 마스터 + 초기 4건 (8350/2530/1130/4110) | +| V67 | P5-W2: withholding_tax_report 테이블 (원천세 분기 신고) | +| V68 | P5-W2: vat_report 테이블 (부가세 분기 신고) | +| V69 | P5-W2: 테스트 데이터 + 메뉴 INSERT (원천세/부가세 신고) | +| V70 | rule 도메인 감사 컬럼 보강 (commission_rate/payout_rule/override_rule 등) | +| V71 | payment 세무 컬럼 보강 (income_tax_amount/local_tax_amount/vat_amount/net_amount) | -> 운영 DB (192.168.0.60) 에는 현재 V17(또는 V22) 까지만 적용되어 있습니다. -> **ga-api 재시작 시** Flyway 가 V18~V55 를 자동으로 순차 적용합니다. +> 운영 DB (192.168.0.60) 현재 V71 까지 적용 완료. +> **ga-api 재시작 시** Flyway 가 신규 마이그레이션을 자동으로 순차 적용합니다. --- @@ -397,7 +433,7 @@ public class FooController { - [x] **ga-batch** — MonthlySettlementJob 8 Step (실제 동작 로직 포함) - [x] **Docker** — docker-compose + multi-stage Dockerfile + nginx - [x] **ga-frontend** — 50+ 페이지 (대시보드/설계사/계약/원장/정산/지급/배치/KPI/수수료/시스템관리) -- [x] **DB 마이그레이션** — V1~V55 (55개 파일, ga-common/src/main/resources/db/migration) +- [x] **DB 마이그레이션** — V1~V71 (71개 파일, ga-common/src/main/resources/db/migration) - [x] **코드 품질** — Enum/DTO/MapStruct/readOnly 트랜잭션 일괄 정비 - [x] **P0 세무·공제·명세서** — TaxCalculator / DeductionService / ExcelStatementGenerator - [x] **P1 1200%룰·분급·차등환수** — RegulatoryLimitChecker / InstallmentPlanGenerator / ChargebackCalculator @@ -405,10 +441,12 @@ public class FooController { - [x] **P3 KPI 대시보드** — Materialized View 4종 + Service + 화면 - [x] **P4-A 수수료 종류 보강** — commission_master 통합 / 수금 / 갱신 / 부활 / 유지보수수당 / 오버라이드원장 - [x] **P4-B 운영 도메인** — 위촉계약 / 자격관리 / 보수교육 / 계약변경 / 민원 / 결재워크플로우 / 공지·알림 / 첨부파일 / 출금신청 -- [x] **운영 DB** — 192.168.0.60:55432/trading_ai/ga (V17까지 적용, V18~V55는 재시작 시 자동 적용) +- [x] **P5-W1 회계 진입점** — agent_annual_income / contract_accounting_entry / account_code (V63~V66, 통합검증 완료) +- [x] **운영 DB** — 192.168.0.60:55432/trading_ai/ga (V71 까지 적용 완료) - [ ] **단위/통합 테스트** — 미작성 (SqlSessionFactory 환경 문제로 보류) - [ ] **MappingEngine 정밀화** — 일부 변환 규칙은 단순화된 형태 -- [ ] **P5 회계/분석** — 전표 분개 / 원천세·부가세 신고 / 결산 / 이상치·예측 / 연말 지급명세서 +- [ ] **P5-W2 원천세·부가세 신고** — withholding_tax_report / vat_report (V67~V69 스키마 완료, Service+Controller 미완) +- [ ] **P5-W3 결산 / 이상치 / 연말 지급명세서** — 미착수 - [ ] **P6 외부 연동** — 펌뱅킹 실연동 / SMS·카카오 / 모바일 셀프 조회 --- diff --git a/ga-api/src/main/java/com/ga/api/controller/accounting/CloseJournalReq.java b/ga-api/src/main/java/com/ga/api/controller/accounting/CloseJournalReq.java new file mode 100644 index 0000000..5402d5b --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/accounting/CloseJournalReq.java @@ -0,0 +1,15 @@ +package com.ga.api.controller.accounting; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +import java.util.List; + +@Data +public class CloseJournalReq { + @NotEmpty + private List entryIds; + @NotBlank + private String journalNo; +} diff --git a/ga-api/src/main/java/com/ga/api/controller/accounting/ContractAccountingController.java b/ga-api/src/main/java/com/ga/api/controller/accounting/ContractAccountingController.java new file mode 100644 index 0000000..2f678fe --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/accounting/ContractAccountingController.java @@ -0,0 +1,47 @@ +package com.ga.api.controller.accounting; + +import com.ga.api.service.accounting.ContractAccountingService; +import com.ga.common.annotation.DataChangeLog; +import com.ga.common.annotation.RequirePermission; +import com.ga.common.model.ApiResponse; +import com.ga.common.model.PageResponse; +import com.ga.core.vo.accounting.ContractAccountingEntryResp; +import com.ga.core.vo.accounting.ContractAccountingEntrySearchParam; +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.web.bind.annotation.*; + +@Tag(name = "계약 회계 분개") +@RestController +@RequestMapping("/api/accounting-entries") +@RequiredArgsConstructor +public class ContractAccountingController { + + private final ContractAccountingService service; + + @GetMapping + @Operation(summary = "분개 엔트리 목록 조회") + @RequirePermission(menu = "ACCOUNTING_JOURNAL", perm = "READ") + public ApiResponse> list( + @ModelAttribute ContractAccountingEntrySearchParam param) { + return ApiResponse.ok(service.list(param)); + } + + @PostMapping("/generate") + @Operation(summary = "자동 분개 생성 (paymentIds 또는 settleMonth)") + @RequirePermission(menu = "ACCOUNTING_JOURNAL", perm = "CREATE") + @DataChangeLog(menu = "ACCOUNTING_JOURNAL", table = "contract_accounting_entry") + public ApiResponse generate(@RequestBody GenerateEntriesReq req) { + return ApiResponse.ok(service.generateFromPayments(req)); + } + + @PutMapping("/close") + @Operation(summary = "분개 마감 처리") + @RequirePermission(menu = "ACCOUNTING_JOURNAL", perm = "UPDATE") + @DataChangeLog(menu = "ACCOUNTING_JOURNAL", table = "contract_accounting_entry") + public ApiResponse close(@Valid @RequestBody CloseJournalReq req) { + return ApiResponse.ok(service.closeJournal(req)); + } +} diff --git a/ga-api/src/main/java/com/ga/api/controller/accounting/GenerateEntriesReq.java b/ga-api/src/main/java/com/ga/api/controller/accounting/GenerateEntriesReq.java new file mode 100644 index 0000000..272242a --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/accounting/GenerateEntriesReq.java @@ -0,0 +1,11 @@ +package com.ga.api.controller.accounting; + +import lombok.Data; + +import java.util.List; + +@Data +public class GenerateEntriesReq { + private List paymentIds; + private String settleMonth; +} diff --git a/ga-api/src/main/java/com/ga/api/controller/income/AgentAnnualIncomeController.java b/ga-api/src/main/java/com/ga/api/controller/income/AgentAnnualIncomeController.java new file mode 100644 index 0000000..e86402a --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/controller/income/AgentAnnualIncomeController.java @@ -0,0 +1,47 @@ +package com.ga.api.controller.income; + +import com.ga.api.service.income.AgentAnnualIncomeService; +import com.ga.common.annotation.DataChangeLog; +import com.ga.common.annotation.RequirePermission; +import com.ga.common.model.ApiResponse; +import com.ga.common.model.PageResponse; +import com.ga.core.vo.income.AgentAnnualIncomeResp; +import com.ga.core.vo.income.AgentAnnualIncomeSearchParam; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "연간 사업소득") +@RestController +@RequestMapping("/api/agent-annual-incomes") +@RequiredArgsConstructor +public class AgentAnnualIncomeController { + + private final AgentAnnualIncomeService service; + + @GetMapping + @Operation(summary = "연간소득 목록 조회") + @RequirePermission(menu = "ANNUAL_INCOME", perm = "READ") + public ApiResponse> list( + @ModelAttribute AgentAnnualIncomeSearchParam param) { + return ApiResponse.ok(service.list(param)); + } + + @GetMapping("/{agentId}/{settleYear}") + @Operation(summary = "연간소득 상세 조회") + @RequirePermission(menu = "ANNUAL_INCOME", perm = "READ") + public ApiResponse detail( + @PathVariable Long agentId, + @PathVariable int settleYear) { + return ApiResponse.ok(service.detail(agentId, settleYear)); + } + + @PostMapping("/regenerate") + @Operation(summary = "연도 전체 재집계") + @RequirePermission(menu = "ANNUAL_INCOME", perm = "EXECUTE") + @DataChangeLog(menu = "ANNUAL_INCOME", table = "agent_annual_income") + public ApiResponse regenerate(@RequestParam int year) { + return ApiResponse.ok(service.regenerate(year)); + } +} diff --git a/ga-api/src/main/java/com/ga/api/controller/regulatory/RegulatoryLimitController.java b/ga-api/src/main/java/com/ga/api/controller/regulatory/RegulatoryLimitController.java index d93c6d0..2a82b87 100644 --- a/ga-api/src/main/java/com/ga/api/controller/regulatory/RegulatoryLimitController.java +++ b/ga-api/src/main/java/com/ga/api/controller/regulatory/RegulatoryLimitController.java @@ -63,6 +63,14 @@ public class RegulatoryLimitController { return ApiResponse.ok(); } + @PutMapping("/{limitId}/activate") + @RequirePermission(menu = "REGULATORY_LIMIT", perm = "UPDATE") + @DataChangeLog(menu = "REGULATORY_LIMIT", table = "regulatory_limit") + public ApiResponse activate(@PathVariable Long limitId) { + limitService.activate(limitId); + return ApiResponse.ok(); + } + @PostMapping("/check-contract") @RequirePermission(menu = "REGULATORY_LIMIT", perm = "READ") public ApiResponse checkContract(@Valid @RequestBody ContractLimitCheckReq req) { diff --git a/ga-api/src/main/java/com/ga/api/service/accounting/ContractAccountingService.java b/ga-api/src/main/java/com/ga/api/service/accounting/ContractAccountingService.java new file mode 100644 index 0000000..033742e --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/accounting/ContractAccountingService.java @@ -0,0 +1,83 @@ +package com.ga.api.service.accounting; + +import com.ga.api.controller.accounting.CloseJournalReq; +import com.ga.api.controller.accounting.GenerateEntriesReq; +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import com.ga.common.model.PageResponse; +import com.ga.core.mapper.accounting.ContractAccountingEntryMapper; +import com.ga.core.mapper.settle.PaymentMapper; +import com.ga.core.vo.accounting.ContractAccountingEntryResp; +import com.ga.core.vo.accounting.ContractAccountingEntrySearchParam; +import com.ga.core.vo.accounting.ContractAccountingEntryVO; +import com.ga.core.vo.settle.PaymentVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ContractAccountingService { + + private static final String DEBIT_ACCOUNT = "8350"; // 지급수수료 + private static final String CREDIT_ACCOUNT = "2530"; // 미지급금 + + private final ContractAccountingEntryMapper entryMapper; + private final PaymentMapper paymentMapper; + + public PageResponse list(ContractAccountingEntrySearchParam param) { + param.startPage(); + return PageResponse.of(entryMapper.selectList(param)); + } + + @Transactional + public int generateFromPayments(GenerateEntriesReq req) { + List payments = resolvePayments(req); + if (payments.isEmpty()) throw new BizException(ErrorCode.NOT_FOUND); + + List entries = new ArrayList<>(payments.size()); + for (PaymentVO p : payments) { + entries.add(buildEntry(p)); + } + return entryMapper.insertBatch(entries); + } + + @Transactional + public int closeJournal(CloseJournalReq req) { + if (req.getEntryIds() == null || req.getEntryIds().isEmpty()) { + throw new BizException(ErrorCode.INVALID_PARAMETER); + } + return entryMapper.closeJournal(req.getEntryIds(), req.getJournalNo()); + } + + private List resolvePayments(GenerateEntriesReq req) { + if (req.getPaymentIds() != null && !req.getPaymentIds().isEmpty()) { + List result = new ArrayList<>(); + for (Long id : req.getPaymentIds()) { + PaymentVO p = paymentMapper.selectById(id); + if (p != null) result.add(p); + } + return result; + } + if (req.getSettleMonth() != null && !req.getSettleMonth().isBlank()) { + return paymentMapper.selectBySettleMonth(req.getSettleMonth()); + } + return List.of(); + } + + private ContractAccountingEntryVO buildEntry(PaymentVO p) { + ContractAccountingEntryVO vo = new ContractAccountingEntryVO(); + vo.setPaymentId(p.getPaymentId()); + vo.setEntryDate(LocalDate.now()); + vo.setDebitAccount(DEBIT_ACCOUNT); + vo.setCreditAccount(CREDIT_ACCOUNT); + vo.setAmount(p.getNetAmount() != null ? p.getNetAmount() : p.getPayAmount()); + vo.setDescription("commission payment " + p.getPaymentId()); + return vo; + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/income/AgentAnnualIncomeService.java b/ga-api/src/main/java/com/ga/api/service/income/AgentAnnualIncomeService.java new file mode 100644 index 0000000..2f3580a --- /dev/null +++ b/ga-api/src/main/java/com/ga/api/service/income/AgentAnnualIncomeService.java @@ -0,0 +1,35 @@ +package com.ga.api.service.income; + +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import com.ga.common.model.PageResponse; +import com.ga.core.mapper.income.AgentAnnualIncomeMapper; +import com.ga.core.vo.income.AgentAnnualIncomeResp; +import com.ga.core.vo.income.AgentAnnualIncomeSearchParam; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AgentAnnualIncomeService { + + private final AgentAnnualIncomeMapper mapper; + + public PageResponse list(AgentAnnualIncomeSearchParam param) { + param.startPage(); + return PageResponse.of(mapper.selectList(param)); + } + + public AgentAnnualIncomeResp detail(Long agentId, int settleYear) { + AgentAnnualIncomeResp resp = mapper.selectDetailById(agentId, settleYear); + if (resp == null) throw new BizException(ErrorCode.NOT_FOUND); + return resp; + } + + @Transactional + public int regenerate(int year) { + return mapper.upsertAggregate(year); + } +} diff --git a/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitService.java b/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitService.java index b2dad6f..f9a7154 100644 --- a/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitService.java +++ b/ga-api/src/main/java/com/ga/api/service/regulatory/RegulatoryLimitService.java @@ -79,6 +79,24 @@ public class RegulatoryLimitService { limitMapper.updateStatus(limitId, false); } + @Transactional + public void activate(Long limitId) { + RegulatoryLimitVO existing = limitMapper.selectById(limitId); + if (existing == null) { + throw new BizException(ErrorCode.NOT_FOUND); + } + if (Boolean.TRUE.equals(existing.getIsActive())) { + throw new BizException(ErrorCode.INVALID_PARAMETER); + } + String today = LocalDate.now().format(DATE_FMT); + RegulatoryLimitVO duplicate = limitMapper.selectActiveByType( + existing.getLimitType(), existing.getProductCategory(), today); + if (duplicate != null) { + throw new BizException(ErrorCode.DUPLICATE_DATA); + } + limitMapper.updateStatus(limitId, true); + } + @Transactional(readOnly = true) public RegulatoryLimitVO selectActiveByType(String limitType, String productCategory, String baseDate) { return limitMapper.selectActiveByType(limitType, productCategory, baseDate); diff --git a/ga-api/src/main/resources/application-local.yml b/ga-api/src/main/resources/application-local.yml index bb49076..4bad099 100644 --- a/ga-api/src/main/resources/application-local.yml +++ b/ga-api/src/main/resources/application-local.yml @@ -4,7 +4,7 @@ ga: spring: datasource: - url: jdbc:postgresql://125.241.236.203:55432/ga + url: jdbc:postgresql://192.168.0.60:55432/ga username: ga password: ga cache: diff --git a/ga-api/src/main/resources/application-trading_ai.yml b/ga-api/src/main/resources/application-trading_ai.yml index 5a3756f..e0dec4d 100644 --- a/ga-api/src/main/resources/application-trading_ai.yml +++ b/ga-api/src/main/resources/application-trading_ai.yml @@ -3,7 +3,7 @@ spring: datasource: - url: jdbc:postgresql://125.241.236.203:55432/trading_ai?currentSchema=ga + url: jdbc:postgresql://192.168.0.60:55432/trading_ai?currentSchema=ga username: ${DB_USER:kyu} password: ${DB_PASSWORD:7895123} driver-class-name: org.postgresql.Driver diff --git a/ga-batch/src/main/java/com/ga/batch/config/BatchConfig.java b/ga-batch/src/main/java/com/ga/batch/config/BatchConfig.java index 428392d..8037ad5 100644 --- a/ga-batch/src/main/java/com/ga/batch/config/BatchConfig.java +++ b/ga-batch/src/main/java/com/ga/batch/config/BatchConfig.java @@ -38,16 +38,20 @@ public class BatchConfig { CalcMaintainStep step5, ChargebackExceptionStep step6, CalcOverrideStep step7, - AggregateStep step8) { + AggregateStep step8, + AccountingEntryGenerateStep step9, + YearEndIncomeAggregateStep step10) { return new JobBuilder("MONTHLY_SETTLEMENT", jobRepository) - .start(taskletStep("receiveData", step1, jobRepository, tx)) - .next(taskletStep("transformData", step2, jobRepository, tx)) - .next(taskletStep("reconcile", step3, jobRepository, tx)) - .next(taskletStep("calcRecruit", step4, jobRepository, tx)) - .next(taskletStep("calcMaintain", step5, jobRepository, tx)) - .next(taskletStep("chargebackException", step6, jobRepository, tx)) - .next(taskletStep("calcOverride", step7, jobRepository, tx)) - .next(taskletStep("aggregate", step8, jobRepository, tx)) + .start(taskletStep("receiveData", step1, jobRepository, tx)) + .next(taskletStep("transformData", step2, jobRepository, tx)) + .next(taskletStep("reconcile", step3, jobRepository, tx)) + .next(taskletStep("calcRecruit", step4, jobRepository, tx)) + .next(taskletStep("calcMaintain", step5, jobRepository, tx)) + .next(taskletStep("chargebackException", step6, jobRepository, tx)) + .next(taskletStep("calcOverride", step7, jobRepository, tx)) + .next(taskletStep("aggregate", step8, jobRepository, tx)) + .next(taskletStep("accountingEntryGenerate", step9, jobRepository, tx)) + .next(taskletStep("yearEndIncomeAggregate", step10, jobRepository, tx)) .build(); } diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/AccountingEntryGenerateStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/AccountingEntryGenerateStep.java new file mode 100644 index 0000000..f0922fd --- /dev/null +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/AccountingEntryGenerateStep.java @@ -0,0 +1,73 @@ +package com.ga.batch.settlement.step; + +import com.ga.batch.settlement.SettlementContext; +import com.ga.core.mapper.accounting.ContractAccountingEntryMapper; +import com.ga.core.vo.accounting.ContractAccountingEntryVO; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +/** + * Step 8b — accountingEntryGenerate: 회계 자동분개 + * + * 정산월 기준 recruit_ledger + maintain_ledger → contract_accounting_entry INSERT. + * - 차변(8350 지급수수료) / 대변(2530 미지급금) 한 쌍을 단일 행으로 저장. + * - journal_no는 NULL(미전기). 마감 UI에서 채번. + * - 이미 분개된 payment는 existsByPaymentId로 중복 방어. + */ +@Slf4j +@Component +@JobScope +@RequiredArgsConstructor +public class AccountingEntryGenerateStep implements Tasklet { + + private final SettlementContext ctx; + private final ContractAccountingEntryMapper accountingMapper; + + private static final int BATCH_SIZE = 500; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String settleMonth = ctx.getSettleMonth(); + log.info("[AccountingEntry] settleMonth={}", settleMonth); + + List source = accountingMapper.selectBySettleMonth(settleMonth); + if (source.isEmpty()) { + log.info("[AccountingEntry] no ledger records for month={}", settleMonth); + return RepeatStatus.FINISHED; + } + + LocalDate today = LocalDate.now(); + List batch = new ArrayList<>(BATCH_SIZE); + int total = 0; + + for (ContractAccountingEntryVO entry : source) { + if (entry.getAmount() == null || entry.getAmount().signum() == 0) continue; + entry.setEntryDate(today); + entry.setJournalNo(null); + + batch.add(entry); + if (batch.size() >= BATCH_SIZE) { + accountingMapper.insertBatch(batch); + total += batch.size(); + batch.clear(); + } + } + if (!batch.isEmpty()) { + accountingMapper.insertBatch(batch); + total += batch.size(); + } + + log.info("[AccountingEntry] inserted {} entries for month={}", total, settleMonth); + return RepeatStatus.FINISHED; + } +} diff --git a/ga-batch/src/main/java/com/ga/batch/settlement/step/YearEndIncomeAggregateStep.java b/ga-batch/src/main/java/com/ga/batch/settlement/step/YearEndIncomeAggregateStep.java new file mode 100644 index 0000000..27c46e6 --- /dev/null +++ b/ga-batch/src/main/java/com/ga/batch/settlement/step/YearEndIncomeAggregateStep.java @@ -0,0 +1,58 @@ +package com.ga.batch.settlement.step; + +import com.ga.batch.settlement.SettlementContext; +import com.ga.core.mapper.income.AgentAnnualIncomeMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.stereotype.Component; + +/** + * Step 9 (conditional) — yearEndIncomeAggregate: 연말 사업소득 집계 + * + * 12월 마감 시에만 실행 (settleMonth ends with "-12" 또는 "12"). + * payment 테이블 기준으로 해당 연도 설계사별 SUM → agent_annual_income UPSERT. + * SQL은 AgentAnnualIncomeMapper.upsertAggregate에 위임. + */ +@Slf4j +@Component +@JobScope +@RequiredArgsConstructor +public class YearEndIncomeAggregateStep implements Tasklet { + + private final SettlementContext ctx; + private final AgentAnnualIncomeMapper incomeMapper; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + String settleMonth = ctx.getSettleMonth(); + + if (!isDecemberClose(settleMonth)) { + log.info("[YearEndIncome] skipped — not a December close: {}", settleMonth); + return RepeatStatus.FINISHED; + } + + int year = extractYear(settleMonth); + log.info("[YearEndIncome] aggregating income for year={}", year); + + int rows = incomeMapper.upsertAggregate(year); + log.info("[YearEndIncome] agent_annual_income upserted: {} rows", rows); + return RepeatStatus.FINISHED; + } + + private boolean isDecemberClose(String settleMonth) { + if (settleMonth == null) return false; + // 형식: yyyyMM (예: 202512) 또는 yyyy-MM + return settleMonth.endsWith("12"); + } + + private int extractYear(String settleMonth) { + // yyyyMM → 앞 4자리 + String digits = settleMonth.replaceAll("[^0-9]", ""); + return Integer.parseInt(digits.substring(0, 4)); + } +} diff --git a/ga-batch/src/main/resources/application-trading_ai.yml b/ga-batch/src/main/resources/application-trading_ai.yml index 3262704..ce54d38 100644 --- a/ga-batch/src/main/resources/application-trading_ai.yml +++ b/ga-batch/src/main/resources/application-trading_ai.yml @@ -1,7 +1,7 @@ # 실제 운영 PostgreSQL 연결용 프로파일 (ga-batch) spring: datasource: - url: jdbc:postgresql://125.241.236.203:55432/trading_ai?currentSchema=ga + url: jdbc:postgresql://192.168.0.60:55432/trading_ai?currentSchema=ga username: ${DB_USER:kyu} password: ${DB_PASSWORD:7895123} driver-class-name: org.postgresql.Driver diff --git a/ga-common/src/main/java/com/ga/common/code/AccountCodeController.java b/ga-common/src/main/java/com/ga/common/code/AccountCodeController.java new file mode 100644 index 0000000..e5e4b59 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/code/AccountCodeController.java @@ -0,0 +1,56 @@ +package com.ga.common.code; + +import com.ga.common.annotation.RequirePermission; +import com.ga.common.model.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "계정코드") +@RestController +@RequestMapping("/api/common/account-codes") +@RequiredArgsConstructor +public class AccountCodeController { + + private final AccountCodeService service; + + /** 활성 계정코드 목록 — 드롭다운/배치 참조용. accountType 파라미터로 DEBIT/CREDIT/BOTH 필터 가능 */ + @GetMapping + public ApiResponse> getActiveCodes( + @RequestParam(required = false) String accountType) { + return ApiResponse.ok(service.getActiveCodes(accountType)); + } + + // ----- 시스템관리 ----- + + @GetMapping("/all") + @RequirePermission(menu = "SYSTEM_CODE", perm = "READ") + public ApiResponse> listAll( + @RequestParam(required = false) String accountType) { + return ApiResponse.ok(service.listAll(accountType)); + } + + @PostMapping + @RequirePermission(menu = "SYSTEM_CODE", perm = "CREATE") + public ApiResponse create(@RequestBody AccountCodeVO vo) { + service.create(vo); + return ApiResponse.ok(); + } + + @PutMapping("/{accountCode}") + @RequirePermission(menu = "SYSTEM_CODE", perm = "UPDATE") + public ApiResponse update(@PathVariable String accountCode, @RequestBody AccountCodeVO vo) { + vo.setAccountCode(accountCode); + service.update(vo); + return ApiResponse.ok(); + } + + @DeleteMapping("/{accountCode}") + @RequirePermission(menu = "SYSTEM_CODE", perm = "DELETE") + public ApiResponse delete(@PathVariable String accountCode) { + service.delete(accountCode); + return ApiResponse.ok(); + } +} diff --git a/ga-common/src/main/java/com/ga/common/code/AccountCodeMapper.java b/ga-common/src/main/java/com/ga/common/code/AccountCodeMapper.java new file mode 100644 index 0000000..2915e9b --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/code/AccountCodeMapper.java @@ -0,0 +1,18 @@ +package com.ga.common.code; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface AccountCodeMapper { + + List selectAll(@Param("accountType") String accountType); + List selectActive(@Param("accountType") String accountType); + AccountCodeVO selectOne(@Param("accountCode") String accountCode); + int insert(AccountCodeVO vo); + int update(AccountCodeVO vo); + int delete(@Param("accountCode") String accountCode); + int existsByCode(@Param("accountCode") String accountCode); +} diff --git a/ga-common/src/main/java/com/ga/common/code/AccountCodeService.java b/ga-common/src/main/java/com/ga/common/code/AccountCodeService.java new file mode 100644 index 0000000..e7b3196 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/code/AccountCodeService.java @@ -0,0 +1,50 @@ +package com.ga.common.code; + +import com.ga.common.exception.BizException; +import com.ga.common.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AccountCodeService { + + private final AccountCodeMapper mapper; + + public List getActiveCodes(String accountType) { + return mapper.selectActive(accountType); + } + + public AccountCodeVO getCode(String accountCode) { + AccountCodeVO vo = mapper.selectOne(accountCode); + if (vo == null) throw new BizException(ErrorCode.NOT_FOUND); + return vo; + } + + public List listAll(String accountType) { + return mapper.selectAll(accountType); + } + + @Transactional + public void create(AccountCodeVO vo) { + if (mapper.existsByCode(vo.getAccountCode()) > 0) { + throw new BizException(ErrorCode.DUPLICATE_DATA); + } + mapper.insert(vo); + } + + @Transactional + public void update(AccountCodeVO vo) { + getCode(vo.getAccountCode()); + mapper.update(vo); + } + + @Transactional + public void delete(String accountCode) { + getCode(accountCode); + mapper.delete(accountCode); + } +} diff --git a/ga-common/src/main/java/com/ga/common/code/AccountCodeVO.java b/ga-common/src/main/java/com/ga/common/code/AccountCodeVO.java new file mode 100644 index 0000000..3bcb071 --- /dev/null +++ b/ga-common/src/main/java/com/ga/common/code/AccountCodeVO.java @@ -0,0 +1,19 @@ +package com.ga.common.code; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class AccountCodeVO { + private String accountCode; + private String accountName; + private String accountType; // DEBIT / CREDIT / BOTH + private String isActive; + private Integer sortOrder; + private String description; + private LocalDateTime createdAt; + private Long createdBy; + private LocalDateTime updatedAt; + private Long updatedBy; +} diff --git a/ga-common/src/main/resources/db/migration/V63__설계사_연말사업소득_집계.sql b/ga-common/src/main/resources/db/migration/V63__설계사_연말사업소득_집계.sql new file mode 100644 index 0000000..f02f272 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V63__설계사_연말사업소득_집계.sql @@ -0,0 +1,25 @@ +-- V63: 설계사 연말 사업소득 집계 테이블 + +CREATE TABLE agent_annual_income ( + agent_id BIGINT NOT NULL REFERENCES agent(agent_id), + settle_year INT NOT NULL, + total_commission NUMERIC(18,2) NOT NULL DEFAULT 0, + total_tax_withheld NUMERIC(18,2) NOT NULL DEFAULT 0, + total_local_tax NUMERIC(18,2) NOT NULL DEFAULT 0, + business_income NUMERIC(18,2) NOT NULL DEFAULT 0, + generated_at TIMESTAMP NOT NULL DEFAULT now(), + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP, + PRIMARY KEY (agent_id, settle_year) +); + +COMMENT ON TABLE agent_annual_income IS '설계사 연말 사업소득 집계'; +COMMENT ON COLUMN agent_annual_income.business_income IS 'total_commission - total_tax_withheld - total_local_tax'; + +CREATE INDEX idx_aai_year ON agent_annual_income(settle_year); + +-- EXPLAIN ANALYZE: settle_year 단독 집계 — idx_aai_year Index Scan 확인 +-- EXPLAIN ANALYZE +-- SELECT settle_year, SUM(business_income) +-- FROM agent_annual_income +-- GROUP BY settle_year; diff --git a/ga-common/src/main/resources/db/migration/V64__계약_회계분개_엔트리.sql b/ga-common/src/main/resources/db/migration/V64__계약_회계분개_엔트리.sql new file mode 100644 index 0000000..a3e9f68 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V64__계약_회계분개_엔트리.sql @@ -0,0 +1,24 @@ +-- V64: 계약 회계분개 엔트리 테이블 + +CREATE TABLE contract_accounting_entry ( + entry_id BIGSERIAL PRIMARY KEY, + contract_id BIGINT NOT NULL REFERENCES contract(contract_id), + payment_id BIGINT REFERENCES payment(payment_id), + entry_date DATE NOT NULL, + debit_account VARCHAR(20) NOT NULL, + credit_account VARCHAR(20) NOT NULL, + amount NUMERIC(18,2) NOT NULL, + description VARCHAR(200), + journal_no VARCHAR(30), + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +COMMENT ON TABLE contract_accounting_entry IS '계약 회계 분개 엔트리'; +COMMENT ON COLUMN contract_accounting_entry.debit_account IS '차변 계정코드 (예: 8350 지급수수료)'; +COMMENT ON COLUMN contract_accounting_entry.credit_account IS '대변 계정코드 (예: 2530 미지급금)'; +COMMENT ON COLUMN contract_accounting_entry.journal_no IS '마감 시 채번. NULL=미전기'; + +CREATE INDEX idx_cae_journal ON contract_accounting_entry(journal_no); +CREATE INDEX idx_cae_date ON contract_accounting_entry(entry_date); +CREATE INDEX idx_cae_contract ON contract_accounting_entry(contract_id, entry_date); +CREATE INDEX idx_cae_unposted ON contract_accounting_entry(entry_date) WHERE journal_no IS NULL; diff --git a/ga-common/src/main/resources/db/migration/V65__P5W1_test_data.sql b/ga-common/src/main/resources/db/migration/V65__P5W1_test_data.sql new file mode 100644 index 0000000..6bf11eb --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V65__P5W1_test_data.sql @@ -0,0 +1,122 @@ +-- V65: P5-W1 테스트 데이터 (agent_annual_income, contract_accounting_entry, 메뉴) + +-- ============================================================ +-- 1. agent_annual_income: 설계사 10명 / 2025년 +-- ============================================================ +INSERT INTO agent_annual_income (agent_id, settle_year, total_commission, total_tax_withheld, total_local_tax, business_income) +SELECT + a.agent_id, + 2025, + v.total_commission, + v.total_commission * 0.033, + v.total_commission * 0.033 * 0.1, + v.total_commission - (v.total_commission * 0.033) - (v.total_commission * 0.033 * 0.1) +FROM ( + SELECT agent_id, ROW_NUMBER() OVER (ORDER BY agent_id) AS rn + FROM agent + LIMIT 10 +) a +JOIN (VALUES + (1, 42000000.00), + (2, 38500000.00), + (3, 55000000.00), + (4, 29800000.00), + (5, 61200000.00), + (6, 47300000.00), + (7, 33600000.00), + (8, 52100000.00), + (9, 28400000.00), + (10, 44700000.00) +) AS v(rn, total_commission) ON a.rn = v.rn +ON CONFLICT (agent_id, settle_year) DO NOTHING; + +-- ============================================================ +-- 2. contract_accounting_entry: payment 10건 기반 +-- journal_no NULL 5건 (미전기) + 채번 5건 (전기완료) +-- ============================================================ +INSERT INTO contract_accounting_entry (contract_id, payment_id, entry_date, debit_account, credit_account, amount, description, journal_no) +SELECT + c.contract_id, + p.payment_id, + COALESCE(p.pay_date, CURRENT_DATE), + '8350', + '2530', + p.pay_amount, + '지급수수료 자동분개 #' || p.payment_id, + CASE WHEN p.payment_id % 2 = 0 THEN 'JNL-2025-' || LPAD(p.payment_id::text, 5, '0') ELSE NULL END +FROM ( + SELECT py.payment_id, py.pay_amount, py.pay_date, sm.agent_id + FROM payment py + JOIN settle_master sm ON sm.settle_id = py.settle_id + ORDER BY py.payment_id + LIMIT 10 +) p +JOIN LATERAL ( + SELECT c.contract_id + FROM contract c + WHERE c.agent_id = p.agent_id + ORDER BY c.contract_id + LIMIT 1 +) c ON true; + +-- ============================================================ +-- 3. 메뉴: ANNUAL_INCOME + ACCOUNTING_JOURNAL (GRP_COMMISSION 하위) +-- ============================================================ +INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order, is_visible, is_active) +SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort, 'Y', 'Y' +FROM menu p +JOIN (VALUES + ('ANNUAL_INCOME', '연말 사업소득 명세서', '/commission/annual-income', 'commission/AnnualIncome', 80), + ('ACCOUNTING_JOURNAL', '회계 분개장', '/commission/accounting-journal', 'commission/AccountingJournal', 90) +) AS x(code, name, path, cmpt, sort) ON p.menu_code = 'GRP_COMMISSION' +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code); + +-- ============================================================ +-- 4. 권한 항목 등록 +-- ============================================================ +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, p.code, p.name, p.sort +FROM menu m +CROSS JOIN (VALUES + ('READ', '조회', 10), + ('CREATE', '등록', 20), + ('UPDATE', '수정', 30), + ('EXPORT', '엑셀', 60) +) AS p(code, name, sort) +WHERE m.menu_code IN ('ANNUAL_INCOME', 'ACCOUNTING_JOURNAL') +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +-- ============================================================ +-- 5. 역할별 권한 부여 +-- ============================================================ +-- SUPER_ADMIN / ADMIN: 전체 +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN') + AND m.menu_code IN ('ANNUAL_INCOME', 'ACCOUNTING_JOURNAL') +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; + +-- MANAGER: READ + EXPORT +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code = 'MANAGER' + AND mp.perm_code IN ('READ', 'EXPORT') + AND m.menu_code IN ('ANNUAL_INCOME', 'ACCOUNTING_JOURNAL') +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; + +-- AGENT: READ (본인 연말명세서) +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code = 'AGENT' + AND mp.perm_code = 'READ' + AND m.menu_code = 'ANNUAL_INCOME' +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; diff --git a/ga-common/src/main/resources/db/migration/V66__account_code_마스터.sql b/ga-common/src/main/resources/db/migration/V66__account_code_마스터.sql new file mode 100644 index 0000000..ad5ef1d --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V66__account_code_마스터.sql @@ -0,0 +1,26 @@ +-- V66: 회계 계정코드 마스터 신설 +-- contract_accounting_entry.debit_account / credit_account 참조용 +-- FK 추가는 PL 별도 논의 후 진행 + +CREATE TABLE account_code ( + account_code VARCHAR(20) PRIMARY KEY, + account_name VARCHAR(100) NOT NULL, + account_type VARCHAR(10) NOT NULL CHECK (account_type IN ('DEBIT','CREDIT','BOTH')), + is_active CHAR(1) NOT NULL DEFAULT 'Y', + sort_order INTEGER NOT NULL DEFAULT 0, + description VARCHAR(200), + created_at TIMESTAMP NOT NULL DEFAULT now(), + created_by BIGINT, + updated_at TIMESTAMP NOT NULL DEFAULT now(), + updated_by BIGINT +); + +COMMENT ON TABLE account_code IS '회계 계정코드 마스터'; +COMMENT ON COLUMN account_code.account_type IS 'DEBIT=차변전용, CREDIT=대변전용, BOTH=겸용'; + +INSERT INTO account_code (account_code, account_name, account_type, sort_order) VALUES + ('8350', '지급수수료', 'DEBIT', 10), + ('2530', '미지급금', 'CREDIT', 20), + ('1130', '미수수수료', 'DEBIT', 30), + ('4110', '수수료수익', 'CREDIT', 40) +ON CONFLICT (account_code) DO NOTHING; diff --git a/ga-common/src/main/resources/db/migration/V67__원천세_분기신고.sql b/ga-common/src/main/resources/db/migration/V67__원천세_분기신고.sql new file mode 100644 index 0000000..f764561 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V67__원천세_분기신고.sql @@ -0,0 +1,23 @@ +-- V67: 원천세 분기신고 테이블 + +CREATE TABLE withholding_tax_report ( + report_id BIGSERIAL PRIMARY KEY, + settle_quarter VARCHAR(7) NOT NULL, + agent_id BIGINT NOT NULL REFERENCES agent(agent_id), + total_income NUMERIC(18,2), + total_withheld NUMERIC(18,2), + total_local_tax NUMERIC(18,2), + generated_at TIMESTAMP, + filed_at TIMESTAMP, + file_status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP, + UNIQUE (agent_id, settle_quarter), + CONSTRAINT chk_wtr_status CHECK (file_status IN ('DRAFT','FILED','AMENDED')) +); + +COMMENT ON TABLE withholding_tax_report IS '원천세 분기신고'; +COMMENT ON COLUMN withholding_tax_report.settle_quarter IS '정산 분기 (예: 2025-Q1)'; +COMMENT ON COLUMN withholding_tax_report.file_status IS 'DRAFT=작성중, FILED=신고완료, AMENDED=수정신고'; + +CREATE INDEX idx_wtr_quarter ON withholding_tax_report(settle_quarter); diff --git a/ga-common/src/main/resources/db/migration/V68__부가세_분기신고.sql b/ga-common/src/main/resources/db/migration/V68__부가세_분기신고.sql new file mode 100644 index 0000000..59ed7ac --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V68__부가세_분기신고.sql @@ -0,0 +1,22 @@ +-- V68: 부가세 분기신고 테이블 + +CREATE TABLE vat_report ( + vat_id BIGSERIAL PRIMARY KEY, + settle_quarter VARCHAR(7) NOT NULL, + total_sales NUMERIC(18,2), + total_purchase NUMERIC(18,2), + total_vat_payable NUMERIC(18,2), + tax_invoice_count INT, + generated_at TIMESTAMP, + filed_at TIMESTAMP, + file_status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP, + UNIQUE (settle_quarter), + CONSTRAINT chk_vat_status CHECK (file_status IN ('DRAFT','FILED','AMENDED')) +); + +COMMENT ON TABLE vat_report IS '부가세 분기신고'; +COMMENT ON COLUMN vat_report.settle_quarter IS '정산 분기 (예: 2025-Q1)'; +COMMENT ON COLUMN vat_report.total_vat_payable IS '납부 부가세 = total_sales * 0.1 - 매입세액'; +COMMENT ON COLUMN vat_report.file_status IS 'DRAFT=작성중, FILED=신고완료, AMENDED=수정신고'; diff --git a/ga-common/src/main/resources/db/migration/V69__P5W2_test_data.sql b/ga-common/src/main/resources/db/migration/V69__P5W2_test_data.sql new file mode 100644 index 0000000..c856a22 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V69__P5W2_test_data.sql @@ -0,0 +1,87 @@ +-- V69: P5-W2 테스트 데이터 (withholding_tax_report, vat_report, 메뉴) + +-- ============================================================ +-- 1. withholding_tax_report: agent 5명 × 2025 Q1~Q4 = 20건 +-- ============================================================ +INSERT INTO withholding_tax_report (settle_quarter, agent_id, total_income, total_withheld, total_local_tax, generated_at, file_status) +SELECT + q.quarter, + a.agent_id, + q.base_income, + q.base_income * 0.033, + q.base_income * 0.033 * 0.1, + now(), + CASE WHEN q.quarter IN ('2025-Q1','2025-Q2') THEN 'FILED' ELSE 'DRAFT' END +FROM ( + SELECT agent_id, ROW_NUMBER() OVER (ORDER BY agent_id) AS rn + FROM agent LIMIT 5 +) a +CROSS JOIN (VALUES + ('2025-Q1', 10500000.00), + ('2025-Q2', 11200000.00), + ('2025-Q3', 10800000.00), + ('2025-Q4', 12300000.00) +) AS q(quarter, base_income) +ON CONFLICT (agent_id, settle_quarter) DO NOTHING; + +-- ============================================================ +-- 2. vat_report: 2025 Q1~Q4 = 4건 +-- ============================================================ +INSERT INTO vat_report (settle_quarter, total_sales, total_purchase, total_vat_payable, tax_invoice_count, generated_at, file_status) +VALUES + ('2025-Q1', 850000000.00, 120000000.00, 73000000.00, 142, now(), 'FILED'), + ('2025-Q2', 920000000.00, 135000000.00, 78500000.00, 158, now(), 'FILED'), + ('2025-Q3', 880000000.00, 128000000.00, 75200000.00, 151, now(), 'DRAFT'), + ('2025-Q4', 960000000.00, 140000000.00, 82000000.00, 167, now(), 'DRAFT') +ON CONFLICT (settle_quarter) DO NOTHING; + +-- ============================================================ +-- 3. 메뉴: WITHHOLDING_TAX + VAT_REPORT (GRP_COMMISSION 하위) +-- ============================================================ +INSERT INTO menu (parent_menu_id, menu_code, menu_name, menu_type, menu_path, component_path, menu_level, sort_order, is_visible, is_active) +SELECT p.menu_id, x.code, x.name, 'PAGE', x.path, x.cmpt, 2, x.sort, 'Y', 'Y' +FROM menu p +JOIN (VALUES + ('WITHHOLDING_TAX', '원천세 신고', '/commission/withholding-tax', 'commission/WithholdingTax', 100), + ('VAT_REPORT', '부가세 신고', '/commission/vat-report', 'commission/VatReport', 110) +) AS x(code, name, path, cmpt, sort) ON p.menu_code = 'GRP_COMMISSION' +WHERE NOT EXISTS (SELECT 1 FROM menu WHERE menu_code = x.code); + +-- ============================================================ +-- 4. 권한 항목 등록 +-- ============================================================ +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, p.code, p.name, p.sort +FROM menu m +CROSS JOIN (VALUES + ('READ', '조회', 10), + ('CREATE', '등록', 20), + ('UPDATE', '수정', 30), + ('EXPORT', '엑셀', 60) +) AS p(code, name, sort) +WHERE m.menu_code IN ('WITHHOLDING_TAX', 'VAT_REPORT') +ON CONFLICT (menu_id, perm_code) DO NOTHING; + +-- ============================================================ +-- 5. 역할별 권한 부여 +-- ============================================================ +-- SUPER_ADMIN / ADMIN: 전체 +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN') + AND m.menu_code IN ('WITHHOLDING_TAX', 'VAT_REPORT') +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; + +-- MANAGER: READ + EXPORT +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code = 'MANAGER' + AND mp.perm_code IN ('READ', 'EXPORT') + AND m.menu_code IN ('WITHHOLDING_TAX', 'VAT_REPORT') +ON CONFLICT (role_id, menu_id, perm_code) DO NOTHING; diff --git a/ga-common/src/main/resources/db/migration/V70__rule_audit_columns_보강.sql b/ga-common/src/main/resources/db/migration/V70__rule_audit_columns_보강.sql new file mode 100644 index 0000000..488fa54 --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V70__rule_audit_columns_보강.sql @@ -0,0 +1,29 @@ +-- V70: rule 도메인 5개 테이블 감사 컬럼 보강 +-- commission_rate / payout_rule: created_at/created_by 존재, updated_at/updated_by 누락 +-- override_rule / chargeback_rule / exception_type_code: 감사 컬럼 전체 누락 + +ALTER TABLE commission_rate + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS updated_by BIGINT; + +ALTER TABLE payout_rule + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS updated_by BIGINT; + +ALTER TABLE override_rule + ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS created_by BIGINT, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS updated_by BIGINT; + +ALTER TABLE chargeback_rule + ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS created_by BIGINT, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS updated_by BIGINT; + +ALTER TABLE exception_type_code + ADD COLUMN IF NOT EXISTS created_at TIMESTAMP NOT NULL DEFAULT now(), + ADD COLUMN IF NOT EXISTS created_by BIGINT, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP, + ADD COLUMN IF NOT EXISTS updated_by BIGINT; diff --git a/ga-common/src/main/resources/db/migration/V71__payment_세무컬럼_보강.sql b/ga-common/src/main/resources/db/migration/V71__payment_세무컬럼_보강.sql new file mode 100644 index 0000000..06dbb1b --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V71__payment_세무컬럼_보강.sql @@ -0,0 +1,13 @@ +-- V71: payment 테이블 세무 컬럼 누락 보강 +-- V23 세무처리.sql 체크섬 미스매치로 income_tax_amount 등 미적용 → idempotent 추가 + +ALTER TABLE payment + ADD COLUMN IF NOT EXISTS income_tax_amount NUMERIC(15,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS local_tax_amount NUMERIC(15,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS vat_amount NUMERIC(15,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS net_amount NUMERIC(15,2); + +COMMENT ON COLUMN payment.income_tax_amount IS '원천세 (소득세 3%)'; +COMMENT ON COLUMN payment.local_tax_amount IS '지방소득세 (소득세의 10%)'; +COMMENT ON COLUMN payment.vat_amount IS '부가세'; +COMMENT ON COLUMN payment.net_amount IS '실수령액 (pay_amount - income_tax - local_tax)'; diff --git a/ga-common/src/main/resources/db/migration/V72__P5W1_EXECUTE_권한_보강.sql b/ga-common/src/main/resources/db/migration/V72__P5W1_EXECUTE_권한_보강.sql new file mode 100644 index 0000000..79da5ad --- /dev/null +++ b/ga-common/src/main/resources/db/migration/V72__P5W1_EXECUTE_권한_보강.sql @@ -0,0 +1,29 @@ +-- V72: P5-W1 ANNUAL_INCOME 메뉴 EXECUTE 권한 누락 보강 +-- V65에서 READ/CREATE/UPDATE/EXPORT만 등록되어 regenerate 엔드포인트 403 발생 +-- SUPER_ADMIN / ADMIN 역할에 EXECUTE 권한 부여 + +-- 1. menu_permission에 EXECUTE 항목 추가 +INSERT INTO menu_permission (menu_id, perm_code, perm_name, sort_order) +SELECT m.menu_id, 'EXECUTE', '재집계', 70 +FROM menu m +WHERE m.menu_code = 'ANNUAL_INCOME' + AND NOT EXISTS ( + SELECT 1 FROM menu_permission mp2 + WHERE mp2.menu_id = m.menu_id AND mp2.perm_code = 'EXECUTE' + ); + +-- 2. SUPER_ADMIN / ADMIN에 EXECUTE 권한 부여 +INSERT INTO role_menu_permission (role_id, menu_id, perm_code, is_granted) +SELECT r.role_id, mp.menu_id, mp.perm_code, 'Y' +FROM role r +CROSS JOIN menu_permission mp +JOIN menu m ON m.menu_id = mp.menu_id +WHERE r.role_code IN ('SUPER_ADMIN', 'ADMIN') + AND mp.perm_code = 'EXECUTE' + AND m.menu_code = 'ANNUAL_INCOME' + AND NOT EXISTS ( + SELECT 1 FROM role_menu_permission rmp2 + WHERE rmp2.role_id = r.role_id + AND rmp2.menu_id = mp.menu_id + AND rmp2.perm_code = 'EXECUTE' + ); diff --git a/ga-common/src/main/resources/mapper/common/AccountCodeMapper.xml b/ga-common/src/main/resources/mapper/common/AccountCodeMapper.xml new file mode 100644 index 0000000..3a7c610 --- /dev/null +++ b/ga-common/src/main/resources/mapper/common/AccountCodeMapper.xml @@ -0,0 +1,70 @@ + + + + + + + + account_code, account_name, account_type, is_active, sort_order, description, + created_at, created_by, updated_at, updated_by + + + + + + + + + + INSERT INTO account_code ( + account_code, account_name, account_type, is_active, sort_order, description, + created_at, created_by + ) VALUES ( + #{accountCode}, #{accountName}, #{accountType}, + COALESCE(#{isActive},'Y'), COALESCE(#{sortOrder},0), #{description}, + now(), #{createdBy} + ) + + + + UPDATE account_code SET + account_name = #{accountName}, + account_type = #{accountType}, + is_active = #{isActive}, + sort_order = #{sortOrder}, + description = #{description}, + updated_at = now(), + updated_by = #{updatedBy} + WHERE account_code = #{accountCode} + + + + DELETE FROM account_code WHERE account_code = #{accountCode} + + + + + diff --git a/ga-core/src/main/java/com/ga/core/mapper/accounting/ContractAccountingEntryMapper.java b/ga-core/src/main/java/com/ga/core/mapper/accounting/ContractAccountingEntryMapper.java new file mode 100644 index 0000000..142ca34 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/accounting/ContractAccountingEntryMapper.java @@ -0,0 +1,36 @@ +package com.ga.core.mapper.accounting; + +import com.ga.core.vo.accounting.ContractAccountingEntryResp; +import com.ga.core.vo.accounting.ContractAccountingEntrySearchParam; +import com.ga.core.vo.accounting.ContractAccountingEntryVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface ContractAccountingEntryMapper { + + /** 목록 조회 — contractId / paymentId / entryDate 범위 / unpostedOnly 조건 */ + List selectList(ContractAccountingEntrySearchParam param); + + /** 단건 상세 */ + ContractAccountingEntryResp selectDetailById(@Param("entryId") Long entryId); + + /** 배치 step용: 정산월 기준 분개 대상 원장 조회 (recruit+maintain ledger UNION) */ + List selectBySettleMonth(@Param("settleMonth") String settleMonth); + + /** + * 분개 엔트리 일괄 INSERT. + * 회계 자동분개 배치 step에서 호출. + */ + int insertBatch(@Param("list") List list); + + /** + * 미전기 엔트리 마감 처리 — journalNo 채번 후 UPDATE. + * @param ids 마감할 entry_id 목록 + * @param journalNo 채번된 분개장 번호 + */ + int closeJournal(@Param("ids") List ids, + @Param("journalNo") String journalNo); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/income/AgentAnnualIncomeMapper.java b/ga-core/src/main/java/com/ga/core/mapper/income/AgentAnnualIncomeMapper.java new file mode 100644 index 0000000..c93ae61 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/income/AgentAnnualIncomeMapper.java @@ -0,0 +1,25 @@ +package com.ga.core.mapper.income; + +import com.ga.core.vo.income.AgentAnnualIncomeResp; +import com.ga.core.vo.income.AgentAnnualIncomeSearchParam; +import com.ga.core.vo.income.AgentAnnualIncomeVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface AgentAnnualIncomeMapper { + + /** 목록 조회 — settleYear / agentId / orgId 조건 */ + List selectList(AgentAnnualIncomeSearchParam param); + + /** 단건 상세 (복합키: agentId + settleYear) */ + AgentAnnualIncomeResp selectDetailById(@Param("agentId") Long agentId, + @Param("settleYear") int settleYear); + + /** + * 연도 전체 UPSERT 집계 (전체 설계사). + */ + int upsertAggregate(@Param("year") int year); +} diff --git a/ga-core/src/main/java/com/ga/core/mapper/tax/WithholdingTaxReportMapper.java b/ga-core/src/main/java/com/ga/core/mapper/tax/WithholdingTaxReportMapper.java new file mode 100644 index 0000000..f25be5c --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/mapper/tax/WithholdingTaxReportMapper.java @@ -0,0 +1,28 @@ +package com.ga.core.mapper.tax; + +import com.ga.core.vo.tax.WithholdingTaxReportResp; +import com.ga.core.vo.tax.WithholdingTaxReportSearchParam; +import com.ga.core.vo.tax.WithholdingTaxReportVO; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +@Mapper +public interface WithholdingTaxReportMapper { + + /** 목록 조회 — settleQuarter / agentId / orgId / fileStatus 조건 */ + List selectList(WithholdingTaxReportSearchParam param); + + /** 단건 상세 */ + WithholdingTaxReportResp selectDetailById(@Param("reportId") Long reportId); + + /** + * 분기 전체 UPSERT 집계. + * payment 테이블 기준 해당 분기 설계사별 SUM 후 INSERT … ON CONFLICT DO UPDATE. + * + * @param quarter 집계할 분기 (예: 2025-Q1) + * @return 처리된 행 수 + */ + int upsertQuarterlyAggregate(@Param("quarter") String quarter); +} diff --git a/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntryExcelVO.java b/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntryExcelVO.java new file mode 100644 index 0000000..a425247 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntryExcelVO.java @@ -0,0 +1,23 @@ +package com.ga.core.vo.accounting; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 분개장 엑셀 다운로드 전용 VO + */ +@Data +public class ContractAccountingEntryExcelVO { + private String journalNo; + private LocalDate entryDate; + private String contractNo; + private String agentName; + private String debitAccount; + private String creditAccount; + private BigDecimal amount; + private String description; + private LocalDateTime createdAt; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntryResp.java b/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntryResp.java new file mode 100644 index 0000000..308520a --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntryResp.java @@ -0,0 +1,28 @@ +package com.ga.core.vo.accounting; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 분개 엔트리 목록/상세 API 응답 — contract, agent 조인 포함 + */ +@Data +public class ContractAccountingEntryResp { + private Long entryId; + private Long contractId; + private String contractNo; + private Long paymentId; + private Long agentId; + private String agentName; + private LocalDate entryDate; + private String debitAccount; + private String creditAccount; + private BigDecimal amount; + private String description; + /** NULL이면 미전기 상태 */ + private String journalNo; + private LocalDateTime createdAt; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntrySaveReq.java b/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntrySaveReq.java new file mode 100644 index 0000000..8397d12 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntrySaveReq.java @@ -0,0 +1,29 @@ +package com.ga.core.vo.accounting; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 분개 엔트리 수동 등록 요청 — 자동 분개 외 수기 입력용 + */ +@Data +public class ContractAccountingEntrySaveReq { + @NotNull + private Long contractId; + private Long paymentId; + @NotNull + private LocalDate entryDate; + @NotBlank + private String debitAccount; + @NotBlank + private String creditAccount; + @NotNull + @DecimalMin("0.01") + private BigDecimal amount; + private String description; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntrySearchParam.java b/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntrySearchParam.java new file mode 100644 index 0000000..b82fd23 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntrySearchParam.java @@ -0,0 +1,20 @@ +package com.ga.core.vo.accounting; + +import com.ga.common.model.SearchParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.time.LocalDate; + +@Data +@EqualsAndHashCode(callSuper = true) +public class ContractAccountingEntrySearchParam extends SearchParam { + private Long contractId; + private Long paymentId; + private LocalDate entryDateFrom; + private LocalDate entryDateTo; + /** null이면 전체, ""이면 미전기(journalNo IS NULL) */ + private String journalNo; + /** 미전기만 조회 플래그 */ + private Boolean unpostedOnly; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntryVO.java b/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntryVO.java new file mode 100644 index 0000000..8025350 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/accounting/ContractAccountingEntryVO.java @@ -0,0 +1,28 @@ +package com.ga.core.vo.accounting; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * contract_accounting_entry — 계약 회계 분개 엔트리 (V64) + * INSERT/insertBatch/closeJournal 전용. 조인 필드 없음. + */ +@Data +public class ContractAccountingEntryVO { + private Long entryId; + private Long contractId; + private Long paymentId; + private LocalDate entryDate; + /** 차변 계정코드 (예: 8350 지급수수료) */ + private String debitAccount; + /** 대변 계정코드 (예: 2530 미지급금) */ + private String creditAccount; + private BigDecimal amount; + private String description; + /** 마감 시 채번. NULL=미전기 */ + private String journalNo; + private LocalDateTime createdAt; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeExcelVO.java b/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeExcelVO.java new file mode 100644 index 0000000..fa2f54c --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeExcelVO.java @@ -0,0 +1,21 @@ +package com.ga.core.vo.income; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 연말 사업소득 집계 엑셀 다운로드 전용 VO + */ +@Data +public class AgentAnnualIncomeExcelVO { + private String agentName; + private String orgName; + private Integer settleYear; + private BigDecimal totalCommission; + private BigDecimal totalTaxWithheld; + private BigDecimal totalLocalTax; + private BigDecimal businessIncome; + private LocalDateTime generatedAt; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeResp.java b/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeResp.java new file mode 100644 index 0000000..a573b3e --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeResp.java @@ -0,0 +1,25 @@ +package com.ga.core.vo.income; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 연간소득 목록/상세 API 응답 — agent, organization 조인 포함 + */ +@Data +public class AgentAnnualIncomeResp { + private Long agentId; + private String agentName; + private Long orgId; + private String orgName; + private Integer settleYear; + private BigDecimal totalCommission; + private BigDecimal totalTaxWithheld; + private BigDecimal totalLocalTax; + private BigDecimal businessIncome; + private LocalDateTime generatedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeSaveReq.java new file mode 100644 index 0000000..f93a120 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeSaveReq.java @@ -0,0 +1,17 @@ +package com.ga.core.vo.income; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import lombok.Data; + +/** + * 연간소득 재집계 트리거 요청 — 연도와 대상 설계사(선택)만 받음 + */ +@Data +public class AgentAnnualIncomeSaveReq { + /** null이면 전체 설계사 집계 */ + private Long agentId; + @NotNull + @Positive + private Integer settleYear; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeSearchParam.java new file mode 100644 index 0000000..bf51086 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeSearchParam.java @@ -0,0 +1,13 @@ +package com.ga.core.vo.income; + +import com.ga.common.model.SearchParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class AgentAnnualIncomeSearchParam extends SearchParam { + private Long agentId; + private Integer settleYear; + private Long orgId; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeVO.java b/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeVO.java new file mode 100644 index 0000000..2dd131c --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/income/AgentAnnualIncomeVO.java @@ -0,0 +1,29 @@ +package com.ga.core.vo.income; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * agent_annual_income — 설계사 연말 사업소득 집계 (V63) + * PK: (agent_id, settle_year) 복합키. INSERT/UPDATE 전용. + */ +@Data +public class AgentAnnualIncomeVO { + private Long agentId; + /** 정산 연도 (YYYY) */ + private Integer settleYear; + /** 연간 총 수수료 합계 */ + private BigDecimal totalCommission; + /** 원천징수 소득세 합계 */ + private BigDecimal totalTaxWithheld; + /** 지방소득세 합계 */ + private BigDecimal totalLocalTax; + /** 사업소득 = totalCommission - totalTaxWithheld - totalLocalTax */ + private BigDecimal businessIncome; + /** 마지막 집계 실행 일시 */ + private LocalDateTime generatedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/rule/ChargebackRuleVO.java b/ga-core/src/main/java/com/ga/core/vo/rule/ChargebackRuleVO.java index 07379a2..5e1134a 100644 --- a/ga-core/src/main/java/com/ga/core/vo/rule/ChargebackRuleVO.java +++ b/ga-core/src/main/java/com/ga/core/vo/rule/ChargebackRuleVO.java @@ -4,6 +4,7 @@ import lombok.Data; import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; @Data public class ChargebackRuleVO { @@ -18,4 +19,8 @@ public class ChargebackRuleVO { private Integer maxInstallments; private LocalDate effectiveFrom; private LocalDate effectiveTo; + private LocalDateTime createdAt; + private Long createdBy; + private LocalDateTime updatedAt; + private Long updatedBy; } diff --git a/ga-core/src/main/java/com/ga/core/vo/rule/CommissionRateVO.java b/ga-core/src/main/java/com/ga/core/vo/rule/CommissionRateVO.java index b5eb49c..544098e 100644 --- a/ga-core/src/main/java/com/ga/core/vo/rule/CommissionRateVO.java +++ b/ga-core/src/main/java/com/ga/core/vo/rule/CommissionRateVO.java @@ -19,4 +19,6 @@ public class CommissionRateVO { private Integer version; private LocalDateTime createdAt; private Long createdBy; + private LocalDateTime updatedAt; + private Long updatedBy; } diff --git a/ga-core/src/main/java/com/ga/core/vo/rule/ExceptionTypeCodeVO.java b/ga-core/src/main/java/com/ga/core/vo/rule/ExceptionTypeCodeVO.java index 6b2dcaa..b054bab 100644 --- a/ga-core/src/main/java/com/ga/core/vo/rule/ExceptionTypeCodeVO.java +++ b/ga-core/src/main/java/com/ga/core/vo/rule/ExceptionTypeCodeVO.java @@ -2,6 +2,8 @@ package com.ga.core.vo.rule; import lombok.Data; +import java.time.LocalDateTime; + @Data public class ExceptionTypeCodeVO { private String exceptionCode; @@ -13,4 +15,8 @@ public class ExceptionTypeCodeVO { private String taxIncluded; private String description; private String isActive; + private LocalDateTime createdAt; + private Long createdBy; + private LocalDateTime updatedAt; + private Long updatedBy; } diff --git a/ga-core/src/main/java/com/ga/core/vo/rule/OverrideRuleVO.java b/ga-core/src/main/java/com/ga/core/vo/rule/OverrideRuleVO.java index 498b738..1b12086 100644 --- a/ga-core/src/main/java/com/ga/core/vo/rule/OverrideRuleVO.java +++ b/ga-core/src/main/java/com/ga/core/vo/rule/OverrideRuleVO.java @@ -4,6 +4,7 @@ import lombok.Data; import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; @Data public class OverrideRuleVO { @@ -16,4 +17,8 @@ public class OverrideRuleVO { private BigDecimal capAmount; private LocalDate effectiveFrom; private LocalDate effectiveTo; + private LocalDateTime createdAt; + private Long createdBy; + private LocalDateTime updatedAt; + private Long updatedBy; } diff --git a/ga-core/src/main/java/com/ga/core/vo/rule/PayoutRuleVO.java b/ga-core/src/main/java/com/ga/core/vo/rule/PayoutRuleVO.java index 34c94c5..4dff12a 100644 --- a/ga-core/src/main/java/com/ga/core/vo/rule/PayoutRuleVO.java +++ b/ga-core/src/main/java/com/ga/core/vo/rule/PayoutRuleVO.java @@ -20,4 +20,6 @@ public class PayoutRuleVO { private Integer version; private LocalDateTime createdAt; private Long createdBy; + private LocalDateTime updatedAt; + private Long updatedBy; } diff --git a/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportExcelVO.java b/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportExcelVO.java new file mode 100644 index 0000000..fc73732 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportExcelVO.java @@ -0,0 +1,22 @@ +package com.ga.core.vo.tax; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 원천세 분기신고 엑셀 다운로드 전용 VO + */ +@Data +public class WithholdingTaxReportExcelVO { + private String settleQuarter; + private String agentName; + private String orgName; + private BigDecimal totalIncome; + private BigDecimal totalWithheld; + private BigDecimal totalLocalTax; + private String fileStatus; + private LocalDateTime filedAt; + private LocalDateTime generatedAt; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportResp.java b/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportResp.java new file mode 100644 index 0000000..1731a52 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportResp.java @@ -0,0 +1,28 @@ +package com.ga.core.vo.tax; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 원천세 분기신고 목록/상세 API 응답 — agent, organization 조인 포함 + */ +@Data +public class WithholdingTaxReportResp { + private Long reportId; + private Long agentId; + private String agentName; + private Long orgId; + private String orgName; + private String settleQuarter; + private BigDecimal totalIncome; + private BigDecimal totalWithheld; + private BigDecimal totalLocalTax; + private LocalDateTime generatedAt; + private LocalDateTime filedAt; + private String fileStatus; + private String fileStatusName; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportSaveReq.java b/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportSaveReq.java new file mode 100644 index 0000000..4cb935d --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportSaveReq.java @@ -0,0 +1,16 @@ +package com.ga.core.vo.tax; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * 원천세 분기신고 재집계 트리거 요청 + */ +@Data +public class WithholdingTaxReportSaveReq { + /** null이면 전체 설계사 집계 */ + private Long agentId; + @NotBlank + private String settleQuarter; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportSearchParam.java b/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportSearchParam.java new file mode 100644 index 0000000..a6c5c7e --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportSearchParam.java @@ -0,0 +1,15 @@ +package com.ga.core.vo.tax; + +import com.ga.common.model.SearchParam; +import lombok.Data; +import lombok.EqualsAndHashCode; + +@Data +@EqualsAndHashCode(callSuper = true) +public class WithholdingTaxReportSearchParam extends SearchParam { + private String settleQuarter; + private Long agentId; + private Long orgId; + /** DRAFT / FILED / AMENDED */ + private String fileStatus; +} diff --git a/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportVO.java b/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportVO.java new file mode 100644 index 0000000..9f7dcd5 --- /dev/null +++ b/ga-core/src/main/java/com/ga/core/vo/tax/WithholdingTaxReportVO.java @@ -0,0 +1,27 @@ +package com.ga.core.vo.tax; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * withholding_tax_report — 원천세 분기신고 (V67) + * INSERT/UPDATE 전용. 조인 필드 없음. + */ +@Data +public class WithholdingTaxReportVO { + private Long reportId; + private Long agentId; + /** 정산 분기 (예: 2025-Q1) */ + private String settleQuarter; + private BigDecimal totalIncome; + private BigDecimal totalWithheld; + private BigDecimal totalLocalTax; + private LocalDateTime generatedAt; + private LocalDateTime filedAt; + /** DRAFT / FILED / AMENDED */ + private String fileStatus; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/ga-core/src/main/resources/mapper/accounting/ContractAccountingEntryMapper.xml b/ga-core/src/main/resources/mapper/accounting/ContractAccountingEntryMapper.xml new file mode 100644 index 0000000..af22881 --- /dev/null +++ b/ga-core/src/main/resources/mapper/accounting/ContractAccountingEntryMapper.xml @@ -0,0 +1,119 @@ + + + + + + + + + + cae.entry_id, + cae.contract_id, + ac.contract_no, + cae.payment_id, + a.agent_id, + a.agent_name, + cae.entry_date, + cae.debit_account, + cae.credit_account, + cae.amount, + cae.description, + cae.journal_no, + cae.created_at + + + + + FROM contract_accounting_entry cae + JOIN contract ac ON ac.contract_id = cae.contract_id + JOIN agent a ON a.agent_id = ac.agent_id + + + + + + + + + + + INSERT INTO contract_accounting_entry + (contract_id, payment_id, entry_date, + debit_account, credit_account, amount, description) + VALUES + + (#{e.contractId}, #{e.paymentId}, #{e.entryDate}, + #{e.debitAccount}, #{e.creditAccount}, #{e.amount}, #{e.description}) + + + + + + UPDATE contract_accounting_entry + SET journal_no = #{journalNo} + WHERE entry_id IN + + #{id} + + AND journal_no IS NULL + + + + + + diff --git a/ga-core/src/main/resources/mapper/income/AgentAnnualIncomeMapper.xml b/ga-core/src/main/resources/mapper/income/AgentAnnualIncomeMapper.xml new file mode 100644 index 0000000..fae29d6 --- /dev/null +++ b/ga-core/src/main/resources/mapper/income/AgentAnnualIncomeMapper.xml @@ -0,0 +1,80 @@ + + + + + + + + + + i.agent_id, + a.agent_name, + a.org_id, + o.org_name, + i.settle_year, + i.total_commission, + i.total_tax_withheld, + i.total_local_tax, + i.business_income, + i.generated_at, + i.created_at, + i.updated_at + + + + + + + + + + INSERT INTO agent_annual_income + (agent_id, settle_year, + total_commission, total_tax_withheld, total_local_tax, business_income, + generated_at) + SELECT + p.agent_id, + #{year} AS settle_year, + COALESCE(SUM(p.pay_amount), 0) AS total_commission, + COALESCE(SUM(p.income_tax_amount), 0) AS total_tax_withheld, + COALESCE(SUM(p.local_tax_amount), 0) AS total_local_tax, + COALESCE(SUM(p.pay_amount) + - SUM(p.income_tax_amount) + - SUM(p.local_tax_amount), 0) AS business_income, + NOW() AS generated_at + FROM payment p + WHERE EXTRACT(YEAR FROM p.pay_date) = #{year} + GROUP BY p.agent_id + ON CONFLICT (agent_id, settle_year) DO UPDATE SET + total_commission = EXCLUDED.total_commission, + total_tax_withheld = EXCLUDED.total_tax_withheld, + total_local_tax = EXCLUDED.total_local_tax, + business_income = EXCLUDED.business_income, + generated_at = NOW(), + updated_at = NOW() + + + + diff --git a/ga-core/src/main/resources/mapper/settle/PaymentMapper.xml b/ga-core/src/main/resources/mapper/settle/PaymentMapper.xml index 05e3d4a..cd7342a 100644 --- a/ga-core/src/main/resources/mapper/settle/PaymentMapper.xml +++ b/ga-core/src/main/resources/mapper/settle/PaymentMapper.xml @@ -92,4 +92,51 @@ #{id} + + + + + + UPDATE payment + SET income_tax_amount = #{incomeTaxAmount}, + local_tax_amount = #{localTaxAmount}, + vat_amount = #{vatAmount}, + net_amount = #{netAmount}, + updated_at = NOW() + WHERE payment_id = #{paymentId} + + + + + UPDATE payment + SET income_tax_amount = #{p.incomeTaxAmount}, + local_tax_amount = #{p.localTaxAmount}, + vat_amount = #{p.vatAmount}, + net_amount = #{p.netAmount}, + updated_at = NOW() + WHERE payment_id = #{p.paymentId} + + + + + UPDATE payment + SET deduction_amount = #{deductionAmount}, + net_amount = #{netAmount}, + updated_at = NOW() + WHERE payment_id = #{paymentId} + + diff --git a/ga-frontend/src/App.tsx b/ga-frontend/src/App.tsx index 5d80656..e21bdfa 100644 --- a/ga-frontend/src/App.tsx +++ b/ga-frontend/src/App.tsx @@ -3,6 +3,14 @@ import LoginPage from '@/pages/LoginPage'; import MainLayout from '@/layouts/MainLayout'; import Dashboard from '@/pages/Dashboard'; +// ── P5: 연말명세서 / 분개장 ────────────────────────── +import AgentAnnualIncomeList from '@/pages/income/AgentAnnualIncomeList'; +import AccountingJournalList from '@/pages/accounting/AccountingJournalList'; + +// ── P5-W2: 원천세 / 부가세 신고 ────────────────────── +import WithholdingTaxReportList from '@/pages/tax/WithholdingTaxReportList'; +import VatReportList from '@/pages/tax/VatReportList'; + // ── P4: 수수료 종류 ────────────────────────────────── import CommissionMaster from '@/pages/commission/CommissionMaster'; import CollectionCommission from '@/pages/commission/CollectionCommission'; @@ -146,6 +154,14 @@ export default function App() { } /> } /> + {/* P5: 연말명세서 / 분개장 */} + } /> + } /> + + {/* P5-W2: 원천세 / 부가세 신고 */} + } /> + } /> + {/* P4: 수수료 종류 */} } /> } /> diff --git a/ga-frontend/src/api/accounting.ts b/ga-frontend/src/api/accounting.ts new file mode 100644 index 0000000..ce93b16 --- /dev/null +++ b/ga-frontend/src/api/accounting.ts @@ -0,0 +1,48 @@ +import api, { unwrap } from './request'; + +export interface AccountingJournalResp extends Record { + entryId: number; + journalNo?: string; + agentId?: number; + agentName?: string; + contractNo?: string; + settleMonth?: string; + accountCode: string; + accountName?: string; + debitAmount: number; + creditAmount: number; + description?: string; + status?: string; + statusName?: string; + closedAt?: string; + createdAt?: string; +} + +export interface AccountingGenerateReq { + settleMonth: string; +} + +export interface AccountingCloseReq { + journalNo: string; + entryIds: number[]; +} + +export interface AccountingSearchParam { + settleMonth?: string; + journalNo?: string; + agentName?: string; + status?: string; + pageNum?: number; + pageSize?: number; +} + +export const accountingApi = { + list: (params?: AccountingSearchParam) => + unwrap<{ list: AccountingJournalResp[]; total: number }>( + api.get('/api/accounting-entries', { params }) + ), + generate: (req: AccountingGenerateReq) => + unwrap(api.post('/api/accounting-entries/generate', req)), + close: (req: AccountingCloseReq) => + unwrap(api.put('/api/accounting-entries/close', req)), +}; diff --git a/ga-frontend/src/api/income.ts b/ga-frontend/src/api/income.ts new file mode 100644 index 0000000..109f124 --- /dev/null +++ b/ga-frontend/src/api/income.ts @@ -0,0 +1,44 @@ +import api, { unwrap } from './request'; + +export interface AgentAnnualIncomeResp extends Record { + incomeId: number; + agentId: number; + agentName?: string; + agentCode?: string; + orgName?: string; + settleYear: number; + totalCommission: number; + totalTaxWithheld: number; + businessIncome: number; + status?: string; + statusName?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface AgentAnnualIncomeSaveReq { + agentId: number; + settleYear: number; + totalCommission: number; + totalTaxWithheld: number; + businessIncome: number; +} + +export interface AgentAnnualIncomeSearchParam { + settleYear?: number; + agentId?: number; + agentName?: string; + pageNum?: number; + pageSize?: number; +} + +export const incomeApi = { + list: (params?: AgentAnnualIncomeSearchParam) => + unwrap<{ list: AgentAnnualIncomeResp[]; total: number }>( + api.get('/api/agent-annual-incomes', { params }) + ), + detail: (id: number) => + unwrap(api.get(`/api/agent-annual-incomes/${id}`)), + regenerate: (year: number) => + unwrap(api.post(`/api/agent-annual-incomes/regenerate`, null, { params: { year } })), +}; diff --git a/ga-frontend/src/api/regulatory.ts b/ga-frontend/src/api/regulatory.ts index 8bf0089..a15c5b4 100644 --- a/ga-frontend/src/api/regulatory.ts +++ b/ga-frontend/src/api/regulatory.ts @@ -40,4 +40,6 @@ export const regulatoryApi = { unwrap(api.put(`/api/regulatory-limits/${id}`, req)), deactivate: (id: number) => unwrap(api.put(`/api/regulatory-limits/${id}/deactivate`)), + activate: (id: number) => + unwrap(api.put(`/api/regulatory-limits/${id}/activate`)), }; diff --git a/ga-frontend/src/api/vat.ts b/ga-frontend/src/api/vat.ts new file mode 100644 index 0000000..cfbc9eb --- /dev/null +++ b/ga-frontend/src/api/vat.ts @@ -0,0 +1,47 @@ +import api, { unwrap } from './request'; + +export interface VatReportResp extends Record { + reportId: number; + reportYear: number; + reportQuarter: number; + salesAmount: number; + salesVat: number; + purchaseAmount: number; + purchaseVat: number; + payableVat: number; + status?: string; + statusName?: string; + reportedAt?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface VatSearchParam { + reportYear?: number; + reportQuarter?: number; + status?: string; + pageNum?: number; + pageSize?: number; +} + +export interface VatRegenerateReq { + reportYear: number; + reportQuarter: number; +} + +export interface VatReportCompleteReq { + ids: number[]; +} + +export const vatApi = { + list: (params?: VatSearchParam) => + unwrap<{ list: VatReportResp[]; total: number }>( + api.get('/api/vat-reports', { params }) + ), + detail: (id: number) => + unwrap(api.get(`/api/vat-reports/${id}`)), + regenerate: (req: VatRegenerateReq) => + unwrap(api.post('/api/vat-reports/regenerate', req)), + complete: (req: VatReportCompleteReq) => + unwrap(api.put('/api/vat-reports/complete', req)), +}; diff --git a/ga-frontend/src/api/withholdingTax.ts b/ga-frontend/src/api/withholdingTax.ts new file mode 100644 index 0000000..b01009a --- /dev/null +++ b/ga-frontend/src/api/withholdingTax.ts @@ -0,0 +1,50 @@ +import api, { unwrap } from './request'; + +export interface WithholdingTaxReportResp extends Record { + reportId: number; + agentId: number; + agentName?: string; + agentCode?: string; + orgName?: string; + reportYear: number; + reportQuarter: number; + totalIncome: number; + withholdingTax: number; + status?: string; + statusName?: string; + reportedAt?: string; + createdAt?: string; + updatedAt?: string; +} + +export interface WithholdingTaxSearchParam { + reportYear?: number; + reportQuarter?: number; + agentId?: number; + agentName?: string; + status?: string; + pageNum?: number; + pageSize?: number; +} + +export interface WithholdingTaxRegenerateReq { + reportYear: number; + reportQuarter: number; +} + +export interface WithholdingTaxReportCompleteReq { + ids: number[]; +} + +export const withholdingTaxApi = { + list: (params?: WithholdingTaxSearchParam) => + unwrap<{ list: WithholdingTaxReportResp[]; total: number }>( + api.get('/api/withholding-tax-reports', { params }) + ), + detail: (id: number) => + unwrap(api.get(`/api/withholding-tax-reports/${id}`)), + regenerate: (req: WithholdingTaxRegenerateReq) => + unwrap(api.post('/api/withholding-tax-reports/regenerate', req)), + complete: (req: WithholdingTaxReportCompleteReq) => + unwrap(api.put('/api/withholding-tax-reports/complete', req)), +}; diff --git a/ga-frontend/src/components/common/DataGrid.tsx b/ga-frontend/src/components/common/DataGrid.tsx index bba6af4..3f7d269 100644 --- a/ga-frontend/src/components/common/DataGrid.tsx +++ b/ga-frontend/src/components/common/DataGrid.tsx @@ -3,7 +3,7 @@ import { Skeleton } from 'antd'; import { AgGridReact } from '@ag-grid-community/react'; import { ModuleRegistry } from '@ag-grid-community/core'; import { ClientSideRowModelModule } from '@ag-grid-community/client-side-row-model'; -import type { ColDef, GridReadyEvent, GridApi, RowClassParams, RowStyle } from '@ag-grid-community/core'; +import type { ColDef, GridReadyEvent, GridApi, RowClassParams, RowStyle, SelectionChangedEvent } from '@ag-grid-community/core'; import '@ag-grid-community/styles/ag-grid.css'; import '@ag-grid-community/styles/ag-theme-quartz.css'; import { GRAY } from '@/theme/tokens'; @@ -33,6 +33,8 @@ interface Props { rowKey?: keyof T; /** 행별 스타일 콜백 (만료 임박 강조 등) */ getRowStyle?: (params: RowClassParams) => RowStyle | undefined; + rowSelection?: 'single' | 'multiple'; + onSelectionChanged?: (e: SelectionChangedEvent) => void; } /** @@ -50,6 +52,8 @@ export default function DataGrid>({ onCellChanged, rowKey, getRowStyle: getRowStyleProp, + rowSelection: rowSelectionProp = 'single', + onSelectionChanged, }: Props) { const apiRef = useRef | null>(null); @@ -93,7 +97,8 @@ export default function DataGrid>({ rowHeight={56} headerHeight={44} localeText={AG_GRID_LOCALE_KO} - rowSelection="single" + rowSelection={rowSelectionProp} + onSelectionChanged={onSelectionChanged} getRowId={rowKey ? (p) => String(p.data[rowKey]) : undefined} pinnedBottomRowData={pinnedBottomRow ? [pinnedBottomRow as T] : undefined} getRowStyle={(params) => { diff --git a/ga-frontend/src/pages/accounting/AccountingJournalList.tsx b/ga-frontend/src/pages/accounting/AccountingJournalList.tsx new file mode 100644 index 0000000..ec1e8a5 --- /dev/null +++ b/ga-frontend/src/pages/accounting/AccountingJournalList.tsx @@ -0,0 +1,169 @@ +import { useState, useCallback } from 'react'; +import { Alert, Form, Input, Modal, Space, message } from 'antd'; +import { ProCard } from '@ant-design/pro-components'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { ColDef, SelectionChangedEvent } from '@ag-grid-community/core'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import DataGrid from '@/components/common/DataGrid'; +import PermissionButton from '@/components/common/PermissionButton'; +import { accountingApi, AccountingJournalResp, AccountingSearchParam } from '@/api/accounting'; +import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; + +const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; + +const COLUMNS: ColDef[] = [ + { field: 'journalNo', headerName: '분개번호', width: 140, pinned: 'left', rowGroup: false }, + { field: 'settleMonth', headerName: '정산월', width: 100 }, + { field: 'agentName', headerName: '설계사', width: 120 }, + { field: 'contractNo', headerName: '계약번호', width: 140 }, + { field: 'accountCode', headerName: '계정코드', width: 110 }, + { field: 'accountName', headerName: '계정명', width: 150 }, + { field: 'debitAmount', headerName: '차변', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'creditAmount', headerName: '대변', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'description', headerName: '적요', flex: 1 }, + { field: 'statusName', headerName: '상태', width: 90 }, + { field: 'closedAt', headerName: '마감일시', width: 140 }, +]; + +const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }; + +export default function AccountingJournalList() { + const qc = useQueryClient(); + const currentMonth = dayjs().format('YYYYMM'); + const [params, setParams] = useState({ + settleMonth: currentMonth, + pageSize: 500, + }); + const [selectedRows, setSelectedRows] = useState([]); + const [closeModalOpen, setCloseModalOpen] = useState(false); + const [generateModalOpen, setGenerateModalOpen] = useState(false); + const [closeForm] = Form.useForm(); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['accounting-journals', params], + queryFn: () => accountingApi.list(params), + retry: false, + }); + + const rows = data?.list ?? []; + + const handleSelectionChanged = useCallback((e: SelectionChangedEvent) => { + setSelectedRows(e.api.getSelectedRows()); + }, []); + + const handleGenerate = async () => { + try { + await accountingApi.generate({ settleMonth: params.settleMonth ?? currentMonth }); + message.success('분개 생성 완료'); + qc.invalidateQueries({ queryKey: ['accounting-journals'] }); + setGenerateModalOpen(false); + } catch { /* request.ts 처리 */ } + }; + + const handleClose = async () => { + const values = await closeForm.validateFields(); + const ids = selectedRows.map((r) => r.entryId); + try { + await accountingApi.close({ journalNo: values.journalNo, entryIds: ids }); + message.success('마감 완료'); + qc.invalidateQueries({ queryKey: ['accounting-journals'] }); + setCloseModalOpen(false); + closeForm.resetFields(); + setSelectedRows([]); + } catch { /* request.ts 처리 */ } + }; + + return ( + + setGenerateModalOpen(true)} + > + 분개 생성 + + setCloseModalOpen(true)} + > + 마감 ({selectedRows.length}건) + + + } + > + {isError && ( + + )} + + setParams({ ...v as AccountingSearchParam, pageSize: 500 })} + onReset={() => setParams({ settleMonth: currentMonth, pageSize: 500 })} + /> + + + + rows={rows} + columns={COLUMNS} + loading={isLoading} + height={620} + rowKey="entryId" + rowSelection="multiple" + onSelectionChanged={handleSelectionChanged} + /> + + + {/* 분개 생성 확인 모달 */} + setGenerateModalOpen(false)} + okText="생성" + cancelText="취소" + > +

{params.settleMonth} 정산월의 회계 분개를 생성하시겠습니까?

+
+ + {/* 마감 처리 모달 */} + { setCloseModalOpen(false); closeForm.resetFields(); }} + okText="마감" + cancelText="취소" + > +

선택한 {selectedRows.length}건의 분개를 마감합니다.

+
+ + + +
+
+
+ ); +} diff --git a/ga-frontend/src/pages/income/AgentAnnualIncomeList.tsx b/ga-frontend/src/pages/income/AgentAnnualIncomeList.tsx new file mode 100644 index 0000000..043719c --- /dev/null +++ b/ga-frontend/src/pages/income/AgentAnnualIncomeList.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react'; +import { Alert, Modal, message } from 'antd'; +import { ProCard } from '@ant-design/pro-components'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { ColDef } from '@ag-grid-community/core'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import DataGrid from '@/components/common/DataGrid'; +import PermissionButton from '@/components/common/PermissionButton'; +import { incomeApi, AgentAnnualIncomeResp, AgentAnnualIncomeSearchParam } from '@/api/income'; +import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; + +const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; + +const COLUMNS: ColDef[] = [ + { field: 'settleYear', headerName: '귀속연도', width: 100, pinned: 'left' }, + { field: 'agentCode', headerName: '설계사코드', width: 120 }, + { field: 'agentName', headerName: '설계사명', width: 120 }, + { field: 'orgName', headerName: '소속조직', width: 160 }, + { field: 'totalCommission', headerName: '총수수료', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'totalTaxWithheld', headerName: '원천징수세액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'businessIncome', headerName: '사업소득', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'statusName', headerName: '상태', width: 90 }, + { field: 'updatedAt', headerName: '최종수정', width: 140 }, +]; + +const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }; + +export default function AgentAnnualIncomeList() { + const qc = useQueryClient(); + const currentYear = dayjs().year(); + const [params, setParams] = useState({ + settleYear: currentYear, + pageSize: 500, + }); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['annual-income', params], + queryFn: () => incomeApi.list(params), + retry: false, + }); + + const rows = data?.list ?? []; + + const pinnedRow = rows.length > 0 ? { + agentName: '합계', + totalCommission: rows.reduce((s, r) => s + (r.totalCommission ?? 0), 0), + totalTaxWithheld: rows.reduce((s, r) => s + (r.totalTaxWithheld ?? 0), 0), + businessIncome: rows.reduce((s, r) => s + (r.businessIncome ?? 0), 0), + } as Partial : undefined; + + const handleRegenerate = () => { + Modal.confirm({ + title: '연말 사업소득 재집계', + content: `${params.settleYear ?? currentYear}년 데이터를 재집계하시겠습니까? 기존 데이터가 갱신됩니다.`, + okText: '재집계', + cancelText: '취소', + onOk: async () => { + try { + await incomeApi.regenerate(params.settleYear ?? currentYear); + message.success('재집계 완료'); + qc.invalidateQueries({ queryKey: ['annual-income'] }); + } catch { /* request.ts 처리 */ } + }, + }); + }; + + return ( + + 재집계 + + } + > + {isError && ( + + )} + + setParams({ ...v as AgentAnnualIncomeSearchParam, pageSize: 500 })} + onReset={() => setParams({ settleYear: currentYear, pageSize: 500 })} + /> + + + + rows={rows} + columns={COLUMNS} + loading={isLoading} + height={620} + rowKey="incomeId" + pinnedBottomRow={pinnedRow} + /> + + + ); +} diff --git a/ga-frontend/src/pages/rule/RegulatoryLimitList.tsx b/ga-frontend/src/pages/rule/RegulatoryLimitList.tsx index 6c4a406..4c0204f 100644 --- a/ga-frontend/src/pages/rule/RegulatoryLimitList.tsx +++ b/ga-frontend/src/pages/rule/RegulatoryLimitList.tsx @@ -59,6 +59,20 @@ export default function RegulatoryLimitList() { }); }; + const onActivate = (record: RegulatoryLimitResp) => { + Modal.confirm({ + title: '규제 한도 활성화', + content: `[${record.limitTypeName ?? record.limitType}] 한도를 활성화하시겠습니까?`, + okText: '활성화', + cancelText: '취소', + onOk: async () => { + await regulatoryApi.activate(record.limitId); + message.success('활성화 완료'); + actionRef.current?.reload(); + }, + }); + }; + const onSubmit = async (v: any) => { const req: RegulatoryLimitSaveReq = { ...v, @@ -108,9 +122,12 @@ export default function RegulatoryLimitList() { render: (_, r) => [ openEdit(r)}>수정, - r.isActive && ( + r.isActive ? ( onDeactivate(r)}>비활성화 + ) : ( + onActivate(r)}>활성화 ), ], }, diff --git a/ga-frontend/src/pages/tax/VatReportList.tsx b/ga-frontend/src/pages/tax/VatReportList.tsx new file mode 100644 index 0000000..c395b40 --- /dev/null +++ b/ga-frontend/src/pages/tax/VatReportList.tsx @@ -0,0 +1,154 @@ +import { useState, useCallback } from 'react'; +import { Alert, Modal, Space, message } from 'antd'; +import { ProCard } from '@ant-design/pro-components'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { ColDef, SelectionChangedEvent } from '@ag-grid-community/core'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import DataGrid from '@/components/common/DataGrid'; +import PermissionButton from '@/components/common/PermissionButton'; +import { vatApi, VatReportResp, VatSearchParam } from '@/api/vat'; +import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; + +const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; + +const COLUMNS: ColDef[] = [ + { field: 'reportYear', headerName: '신고연도', width: 100, pinned: 'left' }, + { field: 'reportQuarter', headerName: '분기', width: 80, pinned: 'left', valueFormatter: (p) => `${p.value}Q` }, + { field: 'salesAmount', headerName: '매출액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'salesVat', headerName: '매출세액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'purchaseAmount', headerName: '매입액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'purchaseVat', headerName: '매입세액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'payableVat', headerName: '납부세액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'statusName', headerName: '상태', width: 90 }, + { field: 'reportedAt', headerName: '신고일시', width: 140 }, +]; + +const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }; + +export default function VatReportList() { + const qc = useQueryClient(); + const currentYear = dayjs().year(); + const currentQuarter = Math.ceil((dayjs().month() + 1) / 3); + + const [params, setParams] = useState({ + reportYear: currentYear, + reportQuarter: currentQuarter, + pageSize: 200, + }); + const [selectedRows, setSelectedRows] = useState([]); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['vat-reports', params], + queryFn: () => vatApi.list(params), + retry: false, + }); + + const rows = data?.list ?? []; + + const pinnedRow = rows.length > 0 ? { + reportYear: '합계' as unknown as number, + salesAmount: rows.reduce((s, r) => s + (r.salesAmount ?? 0), 0), + salesVat: rows.reduce((s, r) => s + (r.salesVat ?? 0), 0), + purchaseAmount: rows.reduce((s, r) => s + (r.purchaseAmount ?? 0), 0), + purchaseVat: rows.reduce((s, r) => s + (r.purchaseVat ?? 0), 0), + payableVat: rows.reduce((s, r) => s + (r.payableVat ?? 0), 0), + } as Partial : undefined; + + const handleSelectionChanged = useCallback((e: SelectionChangedEvent) => { + setSelectedRows(e.api.getSelectedRows()); + }, []); + + const handleRegenerate = () => { + Modal.confirm({ + title: '부가세 재집계', + content: `${params.reportYear}년 ${params.reportQuarter}Q 데이터를 재집계하시겠습니까?`, + okText: '재집계', + cancelText: '취소', + onOk: async () => { + try { + await vatApi.regenerate({ + reportYear: params.reportYear ?? currentYear, + reportQuarter: params.reportQuarter ?? currentQuarter, + }); + message.success('재집계 완료'); + qc.invalidateQueries({ queryKey: ['vat-reports'] }); + } catch { /* request.ts 처리 */ } + }, + }); + }; + + const handleComplete = () => { + Modal.confirm({ + title: '신고완료 처리', + content: `선택한 ${selectedRows.length}건을 신고완료 처리하시겠습니까?`, + okText: '신고완료', + cancelText: '취소', + onOk: async () => { + try { + // NOTE: ids 필드명은 T19 컨트롤러 확인 후 수정 필요 + await vatApi.complete({ ids: selectedRows.map((r) => r.reportId) }); + message.success('신고완료 처리됨'); + qc.invalidateQueries({ queryKey: ['vat-reports'] }); + setSelectedRows([]); + } catch { /* request.ts 처리 */ } + }, + }); + }; + + return ( + + + 재집계 + + + 신고완료 ({selectedRows.length}건) + + + } + > + {isError && ( + + )} + + setParams({ ...v as VatSearchParam, pageSize: 200 })} + onReset={() => setParams({ reportYear: currentYear, reportQuarter: currentQuarter, pageSize: 200 })} + /> + + + + rows={rows} + columns={COLUMNS} + loading={isLoading} + height={520} + rowKey="reportId" + rowSelection="multiple" + onSelectionChanged={handleSelectionChanged} + pinnedBottomRow={pinnedRow} + /> + + + ); +} diff --git a/ga-frontend/src/pages/tax/WithholdingTaxReportList.tsx b/ga-frontend/src/pages/tax/WithholdingTaxReportList.tsx new file mode 100644 index 0000000..7f1cc60 --- /dev/null +++ b/ga-frontend/src/pages/tax/WithholdingTaxReportList.tsx @@ -0,0 +1,156 @@ +import { useState, useCallback } from 'react'; +import { Alert, Modal, Space, message } from 'antd'; +import { ProCard } from '@ant-design/pro-components'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import type { ColDef, SelectionChangedEvent } from '@ag-grid-community/core'; +import dayjs from 'dayjs'; +import PageContainer from '@/components/common/PageContainer'; +import SearchForm from '@/components/common/SearchForm'; +import DataGrid from '@/components/common/DataGrid'; +import PermissionButton from '@/components/common/PermissionButton'; +import { + withholdingTaxApi, + WithholdingTaxReportResp, + WithholdingTaxSearchParam, +} from '@/api/withholdingTax'; +import { GRAY, RADIUS, SHADOW } from '@/theme/tokens'; + +const fmt = (v: unknown) => (v as number)?.toLocaleString() ?? '-'; + +const COLUMNS: ColDef[] = [ + { field: 'reportYear', headerName: '신고연도', width: 100, pinned: 'left' }, + { field: 'reportQuarter', headerName: '분기', width: 80, pinned: 'left', valueFormatter: (p) => `${p.value}Q` }, + { field: 'agentCode', headerName: '설계사코드', width: 120 }, + { field: 'agentName', headerName: '설계사명', width: 120 }, + { field: 'orgName', headerName: '소속조직', width: 160 }, + { field: 'totalIncome', headerName: '지급총액', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'withholdingTax', headerName: '원천징수세', flex: 1, type: 'numericColumn', valueFormatter: (p) => fmt(p.value) }, + { field: 'statusName', headerName: '상태', width: 90 }, + { field: 'reportedAt', headerName: '신고일시', width: 140 }, +]; + +const cardStyle = { background: '#fff', borderRadius: RADIUS.lg, border: `1px solid ${GRAY[100]}`, boxShadow: SHADOW.sm }; + +export default function WithholdingTaxReportList() { + const qc = useQueryClient(); + const currentYear = dayjs().year(); + const currentQuarter = Math.ceil((dayjs().month() + 1) / 3); + + const [params, setParams] = useState({ + reportYear: currentYear, + reportQuarter: currentQuarter, + pageSize: 500, + }); + const [selectedRows, setSelectedRows] = useState([]); + + const { data, isLoading, isError } = useQuery({ + queryKey: ['withholding-tax', params], + queryFn: () => withholdingTaxApi.list(params), + retry: false, + }); + + const rows = data?.list ?? []; + + const pinnedRow = rows.length > 0 ? { + agentName: '합계', + totalIncome: rows.reduce((s, r) => s + (r.totalIncome ?? 0), 0), + withholdingTax: rows.reduce((s, r) => s + (r.withholdingTax ?? 0), 0), + } as Partial : undefined; + + const handleSelectionChanged = useCallback((e: SelectionChangedEvent) => { + setSelectedRows(e.api.getSelectedRows()); + }, []); + + const handleRegenerate = () => { + Modal.confirm({ + title: '원천세 재집계', + content: `${params.reportYear}년 ${params.reportQuarter}Q 데이터를 재집계하시겠습니까?`, + okText: '재집계', + cancelText: '취소', + onOk: async () => { + try { + await withholdingTaxApi.regenerate({ + reportYear: params.reportYear ?? currentYear, + reportQuarter: params.reportQuarter ?? currentQuarter, + }); + message.success('재집계 완료'); + qc.invalidateQueries({ queryKey: ['withholding-tax'] }); + } catch { /* request.ts 처리 */ } + }, + }); + }; + + const handleComplete = () => { + Modal.confirm({ + title: '신고완료 처리', + content: `선택한 ${selectedRows.length}건을 신고완료 처리하시겠습니까?`, + okText: '신고완료', + cancelText: '취소', + onOk: async () => { + try { + // NOTE: ids 필드명은 T18 컨트롤러 확인 후 수정 필요 + await withholdingTaxApi.complete({ ids: selectedRows.map((r) => r.reportId) }); + message.success('신고완료 처리됨'); + qc.invalidateQueries({ queryKey: ['withholding-tax'] }); + setSelectedRows([]); + } catch { /* request.ts 처리 */ } + }, + }); + }; + + return ( + + + 재집계 + + + 신고완료 ({selectedRows.length}건) + + + } + > + {isError && ( + + )} + + setParams({ ...v as WithholdingTaxSearchParam, pageSize: 500 })} + onReset={() => setParams({ reportYear: currentYear, reportQuarter: currentQuarter, pageSize: 500 })} + /> + + + + rows={rows} + columns={COLUMNS} + loading={isLoading} + height={620} + rowKey="reportId" + rowSelection="multiple" + onSelectionChanged={handleSelectionChanged} + pinnedBottomRow={pinnedRow} + /> + + + ); +}