Merge branch 'main' into refactor/backend

main의 negodata 기능 개발분(대시보드·회원관리/OWNER·알림함·목표가/앵커링·
견적 UX·백엔드 테스트 스위트 등 21커밋)을 backend 리팩토링 브랜치로 통합.

- 충돌: backend/router/v1/negotiation/protocol.py 1건 해결.
  refactor의 Field(description=...) 형식 유지 + main의 qt_type enum 설명
  (3=신규협상, 4=신규견적) 반영.
- 나머지(models.py, enums.py 등)는 자동 병합.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
민헌 2026-07-02 09:01:48 +09:00
commit 0ac37625fc
224 changed files with 9106 additions and 967 deletions

View File

@ -85,6 +85,9 @@ class items(MAIN_BASE):
vat_yn = Column(Boolean, nullable=True) # 부가세 포함 여부
delivery_fee_yn = Column(Boolean, nullable=True) # 배송비 포함 여부
internet_lowest_price_yn = Column(Boolean, nullable=False, server_default=text("false")) # 최저가 솔루션 보조 컬럼
internet_lowest_price = Column(BigInteger, nullable=True)
purchase_price = Column(BigInteger, nullable=True)
selling_price = Column(BigInteger, nullable=True)
category_type = Column(Integer, nullable=False, server_default=text("1")) # 카테고리 조회용 자동 증가 숫자
created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC)
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신)
@ -106,8 +109,9 @@ class sessions(MAIN_BASE):
supplier_id = Column(UUID(as_uuid=True), nullable=False) # 대상 공급사(partner.suppliers.supplier_id)
qt_number = Column(String(30), nullable=False) # 견적번호(스냅샷)
qt_round = Column(Integer, nullable=False) # 견적 라운드(스냅샷)
qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적 (QtType)
qt_type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType)
target_price = Column(BigInteger, nullable=False) # 목표가(원)
target_anchoring_price = Column(BigInteger, nullable=True)
status = Column(SmallInteger, nullable=False) # 진행 상태 (SessionStatus 코드)
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각
@ -135,7 +139,7 @@ class quotations(MAIN_BASE):
version_id = Column(UUID(as_uuid=True), nullable=False) # 버전(card.versions.version_id)
name = Column(String(50), nullable=False) # 견적명
number = Column(String(30), nullable=False) # 견적번호
type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적 (QtType)
type = Column(SmallInteger, nullable=False) # 견적 유형: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적 (QtType)
round = Column(Integer, nullable=False, server_default=text("1")) # 재견적 회차
status = Column(SmallInteger, nullable=False) # 진행 상태 (QuotationStatus 코드)
start_time = Column(DateTime(timezone=True), nullable=False) # 견적 시작 시각
@ -144,6 +148,8 @@ class quotations(MAIN_BASE):
manager_email = Column(String(255), nullable=True) # 담당자 이메일
manager_contact_number = Column(String(20), nullable=True) # 담당자 연락처
memo = Column(String(100), nullable=True) # 메모
md_price = Column(BigInteger, nullable=True)
supplier_type = Column(SmallInteger, nullable=True)
iteration = Column(Integer, nullable=False, server_default=text("0")) # 반복 횟수
preferred_sp_yn = Column(Boolean, nullable=True) # 선호 공급사 지정 여부
preferred_sp_id = Column(UUID(as_uuid=True), nullable=True) # 선호 공급사(partner.suppliers.supplier_id)

View File

@ -103,10 +103,13 @@ class TokenType(Enum):
class QtType(Enum):
"""견적/세션 유형 코드. quotation.quotations.type / negotiation.sessions.qt_type."""
"""견적/세션 유형 코드. quotation.quotations.type / negotiation.sessions.qt_type.
신규/재 × 협상(1:1)/견적(1:N). 1·2(재)는 기존 데이터 보존 위해 고정, 신규는 3·4."""
RENEGO = 1 # 재협상(1:1)
REQUOTE = 2 # 재견적(1:N)
RENEGO = 1 # 재협상(1:1)
REQUOTE = 2 # 재견적(1:N)
NEW_NEGO = 3 # 신규협상(1:1)
NEW_QUOTE = 4 # 신규견적(1:N)
class SessionStatus(Enum):

View File

@ -7,7 +7,7 @@ from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
class ListItem(WebPacketProtocol):
session_id: str = ""
session_status: int = Field(0, description="세션 상태 코드 (SessionStatus: 1=생성 2=진행중 3=완료 4=미참여 5=거부)")
qt_type: int = Field(0, description="견적 종류 코드 (1=재협상, 2=재견적)")
qt_type: int = Field(0, description="견적 종류 코드 (1=재협상, 2=재견적, 3=신규협상, 4=신규견적)")
qt_number: str = ""
qt_end_time: str = Field("", description="견적 마감 시각 (ISO 8601)")
item_code: str = ""

View File

@ -22,7 +22,7 @@ async def list_sessions(
credentials: HTTPAuthorizationCredentials = Depends(security),
service: NegotiationService = Depends(),
status: Optional[int] = Query(None, description="세션 상태 코드 (SessionStatus)"),
qt_type: Optional[int] = Query(None, description="견적 유형 코드 (QtType: 1=재협상, 2=재견적)"),
qt_type: Optional[int] = Query(None, description="견적 유형 코드 (QtType: 1=재협상, 2=재견적, 3=신규협상, 4=신규견적)"),
order: str = Query("asc", description="마감일 정렬: asc(임박순)/desc"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),

View File

@ -60,7 +60,7 @@ VALUES
('b0000000-0000-0000-0000-000000000006','e0000000-0000-0000-0000-000000000000','e0000000-0000-0000-0000-000000000000','회의실 대형 디스플레이 65인치','IMK-10236','QM65R','삼성전자', 2890000);
-- 5) 견적 6개 (end_time = 마감 진실값)
-- type: 1=재협상(RENEGO) 2=재견적(REQUOTE) / status: 1=생성 2=진행중 3=마감
-- type: 1=재협상(RENEGO) 2=재견적(REQUOTE) 3=신규협상(NEW_NEGO) 4=신규견적(NEW_QUOTE) / status: 1=생성 2=진행중 3=마감
INSERT INTO quotation.quotations
(qt_id, user_id, qt_setting_id, version_id, name, number, type, round, status, start_time, end_time)
VALUES

View File

@ -71,13 +71,15 @@ export function useChatController(sessionId: string) {
// 진입 로드 실패. 권한 없음/없는 세션(잘못된 접근)이면 토스트 후 목록으로 복귀시킨다.
const loadError = initQuery.error ?? messagesQuery.error
const isInvalidAccess = isApiError(loadError) && INVALID_ACCESS_CODES.has(loadError.code)
const redirectedRef = useRef(false)
// 이미 리다이렉트한 sessionId 를 기록(boolean 이 아니라 sessionId). 리마운트 없이 sessionId 가 바뀌면
// (브라우저 뒤로/앞으로 등) 값이 달라져 가드가 자연 해제 → 두 번째 무권한 세션도 토스트+복귀가 동작한다.
const redirectedSessionRef = useRef<string | null>(null)
useEffect(() => {
if (!isInvalidAccess || redirectedRef.current) return
redirectedRef.current = true
if (!isInvalidAccess || redirectedSessionRef.current === sessionId) return
redirectedSessionRef.current = sessionId
toast.error('잘못된 접근입니다.')
navigate('/list', { replace: true })
}, [isInvalidAccess, navigate])
}, [isInvalidAccess, sessionId, navigate])
// init 메타 → 스토어
useEffect(() => {

View File

@ -1,6 +1,6 @@
# Negodata Backend
DerbyMasters_Server 아키텍처를 이식한 FastAPI 골격. 기능은 **JWT id/pw 로그인**만 예시 구현.
DerbyMasters_Server 아키텍처를 이식한 FastAPI 백엔드. 인증(JWT) 위에 **견적·협력사·상품·견적설정·대시보드·알림·협상카드·회사유저관리** 도메인과 **견적 마감 스케줄러**를 구현.
[negosium-backend](../../backend/README.md) 와 동일 구조이며, 실행·테스트·벤치마크 종합은 [레포 최상위 README](../../README.md) 참고.
## 디렉토리 구조
@ -9,19 +9,20 @@ DerbyMasters_Server 아키텍처를 이식한 FastAPI 골격. 기능은 **JWT id
negodata/backend/
├── web_main.py # 엔트리포인트 (uvicorn)
├── config/ # 환경설정 (APP_ENV 별 toml 로드)
├── conftest.py, tests/ # pytest (test DB 자동 create/drop) — 아래 '테스트'
├── common/
│ ├── enums.py # ErrorType / DBType / DBWRType / EXCEPTION_*
│ ├── enums.py # ErrorType / 코드값 enum / EXCEPTION_*
│ ├── models/gmodel.py # 프로토콜 베이스 (WebPacketProtocol 등)
│ └── database/
│ ├── db_session_manager.py# ★ DB Read/Write + 람다 실행 핵심
│ └── model/models.py # ORM 모델 (tbl_account)
├── crud/user_crud.py # DB 접근 (I*CRUD 인터페이스 + 구현)
├── services/auth_service.py # 비즈니스 로직
│ └── model/models.py # ORM 모델 (companies·users·quotations·sessions·items·suppliers·notifications·cards …)
├── crud/ # 도메인별 DB 접근(I*CRUD 인터페이스+구현): quotation·supplier·item·dashboard·notification·card·user …
├── services/ # 비즈니스 로직: quotation·supplier·item·dashboard·notification·company_user·auth·email …
├── scheduler/ # 견적 마감 크론 잡(만료 마감 · 협상종결 마감)
└── router/
├── router.py # FastAPI app
└── v1/
├── auth/{account,protocol}.py # 엔드포인트 / Req_·Res_
└── validator/dependencies.py # ★ JWT 발급·검증, 해시, RemoveNoneResponse
├── router.py # FastAPI app (CORS 등)
└── v1/ # 도메인별 라우터: auth·quotation·quotation_setting·supplier·item·card·dashboard·notification·company
└── validator/dependencies.py # ★ JWT 발급·검증, 해시(bcrypt), RemoveNoneResponse, RequireOwner
```
## 핵심 패턴
@ -40,22 +41,53 @@ negodata/backend/
- **ResponseNone**: 응답의 `None` 필드 재귀 제거(`RemoveNoneResponse`).
- **bcrypt 비차단**: `GetHashedPW`/`VerifyPW` 를 `asyncio.to_thread` 로 오프로드(이벤트 루프 비차단). → [벤치마크](../../README.md#성능--벤치마크)
## 엔드포인트
| Method | Path | 설명 |
|---|---|---|
| POST | `/v1/auth/create` | 계정 생성 (pw bcrypt 해시) |
| POST | `/v1/auth/login` | 로그인, access/refresh 토큰 발급 |
| POST | `/v1/auth/refresh_token` | access 토큰 재발급 (refresh 필요) |
| GET | `/v1/auth/me` | 내 정보 (access 토큰 필요) |
## API 도메인 (`/v1/*`)
전체 스펙은 실행 후 **http://localhost:9400/docs** (Swagger). 주요 도메인:
## 실행 / 테스트
| prefix | 요약 |
|---|---|
| `/v1/auth` | 로그인 · access 토큰 재발급 · 내 정보(`me`). ※ 무인증 계정 생성은 제거됨 |
| `/v1/company/user` | 최고관리자(OWNER) 전용 — 자기 회사 직원 계정 생성·관리 |
| `/v1/quotation` | 견적 생성·목록·단건·마감·재생성 + 세션·채팅·낙찰결과·카드·초청메일 |
| `/v1/quotation-setting` | 견적 설정(마진율 등) — **유저별** 소유 |
| `/v1/supplier`, `/v1/item` | 협력사 / 상품 CRUD (회사 스코프) |
| `/v1/card` | 협상 카드 |
| `/v1/dashboard` | 요약(회사 전체 + 내 견적) |
| `/v1/notification` | 알림함(목록 · 읽음 처리) |
> 인증 헤더: `Authorization: Bearer <access_token>`. 회사 소유 자원은 토큰의 회사로 스코프되고, 계정 관리는 OWNER 만 가능.
## 실행
```bash
# 레포 최상위에서 docker compose up -d (backend만; DB 는 외부 PostgreSQL). 상세는 루트 README.
cd negodata/backend
pip install -r requirements.txt # 실행
python web_main.py # APP_ENV 기본 local
pip install pytest pytest-asyncio httpx # 테스트 도구
python -m pytest
pip install -r requirements.txt
python web_main.py # APP_ENV 기본 local → http://localhost:9400/docs
```
- 서버: http://localhost:9400/docs
- 환경: `config.{local,test,docker}.toml` (`APP_ENV` 로 선택, docker 는 DB 호스트=`host.docker.internal`, database=`negodata_db`)
환경: `config.{local,test,prod}.toml` (`APP_ENV` 로 선택).
## 테스트
**테스트는 도커가 아니라 호스트(venv)에서 돌린다** — DB(PostgreSQL)만 도커(`negosium-db`, `127.0.0.1:5432`)면 되고, 앱 컨테이너 안엔 pytest 가 없다. test DB(`negosium_test_db`)는 알아서 만들어졌다 지워지므로 **수동 세팅이 필요 없다.**
```bash
cd negodata/backend
python3 -m venv .venv && source .venv/bin/activate # 최초 1회 (venv 없을 때)
pip install -r requirements.txt # httpx 포함
pip install pytest pytest-asyncio # 테스트 도구(requirements 에 없음)
python -m pytest # 전체 (venv 활성화 상태)
python -m pytest -v # 테스트별 PASS/FAIL
python -m pytest tests/test_company_scope.py # 파일 하나만
python -m pytest -k scope # 이름에 'scope' 든 것만
```
venv 를 활성화(`source .venv/bin/activate`)하지 않으면 `.venv/bin/python -m pytest` 로 직접 지정한다.
(시스템에 `python` 명령이 없거나 pytest 가 venv 밖에 없으면 맨 `python -m pytest` 는 실패한다.)
정상이면 마지막 줄에 `NN passed`.
동작 방식 (전부 [conftest.py](conftest.py) 가 자동 처리 — 손댈 것 없음):
- `APP_ENV` 를 `test` 로 자동 설정 → [config.test.toml](config/config.test.toml) 의 **`negosium_test_db`** 사용(dev DB `negosium_db` 와 완전 분리).
- **세션 시작 시 test DB 를 새로 만들고(CREATE), 끝나면 내린다(DROP).** 매번 현재 모델로 새로 빌드돼 스키마가 낡을 일이 없다. 남는 DB 도 없음.
- 테이블은 `create_all` 로 자동 생성, 매 테스트 전 `TRUNCATE` 로 비워 격리.
- 안전가드: 이름에 `test` 없는 DB 는 만들지도 지우지도 않는다(실 DB 보호).
> 즉 새로 clone 받은 팀원도 **Postgres 만 켜져 있으면 `python -m pytest` 한 방**이면 끝.

View File

@ -156,6 +156,25 @@ class DBSessionManager(Singleton):
raise RuntimeError(err_type.name, err_msg)
return err_type
async def add_with_rowcount(self, db: AsyncSession, query, err_msg="DB Operation Failed") -> tuple[ErrorType, int]:
"""update/delete 등 비-select 쿼리 실행 후 (ErrorType, 영향행수) 반환.
조건부 갱신(WHERE 로 상태를 거른 UPDATE)이 실제로 적용됐는지 판별하는 동시처리 가드용."""
try:
if hasattr(query, "column_descriptions"):
raise RuntimeError("DO NOT USE SELECT QUERY IN DBJOB")
res = await db.execute(query, execution_options=immutabledict({"synchronize_session": "fetch"}))
return ErrorType.SUCCESS, res.rowcount
except IntegrityError as ex:
await db.rollback()
err_type = ErrorType.DB_ALREADY_SAME_KEY
LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}")
return err_type, 0
except Exception as ex:
await db.rollback()
err_type = ErrorType.DB_RUN_FAILED
LOG.e_no_callstack(f"[{err_type.name}] {err_msg=}, {ex=}")
return err_type, 0
async def execute(self, db: AsyncSession, query, err_msg="DB Query Execution Failed", raise_error=True) -> tuple[ErrorType, list]:
"""select 쿼리 실행 후 결과 리스트 반환."""
try:
@ -201,5 +220,22 @@ class DBSessionManager(Singleton):
finally:
await self.end_session(db_type, DBWRType.DB_WRITE.value)
async def execute_lambda_claim(self, db_type: int, func) -> tuple[ErrorType, int]:
"""조건부 변경 쿼리 1건을 한 트랜잭션으로 실행/commit 하고 (ErrorType, 적용행수) 반환.
동시처리 가드용 — func(session) -> (ErrorType, rowcount). 적용행수 0 이면 다른 호출자가 이미 처리한 것.
(Postgres READ COMMITTED 에서 같은 행 UPDATE 는 행 잠금으로 직렬화되어, 진 호출자는 0 을 받는다.)"""
s = await self.start_session(db_type, DBWRType.DB_WRITE.value)
try:
err_type, rowcount = await func(s)
if err_type != ErrorType.SUCCESS:
return err_type, 0
commit_err = await self.run(s)
return commit_err, rowcount
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
finally:
await self.end_session(db_type, DBWRType.DB_WRITE.value)
DB_SESSION_MNG = DBSessionManager()

View File

@ -27,8 +27,8 @@ class _DBTypeMixin:
# ERD 공통 컬럼
class MainTableMixin(_DBTypeMixin):
created_at = Column(DateTime, nullable=False, server_default=_utc_now_sql())
updated_at = Column(DateTime, nullable=False, server_default=_utc_now_sql(), onupdate=_utc_now_sql())
created_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
updated_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql(), onupdate=_utc_now_sql())
deleted = Column(Boolean, nullable=False, server_default=text("false"), default=False)
@ -60,11 +60,24 @@ class users(MainTableMixin, MAIN_BASE):
name = Column(String(50), nullable=True)
email = Column(String(255), nullable=True)
contact_number = Column(String(20), nullable=True)
last_accessed_at = Column(DateTime, nullable=False, server_default=_utc_now_sql())
last_accessed_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
status = Column(SmallInteger, nullable=False, default=UserStatus.ACTIVE.value)
role = Column(SmallInteger, nullable=False, default=UserRole.USER.value)
class notifications(MainTableMixin, MAIN_BASE):
__tablename__ = "notifications"
__table_args__ = {"schema": "company"}
notification_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 수신자(users.user_id) = 견적 작성자
type = Column(SmallInteger, nullable=False) # NotificationType: 1=success(낙찰) 2=regenerated(재생성) 3=failure(결렬)
ref_qt_id = Column(UUID(as_uuid=True), nullable=True) # 관련 견적(quotations.qt_id)
ref_session_id = Column(UUID(as_uuid=True), nullable=True) # 관련 세션(sessions.session_id)
data = Column(JSONB, nullable=True) # 렌더 스냅샷(유형별)
read_at = Column(DateTime(timezone=True), nullable=True) # 읽은 시각(NULL=안읽음)
class items(MainTableMixin, MAIN_BASE):
__tablename__ = "items"
__table_args__ = {"schema": "partner"}
@ -86,6 +99,9 @@ class items(MainTableMixin, MAIN_BASE):
price = Column(BigInteger, nullable=True) # 금액(원), 스키마 BIGINT
internet_lowest_price_yn = Column(Boolean, nullable=False, default=False) # 최저가 솔루션 원자성 보존용
internet_lowest_price = Column(BigInteger, nullable=True)
purchase_price = Column(BigInteger, nullable=True)
selling_price = Column(BigInteger, nullable=True)
moq = Column(String(50), nullable=True) # 최소 주문 수량
lead_time = Column(SmallInteger, nullable=True) # 주문 후 배송 도착까지 시간
@ -121,6 +137,7 @@ class nego_cards(MainTableMixin, MAIN_BASE):
number = Column(String(10), nullable=True) # 식별번호(카드코드)
script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기)
edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON)
usage_type = Column(SmallInteger, nullable=False, default=1)
class wild_cards(MainTableMixin, MAIN_BASE):
@ -133,6 +150,7 @@ class wild_cards(MainTableMixin, MAIN_BASE):
number = Column(String(10), nullable=True) # 식별번호(카드코드)
script = Column(String(255), nullable=True) # 협상 스크립트(평문 미리보기)
edit_script = Column(JSONB, nullable=True) # 편집된 스크립트(Slate JSON)
usage_type = Column(SmallInteger, nullable=False, default=1)
condition = Column(String(255), nullable=True) # 사용 조건(트리거)
available = Column(Boolean, nullable=False, default=False) # 수동 협상 적용 여부(ACTIVE/INACTIVE 매핑)
memo = Column(String(255), nullable=True) # 자유 메모
@ -174,7 +192,7 @@ class quotation_settings(MainTableMixin, MAIN_BASE):
user_id = Column(UUID(as_uuid=True), nullable=True, index=True) # 설정 소유 유저
target_margin_rate = Column(Numeric(8, 6), nullable=False)
anchoring_value = Column(Numeric(8, 6), nullable=False, default=0.01)
card_count = Column(Integer, nullable=False, default=3) # 한 협상 내 협상카드 사용 횟수
card_count = Column(Integer, nullable=False, default=3)
class quotations(MainTableMixin, MAIN_BASE):
@ -188,16 +206,18 @@ class quotations(MainTableMixin, MAIN_BASE):
name = Column(String(50), nullable=False)
number = Column(String(30), nullable=False)
type = Column(SmallInteger, nullable=False) # QuotationType: 1=renego(1:1) / 2=requote(1:N)
type = Column(SmallInteger, nullable=False) # QuotationType: 1=renego(1:1) / 2=requote(1:N) / 3=new_nego(1:1) / 4=new_quote(1:N)
round = Column(Integer, nullable=False, default=1) # 재견적 진행 시 증가
status = Column(SmallInteger, nullable=False) # 진행 상태 코드
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=False)
start_time = Column(DateTime(timezone=True), nullable=False)
end_time = Column(DateTime(timezone=True), nullable=False)
manager_name = Column(String(50), nullable=True)
manager_email = Column(String(255), nullable=True)
manager_contact_number = Column(String(20), nullable=True)
memo = Column(String(100), nullable=True)
md_price = Column(BigInteger, nullable=True)
supplier_type = Column(SmallInteger, nullable=True)
iteration = Column(Integer, nullable=False, default=0)
preferred_sp_yn = Column(Boolean, nullable=True)
@ -220,13 +240,15 @@ class sessions(MainTableMixin, MAIN_BASE):
qt_round = Column(Integer, nullable=False) # 견적 라운드 스냅샷
qt_type = Column(SmallInteger, nullable=False) # QuotationType 스냅샷
target_price = Column(BigInteger, nullable=False) # 목표가(원)
target_anchoring_price = Column(BigInteger, nullable=True)
status = Column(SmallInteger, nullable=False) # SessionStatus 코드
bid_price = Column(BigInteger, nullable=True) # 입찰가(원)
bid_at = Column(DateTime, nullable=True) # 입찰 시각
end_time = Column(DateTime, nullable=False) # 세션 종료 시각
bid_at = Column(DateTime(timezone=True), nullable=True) # 입찰 시각
end_time = Column(DateTime(timezone=True), nullable=False) # 세션 종료 시각
reject_reason = Column(String(255), nullable=True)
reject_price = Column(BigInteger, nullable=True)
reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드
email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송)
class chats(MainTableMixin, MAIN_BASE):

View File

@ -35,6 +35,7 @@ class ErrorType(Enum):
INTERNAL_EXCEPTION = auto()
# http 에러 코드와 겹치지 않게 설정 - router 전용 예외 발생 옵션
HTTP_FORBIDDEN = 403
HTTP_INVALID_CLIENT_REQUEST = 419
HTTP_TO_MANY_REQUEST = 429
HTTP_INVALID_CLIENT_ACCESS = 433
@ -46,6 +47,8 @@ class ErrorType(Enum):
ACCOUNT_INVALID_INFO = 1200
ACCOUNT_ALREADY_EXIST = auto()
ACCOUNT_BLOCKED_USER = auto()
ACCOUNT_NOT_FOUND = auto()
ACCOUNT_FORBIDDEN = auto() # 최고관리자 외 접근 / 다른 회사·최고관리자 대상 변경 시도
# 상품 관련 에러
ITEM_NOT_FOUND = 1300
@ -58,6 +61,7 @@ class ErrorType(Enum):
# 견적 관련 에러
QUOTATION_NOT_FOUND = 1500
QUOTATION_NOT_LATEST_ROUND = auto() # 마지막 차수가 아닌 견적을 재생성하려 함
QUOTATION_TARGET_PRICE_UNAVAILABLE = auto() # md_price·인터넷최저가 둘 다 없어 목표가 산정 불가
# 견적 설정 관련 에러
QUOTATION_SETTING_NOT_FOUND = 1600
@ -70,8 +74,13 @@ class ErrorType(Enum):
IMAGE_TOO_LARGE = auto()
IMAGE_UPLOAD_FAILED = auto()
# 초청 메일 발송 관련 에러
EMAIL_NOT_CONFIGURED = 1900 # ACS/SMTP 둘 다 미설정 — 발송 불가(설정 필요)
EMAIL_SEND_FAILED = auto() # 발송 시도했으나 전부 실패(수신자 0 성공)
# ErrorType 의 HTTP_* 값과 status_code 를 맞춰 router 단에서 raise 한다.
EXCEPTION_FORBIDDEN = HTTPException(status_code=ErrorType.HTTP_FORBIDDEN.value, detail=ErrorType.HTTP_FORBIDDEN.name)
EXCEPTION_INVALID_CLIENT_REQUEST = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_REQUEST.value, detail=ErrorType.HTTP_INVALID_CLIENT_REQUEST.name)
EXCEPTION_TO_MANY_REQUEST = HTTPException(status_code=ErrorType.HTTP_TO_MANY_REQUEST.value, detail=ErrorType.HTTP_TO_MANY_REQUEST.name)
EXCEPTION_INVALID_CLIENT_ACCESS = HTTPException(status_code=ErrorType.HTTP_INVALID_CLIENT_ACCESS.value, detail=ErrorType.HTTP_INVALID_CLIENT_ACCESS.name)
@ -104,10 +113,12 @@ class UserStatus(CodeEnum):
class UserRole(CodeEnum):
"""users.role 코드값."""
"""users.role 코드값. negodata 유저는 전부 회사 직원(관리자측) —
의미 있는 구분은 '직원 계정 관리 권한 유무' 하나뿐이라 2단계로 둔다.
1=일반, 2=최고관리자(직원 계정 생성·관리)."""
USER = 1
MANAGER = 2
OWNER = 2 # 최고관리자: 자기 회사 유저(직원 계정)를 생성·관리
class CompanyStatus(CodeEnum):
@ -118,19 +129,27 @@ class CompanyStatus(CodeEnum):
class QuotationType(CodeEnum):
"""quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N)."""
"""quotations.type 코드값. 신규/재 × 협상(1:1)/견적(1:N).
1=재협상(1:1), 2=재견적(1:N), 3=신규협상(1:1), 4=신규견적(1:N).
기존 데이터 보존 위해 재협상/재견적 코드(1·2)는 고정, 신규는 3·4로 추가."""
RENEGO = 1
REQUOTE = 2
RENEGO = 1 # 재협상(1:1)
REQUOTE = 2 # 재견적(1:N)
NEW_NEGO = 3 # 신규협상(1:1)
NEW_QUOTE = 4 # 신규견적(1:N)
@classmethod
def is_new(cls, code) -> bool:
"""신규(NEW_*) 견적유형이면 True. 목표가 후보(신규=인터넷최저가만)가 이 분기에 의존하므로 한 곳에서만 판단한다."""
return code in (cls.NEW_NEGO.value, cls.NEW_QUOTE.value)
class QuotationStatus(CodeEnum):
"""quotations.status 코드값(SMALLINT). 프론트 견적상태 뱃지와 매핑된다."""
CREATED = 1
ACTIVE = 2
IN_PROGRESS = 2
CLOSED = 3
ON_HOLD = 4
class SessionStatus(CodeEnum):
@ -148,7 +167,17 @@ class CloseOutcome(Enum):
AWARDED = "awarded" # 단독 낙찰 확정
REGENERATED = "regenerated" # 다음 라운드 재생성
CLOSED = "closed" # 그냥 마감
CLOSED = "closed" # 그냥 마감 (선점 실패로 이미 닫혀 있던 경우 포함)
REGEN_FAILED = "regen_failed" # 재생성 시도했으나 실패 — 원본은 CLOSED 인데 다음 라운드가 없음(체인 끊김, 모니터링 필요)
class NotificationType(CodeEnum):
"""company.notifications.type 코드값. 견적 생애 이벤트를 작성자에게 통지. 마감 결과 3종(SUCCESS/REGENERATED/FAILURE)은 close_and_decide 와 1:1. 네이밍은 KTC."""
SUCCESS = 1 # 낙찰(단독 최저가) — KTC SUCCESS
REGENERATED = 2 # 다음 라운드 자동 생성(동가/미참여) — KTC 대응어 없어 negodata 유지
FAILURE = 3 # 결렬: 낙찰 없이 마감(거절/부분/한도) — KTC FAILURE
CREATED = 4 # 견적 생성됨(작성 직후) — 생성 알림
class ChatSender(CodeEnum):
@ -161,7 +190,7 @@ class ChatSender(CodeEnum):
class DeliveryType(CodeEnum):
"""items.delivery_type 코드값. 협상 채팅의 배송형태 선택지와 동일 집합."""
PARTNER = 1 # 협력사배송
SUPPLIER = 1 # 협력사배송
COURIER = 2 # 지정택배배송
PICKUP = 3 # 픽업배송
@ -178,3 +207,22 @@ class CardType(CodeEnum):
NEGO = 1
WILD = 2
class SupplierType(CodeEnum):
"""quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3).
없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0)와 매핑 시 0↔없음 대응."""
NONE = 0 # 없음(미지정)
DISTRIBUTION = 1 # 유통
MANUFACTURE = 2 # 제조
SOLE_AGENCY = 3 # 총판
class CardUsageType(CodeEnum):
"""nego_cards/wild_cards.usage_type 코드값. 협상카드 사용 범위(신규/재 견적·협상 양쪽 적용).
공통=모두 적용(기본), 신규견적전용, 재견적전용."""
COMMON = 1 # 공통(모두) — 기본
NEW = 2 # 신규견적전용
REUSE = 3 # 재견적전용

View File

@ -4,7 +4,7 @@ from typing import Optional
from fastapi import Query
from pydantic import BaseModel, Field
from common.enums import ErrorType
from common.enums import ErrorType, UserRole
class StructModel:
@ -70,9 +70,12 @@ class UserInfo(StructModel):
user_id: str # users.user_id (uuid) — 데이터 스코프 키
id: str # users.id (로그인 아이디) — get_me 재조회 키
company_id: str # users.company_id (uuid) — 멀티테넌트 스코프 키
role: int # users.role (UserRole) — 권한 게이트(최고관리자 등) 판단 키
def __init__(self, *args, **kwargs) -> None:
super().__init__()
# 구버전 토큰(role 미포함) 도 디코딩되도록 기본값을 먼저 깔고 kwargs 로 덮어쓴다.
self.role = UserRole.USER.value
for dictionary in args:
for key in dictionary:
setattr(self, key, dictionary[key])

View File

@ -53,3 +53,23 @@ class StorageConfig(ConfigModel):
azure_blob_sas_token: str = "" # SAS 토큰(쿼리스트링). 만료 있음 — 만료되면 업로드 실패
blob_root: str = "negodata" # 컨테이너 내 최상위 디렉터리(infinith 파일과 분리)
max_image_mb: int = 4 # 업로드 허용 최대 크기(MB). 프론트 ImageDropzone 와 일치
# 협상 초청 메일 발송 설정. services/email.py 가 ACS → SMTP 순으로 시도한다.
# 1순위: Azure Communication Services(ACS) Email — endpoint + accesskey.
# azure_acs_sender 는 검증된 MailFrom 주소(예: donotreply@negodata.o2o.kr). Blob 과 별개 리소스다.
# 2순위(폴백): SMTP — ACS 미설정 시 사용. 둘 다 비우면 발송 시 EmailUnavailable.
class MailConfig(ConfigModel):
azure_acs_endpoint: str = ""
azure_acs_accesskey: str = ""
azure_acs_sender: str = ""
smtp_host: str = ""
smtp_port: int = 587
smtp_user: str = ""
smtp_password: str = ""
smtp_from: str = "Negodata <no-reply@negodata.o2o.kr>"
smtp_starttls: bool = True
@property
def acs_configured(self) -> bool:
return bool(self.azure_acs_endpoint and self.azure_acs_accesskey and self.azure_acs_sender)

View File

@ -1,7 +1,7 @@
import os
from config.config_loader import Configs
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken, StorageConfig
from config.config_models import WebServerConfig, LogConfig, MainDBConfig, JwtToken, StorageConfig, MailConfig
# 실행 환경 결정 (기본 local). 환경변수 APP_ENV 로 변경.
APP_ENV = os.environ.get("APP_ENV", "local")
@ -20,6 +20,8 @@ log_config: LogConfig = configs.get(LogConfig)
main_db_config: MainDBConfig = configs.get(MainDBConfig)
jwt_token_config: JwtToken = configs.get(JwtToken)
storage_config: StorageConfig = configs.get(StorageConfig)
# [MailConfig] 섹션이 없는 toml(구버전)에서도 죽지 않도록 기본값으로 폴백(전 필드 빈 값 → 발송 시 EmailUnavailable).
mail_config: MailConfig = configs.get(MailConfig) or MailConfig()
# DB 접속 env override (config.local.toml 유지, 도커에서 host 만 교체). 로컬은 env 미설정 → toml 그대로.

View File

@ -13,6 +13,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from common.database.model.models import MAIN_BASE
from common.enums import CompanyStatus
from config.server_configs import main_db_config
@ -26,8 +27,47 @@ def _write_url(cfg) -> str:
return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/{cfg.name}"
def _admin_url(cfg) -> str:
"""DB 생성용 관리 접속. CREATE DATABASE 는 대상 DB 안에서 못 하므로 기본 'postgres' DB 로 붙는다."""
pw = f":{cfg.write_pw}" if cfg.write_pw else ""
return f"postgresql+asyncpg://{cfg.write_id}{pw}@{cfg.write_host}:{cfg.write_port}/postgres"
async def _drop_test_db(*, recreate: bool):
"""test DB 를 지운다(있으면). recreate=True 면 지운 뒤 새로 만든다.
WITH (FORCE): 남아있는 커넥션을 끊고 drop (PG13+). 관리 접속은 기본 'postgres' DB."""
engine = create_async_engine(_admin_url(main_db_config), isolation_level="AUTOCOMMIT")
try:
async with engine.connect() as conn:
await conn.execute(text(f'DROP DATABASE IF EXISTS "{main_db_config.name}" WITH (FORCE)'))
if recreate:
await conn.execute(text(f'CREATE DATABASE "{main_db_config.name}"'))
finally:
await engine.dispose()
@pytest_asyncio.fixture(scope="session", autouse=True)
async def _test_db_lifecycle():
"""테스트 세션 동안만 test DB 를 만들고, 끝나면 내린다.
매 세션 '깨끗한 새 DB'로 시작하므로 스키마 낡음(드리프트)이 원천 차단되고, 끝나면 남는 DB 도 없다.
(테이블 구조는 db_engine 의 create_all 이 현재 모델 기준으로 채운다.)
안전가드: 이름에 'test' 있는 DB 만 만들고/지운다(dev DB 보호).
"""
assert "test" in main_db_config.name, (
f"비-test DB('{main_db_config.name}') 는 만들거나 지우지 않는다. APP_ENV=test 로 실행하세요."
)
await _drop_test_db(recreate=True) # 세션 시작: 깨끗한 새 DB
yield
# 세션 종료: 앱 싱글톤 커넥션부터 정리(활성 커넥션 있으면 FORCE 로 끊김) 후 DB 를 내린다.
from common.database.db_session_manager import DB_SESSION_MNG
await DB_SESSION_MNG.dispose_all()
await _drop_test_db(recreate=False)
@pytest_asyncio.fixture
async def db_engine():
async def db_engine(_test_db_lifecycle):
"""테스트용 스키마를 보장하고, 매 테스트 시작 시 테이블을 비워 격리한다.
⚠ 이 픽스처는 TRUNCATE 한다 → dev DB(negosium_db)를 가리키면 실데이터가 날아간다.
@ -50,7 +90,7 @@ async def db_engine():
# negodata 도메인 테이블 전부 비워 격리 (CASCADE: FK 미설정이라 안전망)
await conn.execute(
text(
"TRUNCATE TABLE tbl_account, users, companies, items, suppliers, "
"TRUNCATE TABLE users, companies, items, suppliers, "
"quotation_settings, quotations, sessions RESTART IDENTITY CASCADE"
)
)
@ -65,22 +105,24 @@ async def company_id(db_engine) -> str:
"""
cid = uuid.uuid4()
async with db_engine.begin() as conn:
# status 는 NOT NULL(모델 default 는 ORM 전용이라 raw INSERT 엔 안 먹음) → 명시.
await conn.execute(
text("INSERT INTO companies (company_id, name) VALUES (:cid, :name)"),
{"cid": cid, "name": "테스트사"},
text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"),
{"cid": cid, "name": "테스트사", "status": CompanyStatus.ACTIVE.value},
)
return str(cid)
@pytest_asyncio.fixture(scope="session", autouse=True)
async def _dispose_app_engines():
"""테스트 세션이 끝날 때 앱 싱글톤 엔진을 정리한다.
(이벤트 루프 종료 후 커넥션이 GC 되며 나오는 'Event loop is closed' 경고 제거)
"""
yield
from common.database.db_session_manager import DB_SESSION_MNG
await DB_SESSION_MNG.dispose_all()
@pytest_asyncio.fixture
async def other_company_id(db_engine) -> str:
"""company_id 와 다른 소속사 1개(회사 스코프/IDOR 격리 테스트용)."""
cid = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO companies (company_id, name, status) VALUES (:cid, :name, :status)"),
{"cid": cid, "name": "다른회사", "status": CompanyStatus.ACTIVE.value},
)
return str(cid)
@pytest_asyncio.fixture
@ -91,3 +133,36 @@ async def client(db_engine):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
@pytest_asyncio.fixture
async def auth_headers(db_engine, client, company_id):
"""테스트 유저를 시드하고 로그인 헤더(Bearer)를 돌려주는 팩토리.
무인증 /v1/auth/create 가 제거(최고관리자 회원관리로 일원화)돼 더는 API 로 계정을 못 만든다.
그래서 users 행을 직접 INSERT(비번 bcrypt 해시)한 뒤 살아있는 /v1/auth/login 으로 토큰을 받는다.
company 미지정 시 기본 소속사(company_id 픽스처). role 로 OWNER 계정도 만들 수 있다.
호출: `h = await auth_headers("user1")` / `await auth_headers("userB", other_company_id)`.
"""
from common.enums import UserRole, UserStatus
from router.v1.validator.dependencies import GetHashedPW
async def _make(login_id, company=None, *, password="pw1234", role=UserRole.USER.value, name="n"):
cid = company or company_id
hashed = await GetHashedPW(password)
async with db_engine.begin() as conn:
# status·role 은 NOT NULL — ORM default 는 raw INSERT 에 안 먹으므로 명시(companies.status 와 동일).
await conn.execute(
text(
"INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) "
"VALUES (:uid, :cid, :id, :pw, :name, :status, :role, now())"
),
{
"uid": uuid.uuid4(), "cid": uuid.UUID(cid), "id": login_id, "pw": hashed,
"name": name, "status": UserStatus.ACTIVE.value, "role": role,
},
)
r = await client.post("/v1/auth/login", json={"id": login_id, "password": password})
return {"Authorization": f"Bearer {r.json()['access_token']}"}
return _make

View File

@ -0,0 +1,204 @@
from abc import ABC, abstractmethod
from typing import Tuple
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions, suppliers, users
from common.enums import ErrorType, QuotationStatus
from common.logger import LOG
# 대시보드 집계 CRUD. 모든 조회는 회사 스코프(작성자 user_id→users.company_id) 기준이며,
# owner(user_id) 가 주어지면 '내가 만든 견적'으로 더 좁힌다. quotations 엔 company_id 컬럼이 없어 서브쿼리로 건다.
def _company_scope(company_id, owner) -> list:
conds = [
quotations.deleted == False, # noqa: E712
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
]
if owner is not None:
conds.append(quotations.user_id == owner)
return conds
class IDashboardCRUD(ABC):
@abstractmethod
async def count_in_progress(self, cdb: AsyncSession, company_id, owner) -> Tuple[ErrorType, int]:
pass
@abstractmethod
async def count_created_since(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, int]:
pass
@abstractmethod
async def count_awarded_since(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, int]:
pass
@abstractmethod
async def deadline_soon(self, cdb: AsyncSession, company_id, owner, now, horizon, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
async def awarded(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
async def equal_bid(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
async def ruptured(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
async def email_unsent(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
pass
class DashboardCRUD(IDashboardCRUD):
async def _count(self, cdb: AsyncSession, where) -> Tuple[ErrorType, int]:
err, rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(quotations).where(where))
if err != ErrorType.SUCCESS:
return err, 0
return ErrorType.SUCCESS, (int(rows[0] or 0) if rows else 0)
async def _list_with_count(self, cdb: AsyncSession, where, cols, order_by, limit) -> Tuple[ErrorType, list, int]:
c_err, total = await self._count(cdb, where)
if c_err != ErrorType.SUCCESS:
return c_err, [], 0
l_err, rows = await DB_SESSION_MNG.execute(cdb, select(*cols).where(where).order_by(order_by).limit(limit))
if l_err != ErrorType.SUCCESS:
return l_err, [], 0
return ErrorType.SUCCESS, list(rows), total
async def count_in_progress(self, cdb: AsyncSession, company_id, owner) -> Tuple[ErrorType, int]:
try:
where = and_(*_company_scope(company_id, owner), quotations.status != QuotationStatus.CLOSED.value)
return await self._count(cdb, where)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def count_created_since(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, int]:
try:
where = and_(*_company_scope(company_id, owner), quotations.created_at >= since)
return await self._count(cdb, where)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def count_awarded_since(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, int]:
try:
# 이번 달 낙찰 = 마감 + 단독 최저 선정(preferred_sp_yn=True) + 낙찰(마감) 시각이 기준일 이후.
where = and_(
*_company_scope(company_id, owner),
quotations.status == QuotationStatus.CLOSED.value,
quotations.preferred_sp_yn.is_(True),
quotations.updated_at >= since,
)
return await self._count(cdb, where)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def deadline_soon(self, cdb: AsyncSession, company_id, owner, now, horizon, limit) -> Tuple[ErrorType, list, int]:
try:
where = and_(
*_company_scope(company_id, owner),
quotations.status != QuotationStatus.CLOSED.value,
quotations.end_time >= now,
quotations.end_time <= horizon,
)
cols = (quotations.qt_id, quotations.name, quotations.end_time)
return await self._list_with_count(cdb, where, cols, quotations.end_time.asc(), limit)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def awarded(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
try:
# 낙찰 = 마감 + 단독 최저가 선정(preferred_sp_yn=True). 동가/결렬과 동형(최신 마감순).
where = and_(
*_company_scope(company_id, owner),
quotations.status == QuotationStatus.CLOSED.value,
quotations.preferred_sp_yn.is_(True),
)
cols = (quotations.qt_id, quotations.name)
return await self._list_with_count(cdb, where, cols, quotations.updated_at.desc(), limit)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def equal_bid(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
try:
where = and_(
*_company_scope(company_id, owner),
quotations.status == QuotationStatus.CLOSED.value,
quotations.equal_bid_yn.is_(True),
)
cols = (quotations.qt_id, quotations.name)
return await self._list_with_count(cdb, where, cols, quotations.updated_at.desc(), limit)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def ruptured(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
try:
# 결렬 = 마감됐는데 단독낙찰(preferred_sp_yn)도 동가(equal_bid_yn)도 아님 → 둘 다 NULL(거부/한도로 그냥 마감).
where = and_(
*_company_scope(company_id, owner),
quotations.status == QuotationStatus.CLOSED.value,
quotations.preferred_sp_yn.is_(None),
quotations.equal_bid_yn.is_(None),
)
cols = (quotations.qt_id, quotations.name)
return await self._list_with_count(cdb, where, cols, quotations.updated_at.desc(), limit)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def email_unsent(self, cdb: AsyncSession, company_id, owner, limit) -> Tuple[ErrorType, list, int]:
try:
# 미발송 세션 = email_sent_at IS NULL + 담당자 이메일 보유, 마감 전 견적만. 견적 단위로 묶는다.
# total = 미발송 '견적' 수(distinct qt_id) — 리스트와 일치. 견적별 미발송 협력사 수는 행의 unsent_count.
conds = [
sessions.deleted == False, # noqa: E712
sessions.email_sent_at.is_(None),
suppliers.manager_email.isnot(None),
suppliers.manager_email != "",
quotations.deleted == False, # noqa: E712
quotations.status != QuotationStatus.CLOSED.value,
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
]
if owner is not None:
conds.append(quotations.user_id == owner)
where = and_(*conds)
def _joined(stmt):
return (
stmt.select_from(sessions)
.join(suppliers, suppliers.supplier_id == sessions.supplier_id)
.join(quotations, quotations.qt_id == sessions.quotation_id)
.where(where)
)
c_err, c_rows = await DB_SESSION_MNG.execute(cdb, _joined(select(func.count(func.distinct(quotations.qt_id)))))
if c_err != ErrorType.SUCCESS:
return c_err, [], 0
total = int(c_rows[0] or 0) if c_rows else 0
cnt = func.count().label("cnt")
g_err, rows = await DB_SESSION_MNG.execute(
cdb,
_joined(select(quotations.qt_id, quotations.name, cnt))
.group_by(quotations.qt_id, quotations.name)
.order_by(cnt.desc())
.limit(limit),
)
if g_err != ErrorType.SUCCESS:
return g_err, [], 0
return ErrorType.SUCCESS, list(rows), total
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0

View File

@ -0,0 +1,107 @@
from abc import ABC, abstractmethod
from typing import Tuple
from sqlalchemy import select, func, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import notifications
from common.enums import ErrorType
from common.logger import LOG
# 알림 CRUD. 항상 user_id(수신자)로 스코프한다.
class INotificationCRUD(ABC):
@abstractmethod
async def list_for_user(self, cdb: AsyncSession, user_id, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
async def count_unread(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, int]:
pass
@abstractmethod
async def mark_read(self, cdb: AsyncSession, user_id, notification_id, ts) -> ErrorType:
pass
@abstractmethod
async def mark_all_read(self, cdb: AsyncSession, user_id, ts) -> ErrorType:
pass
class NotificationCRUD(INotificationCRUD):
async def list_for_user(self, cdb: AsyncSession, user_id, skip, limit) -> Tuple[ErrorType, list, int]:
try:
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(
cdb,
select(func.count()).select_from(notifications).where(
notifications.user_id == user_id, notifications.deleted == False # noqa: E712
),
)
if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0
list_err, rows = await DB_SESSION_MNG.execute(
cdb,
select(notifications)
.where(notifications.user_id == user_id, notifications.deleted == False) # noqa: E712
.order_by(notifications.created_at.desc())
.offset(skip)
.limit(limit),
)
if list_err != ErrorType.SUCCESS:
return list_err, [], 0
return ErrorType.SUCCESS, list(rows), total
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def count_unread(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, int]:
try:
err_type, rows = await DB_SESSION_MNG.execute(
cdb,
select(func.count()).select_from(notifications).where(
notifications.user_id == user_id,
notifications.read_at.is_(None),
notifications.deleted == False, # noqa: E712
),
)
if err_type != ErrorType.SUCCESS:
return err_type, 0
return ErrorType.SUCCESS, (int(rows[0] or 0) if rows else 0)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def mark_read(self, cdb: AsyncSession, user_id, notification_id, ts) -> ErrorType:
try:
query = (
update(notifications)
.where(
notifications.notification_id == notification_id,
notifications.user_id == user_id, # 남의 알림 못 건드리게 수신자 스코프
notifications.read_at.is_(None),
)
.values(read_at=ts, updated_at=ts)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def mark_all_read(self, cdb: AsyncSession, user_id, ts) -> ErrorType:
try:
query = (
update(notifications)
.where(
notifications.user_id == user_id,
notifications.read_at.is_(None),
notifications.deleted == False, # noqa: E712
)
.values(read_at=ts, updated_at=ts)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED

View File

@ -8,7 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import (
quotations, sessions, chats, nego_cards, wild_cards, items, suppliers, quotation_settings,
version_nego_cards, version_wild_cards,
version_nego_cards, version_wild_cards, users,
)
from common.enums import ErrorType, QuotationStatus, SessionStatus
from common.logger import LOG
@ -19,12 +19,12 @@ from common.utils.gtime import GTime
class IQuotationCRUD(ABC):
@abstractmethod
async def search(
self, cdb: AsyncSession, search, status, type_, start_from, start_to, skip, limit
self, cdb: AsyncSession, company_id, owner, search, status, type_, start_from, start_to, skip, limit
) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
async def get_by_id(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, quotations]:
async def get_by_id(self, cdb: AsyncSession, qt_id, company_id=None) -> Tuple[ErrorType, quotations]:
pass
@abstractmethod
@ -40,7 +40,11 @@ class IQuotationCRUD(ABC):
pass
@abstractmethod
async def get_target_margin(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, Optional[float]]:
async def get_last_supplier_type(self, cdb: AsyncSession, supplier_id, company_id=None) -> Tuple[ErrorType, Optional[tuple]]:
pass
@abstractmethod
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
pass
@abstractmethod
@ -79,6 +83,18 @@ class IQuotationCRUD(ABC):
async def list_used_cards(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def list_sessions_with_supplier(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
pass
@abstractmethod
async def get_session_with_supplier(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]:
pass
@abstractmethod
async def mark_sessions_emailed(self, cdb: AsyncSession, session_ids, ts) -> ErrorType:
pass
@abstractmethod
async def session_counts(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]:
pass
@ -87,6 +103,10 @@ class IQuotationCRUD(ABC):
async def item_map(self, cdb: AsyncSession, qt_ids) -> Tuple[ErrorType, dict]:
pass
@abstractmethod
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
pass
# ----- 스케줄러(크론) 전용 -----
@abstractmethod
async def list_due_for_close(self, cdb: AsyncSession, now) -> Tuple[ErrorType, list]:
@ -97,7 +117,7 @@ class IQuotationCRUD(ABC):
pass
@abstractmethod
async def list_chain_equal_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]:
async def list_chain_close_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]:
pass
@abstractmethod
@ -120,11 +140,17 @@ class IQuotationCRUD(ABC):
async def bulk_update_sessions_status(self, cdb: AsyncSession, qt_ids, from_statuses: list[int], to_status: int) -> ErrorType:
pass
@abstractmethod
async def claim_for_close(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, int]:
pass
class QuotationCRUD(IQuotationCRUD):
async def search(
self,
cdb: AsyncSession,
company_id,
owner,
search: Optional[str],
status: Optional[str],
type_: Optional[str],
@ -134,7 +160,13 @@ class QuotationCRUD(IQuotationCRUD):
limit: int,
) -> Tuple[ErrorType, list, int]:
try:
conditions = [quotations.deleted == False] # noqa: E712
# 회사 스코프(멀티테넌트): quotations 엔 company_id 가 없어 작성자(user_id)→users.company_id 로 건다.
conditions = [
quotations.deleted == False, # noqa: E712
quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)),
]
if owner:
conditions.append(quotations.user_id == owner) # '내 견적만' — 작성자(user_id)=로그인 유저
if search:
conditions.append(or_(quotations.name.ilike(f"%{search}%"), quotations.number.ilike(f"%{search}%")))
if status:
@ -207,9 +239,27 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}
async def get_by_id(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, quotations]:
async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]:
"""user_id 목록 → {user_id: name}. 견적 목록 '작성자(등록자)' 표기용(company.users 조인)."""
try:
query = select(quotations).where(quotations.qt_id == qt_id, quotations.deleted == False).limit(1) # noqa: E712
if not user_ids:
return ErrorType.SUCCESS, {}
query = select(users.user_id, users.name).where(users.user_id.in_(user_ids))
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, {}
return ErrorType.SUCCESS, {uid: name for uid, name in rows}
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}
async def get_by_id(self, cdb: AsyncSession, qt_id, company_id=None) -> Tuple[ErrorType, quotations]:
try:
# company_id 가 주어지면 회사 스코프(작성자 회사)로 좁힌다 — 남의 회사 견적은 '없음'으로 떨어진다.
conds = [quotations.qt_id == qt_id, quotations.deleted == False] # noqa: E712
if company_id is not None:
conds.append(quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)))
query = select(quotations).where(*conds).limit(1)
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, None
@ -309,35 +359,72 @@ class QuotationCRUD(IQuotationCRUD):
return ErrorType.DB_RUN_FAILED, []
async def get_item_prices(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]:
"""item_id -> price(원, NULL 가능) 매핑. 세션 목표가 계산 입력."""
"""item_id -> (internet_lowest_price, purchase_price, selling_price)(원, NULL 가능) 매핑. 세션 목표가 계산 입력."""
try:
if not item_ids:
return ErrorType.SUCCESS, {}
query = select(items.item_id, items.price).where(
query = select(
items.item_id, items.internet_lowest_price, items.purchase_price, items.selling_price
).where(
items.item_id.in_(item_ids), items.deleted == False # noqa: E712
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, {}
return ErrorType.SUCCESS, {r[0]: r[1] for r in rows}
return ErrorType.SUCCESS, {r[0]: (r[1], r[2], r[3]) for r in rows}
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}
async def get_target_margin(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, Optional[float]]:
"""견적 세팅의 목표 마진율. 세션 목표가 = price / (1 + margin)."""
async def get_last_supplier_type(self, cdb: AsyncSession, supplier_id, company_id=None) -> Tuple[ErrorType, Optional[tuple]]:
"""협력사의 직전 견적 supplier_type. (supplier_type, qt_number) | None.
sessions(supplier_id) ⨝ quotations 에서 supplier_type 가 있는 최신 견적 1건."""
try:
query = select(quotation_settings.target_margin_rate).where(
quotation_settings.qt_setting_id == qt_setting_id
).limit(1)
conds = [
sessions.supplier_id == supplier_id,
quotations.supplier_type.isnot(None),
quotations.deleted == False, # noqa: E712
]
if company_id is not None:
conds.append(quotations.user_id.in_(select(users.user_id).where(users.company_id == company_id)))
query = (
select(quotations.supplier_type, quotations.number)
.join(sessions, sessions.quotation_id == quotations.qt_id)
.where(*conds)
.order_by(quotations.created_at.desc())
.limit(1)
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, None
return ErrorType.SUCCESS, (float(rows[0]) if rows and rows[0] is not None else None)
if not rows:
return ErrorType.SUCCESS, None
return ErrorType.SUCCESS, (rows[0][0], rows[0][1])
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def get_setting_rates(self, cdb: AsyncSession, qt_setting_id) -> Tuple[ErrorType, dict]:
"""견적 세팅의 율: {margin, anchoring}. 목표가·앵커링가 산정 입력. (인터넷 수수료는 상수)"""
try:
query = select(
quotation_settings.target_margin_rate,
quotation_settings.anchoring_value,
).where(quotation_settings.qt_setting_id == qt_setting_id).limit(1)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, {}
if not rows:
return ErrorType.SUCCESS, {}
r = rows[0]
return ErrorType.SUCCESS, {
"margin": float(r[0]) if r[0] is not None else None,
"anchoring": float(r[1]) if r[1] is not None else None,
}
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, {}
async def update_quotation(self, cdb: AsyncSession, qt_id, data: dict) -> ErrorType:
try:
if not data:
@ -348,6 +435,26 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def claim_for_close(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, int]:
"""[동시 마감 가드] 아직 안 닫힌(status != CLOSED, not deleted) 견적만 CLOSED 로 선점 전이.
반환: (ErrorType, 적용행수). 동시 호출 시 Postgres 행 잠금으로 직렬화되어
실제로 CLOSED 로 바꾼 호출자만 1, 이미 닫혀 있던(진 호출자/재처리) 경우는 0 을 받는다.
close_and_decide 가 이 결과로 '마감 판정 권한'을 단 한 번만 갖도록 한다."""
try:
query = (
update(quotations)
.where(
quotations.qt_id == qt_id,
quotations.status != QuotationStatus.CLOSED.value,
quotations.deleted == False, # noqa: E712
)
.values(status=QuotationStatus.CLOSED.value, updated_at=GTime.UTC())
)
return await DB_SESSION_MNG.add_with_rowcount(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def update_sessions_status(self, cdb: AsyncSession, qt_id, from_statuses: list[int], to_status: int) -> ErrorType:
# 견적에 딸린 세션 중 from_statuses 에 속한 것만 to_status 로 일괄 전이(삭제 제외). 다른 상태는 건드리지 않는다.
try:
@ -447,11 +554,15 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def list_chain_equal_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]:
"""[재생성 한도] 같은 견적번호(체인)의 이전 라운드(round < current_round)들의 equal_bid_yn 목록. 삭제 제외.
True=동가로 닫힌 라운드 / 그 외(False·NULL)=미참여로 닫힌 라운드."""
async def list_chain_close_flags(self, cdb: AsyncSession, number, current_round) -> Tuple[ErrorType, list]:
"""[재생성 한도] 같은 견적번호(체인)의 이전 라운드(round < current_round)들의 (preferred_sp_yn, equal_bid_yn) 목록. 삭제 제외.
마감 사유 식별용 표식:
- equal_bid_yn=True → 동가 재생성
- preferred_sp_yn=False AND equal_bid_yn=False → 미참여 재생성
- preferred_sp_yn=True → 단독낙찰(체인 어느 쪽에도 안 셈)
- 둘 다 NULL → 거부/한도 그냥 마감(안 셈)"""
try:
query = select(quotations.equal_bid_yn).where(
query = select(quotations.preferred_sp_yn, quotations.equal_bid_yn).where(
quotations.number == number,
quotations.round < current_round,
quotations.deleted == False, # noqa: E712
@ -549,6 +660,56 @@ class QuotationCRUD(IQuotationCRUD):
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def list_sessions_with_supplier(self, cdb: AsyncSession, qt_id) -> Tuple[ErrorType, list]:
"""견적의 세션 + 공급사(담당자 이메일/이름) 조인. 초청 메일 발송 대상 조회용.
반환: [(session, supplier_name, manager_email), ...] (created_at asc). 행은 인덱스로 언팩."""
try:
query = (
select(sessions, suppliers.name, suppliers.manager_email)
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
.where(sessions.quotation_id == qt_id, sessions.deleted == False) # noqa: E712
.order_by(sessions.created_at.asc())
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, []
return ErrorType.SUCCESS, list(rows)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, []
async def get_session_with_supplier(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, Optional[tuple]]:
"""단일 세션 + 공급사(이름/이메일). 행별 재발송용. 반환: (session, name, email) | None."""
try:
query = (
select(sessions, suppliers.name, suppliers.manager_email)
.outerjoin(suppliers, suppliers.supplier_id == sessions.supplier_id)
.where(sessions.session_id == session_id, sessions.deleted == False) # noqa: E712
)
err_type, rows = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, None
rows = list(rows)
return ErrorType.SUCCESS, (rows[0] if rows else None)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def mark_sessions_emailed(self, cdb: AsyncSession, session_ids, ts) -> ErrorType:
"""발송 성공 세션들의 email_sent_at 을 ts 로 기록(write)."""
try:
if not session_ids:
return ErrorType.SUCCESS
query = (
update(sessions)
.where(sessions.session_id.in_(session_ids))
.values(email_sent_at=ts, updated_at=ts)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def list_chats(self, cdb: AsyncSession, session_id) -> Tuple[ErrorType, list]:
try:
query = (

View File

@ -1,7 +1,7 @@
from abc import ABC, abstractmethod
from typing import Tuple
from typing import Optional, Tuple
from sqlalchemy import select, update
from sqlalchemy import select, func, and_, or_, update
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.db_session_manager import DB_SESSION_MNG
@ -35,6 +35,18 @@ class IUserCRUD(ABC):
async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]:
pass
@abstractmethod
async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
pass
@abstractmethod
async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType:
pass
class UserCRUD(IUserCRUD):
async def get_user_by_login_id(self, cdb: AsyncSession, login_id: str) -> Tuple[ErrorType, users]:
@ -90,3 +102,57 @@ class UserCRUD(IUserCRUD):
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def list_by_company(
self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int
) -> Tuple[ErrorType, list, int]:
try:
conditions = [users.deleted == False, users.company_id == company_id] # noqa: E712
if search:
conditions.append(
or_(
users.id.ilike(f"%{search}%"),
users.name.ilike(f"%{search}%"),
users.email.ilike(f"%{search}%"),
)
)
where = and_(*conditions)
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(cdb, select(func.count()).select_from(users).where(where))
if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0
list_err, rows = await DB_SESSION_MNG.execute(
cdb,
select(users).where(where).order_by(users.created_at.desc()).offset(skip).limit(limit),
)
if list_err != ErrorType.SUCCESS:
return list_err, [], 0
return ErrorType.SUCCESS, list(rows), total
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def get_by_user_id(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, users]:
try:
query = select(users).where(users.user_id == user_id, users.deleted == False).limit(1) # noqa: E712
err_type, row_list = await DB_SESSION_MNG.execute(cdb, query)
if err_type != ErrorType.SUCCESS:
return err_type, None
if len(row_list) != 1:
return ErrorType.DB_INVALID_KEY, None
return ErrorType.SUCCESS, row_list[0]
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, None
async def update_user(self, cdb: AsyncSession, user_id, data: dict) -> ErrorType:
try:
if not data:
return ErrorType.SUCCESS
query = update(users).where(users.user_id == user_id).values(**data)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED

View File

@ -11,3 +11,5 @@ python-multipart
openpyxl
httpx
apscheduler>=3.10
azure-communication-email>=1.0 # 초청 메일 1순위 발송 채널(ACS Email)
aiosmtplib>=3.0 # 초청 메일 폴백(SMTP)

View File

@ -11,11 +11,14 @@ from common.utils.gtime import GTime
from config.server_configs import web_server_config
from scheduler import shutdown_scheduler, start_scheduler
import router.v1.auth.account
import router.v1.company.user
import router.v1.item.item
import router.v1.supplier.supplier
import router.v1.card.card
import router.v1.quotation.quotation
import router.v1.quotation_setting.quotation_setting
import router.v1.dashboard.dashboard
import router.v1.notification.notification
API_SERVER_START_TIME = GTime.UTCStr()
@ -50,7 +53,8 @@ async def log_time(request: Request, call_next):
start_time = time.time()
response = await call_next(request)
elapsed = time.time() - start_time
LOG.d(f"took: {elapsed:.4f} - {request.url.path}")
# status_code 를 함께 남긴다(403/4xx 등을 로그만으로 식별 가능하게).
LOG.d(f"{response.status_code} {request.method} {request.url.path} - {elapsed:.4f}s")
return response
@ -61,8 +65,11 @@ async def healthz():
# 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.<domain>.<file> 를 import 후 include.
app.include_router(router.v1.auth.account.router)
app.include_router(router.v1.company.user.router)
app.include_router(router.v1.item.item.router)
app.include_router(router.v1.supplier.supplier.router)
app.include_router(router.v1.card.card.router)
app.include_router(router.v1.quotation.quotation.router)
app.include_router(router.v1.quotation_setting.quotation_setting.router)
app.include_router(router.v1.dashboard.dashboard.router)
app.include_router(router.v1.notification.notification.router)

View File

@ -4,7 +4,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, IsValidRefreshToken, RemoveNoneResponse
from services.auth_service import AuthService
from .protocol import Req_CreateAccount, Req_Login, Res_CreateAccount, Res_Login, Res_Me, Res_RefreshToken
from .protocol import Req_Login, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken
security = HTTPBearer()
@ -17,13 +17,6 @@ async def login(request: Request, req: Req_Login, service: AuthService = Depends
return RemoveNoneResponse(await service.attempt_login(req.id, req.password, request.client.host))
@router.post(path="/create", response_model=Res_CreateAccount, summary="계정 생성", description="새 계정을 생성한다.")
async def create_account(req: Req_CreateAccount, service: AuthService = Depends()):
return RemoveNoneResponse(
await service.create_account(req.id, req.password, req.company_id, req.name, req.email, req.contact_number, req.role)
)
@router.post(
path="/refresh_token",
dependencies=[Depends(IsValidRefreshToken)],
@ -43,3 +36,13 @@ async def refresh_token(service: AuthService = Depends(), credentials: HTTPAutho
)
async def me(service: AuthService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_me(user_info))
@router.patch(
path="/me",
response_model=Res_Me,
summary="내 정보 수정",
description="본인 이름/이메일/연락처/비밀번호를 수정한다(권한·소속·ID 변경 불가).",
)
async def update_me(req: Req_UpdateMe, service: AuthService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.update_me(user_info, req))

View File

@ -22,18 +22,12 @@ class Res_Login(Res_WebPacketProtocol):
token_type: str = "bearer"
class Req_CreateAccount(AuthProtocol):
id: str = ""
password: str = ""
company_id: str = ""
name: str = ""
email: str = ""
contact_number: str = ""
role: int = UserRole.USER.value
class Res_CreateAccount(Res_WebPacketProtocol):
user_id: str = ""
class Req_UpdateMe(AuthProtocol):
# 본인 정보 수정. role·company·id 는 받지 않는다(자기 권한·소속 변경 불가).
name: Optional[str] = None
email: Optional[str] = None
contact_number: Optional[str] = None
password: Optional[str] = None # 비밀번호 변경(옵션). 비우면 유지
class Res_RefreshToken(Res_WebPacketProtocol):

View File

@ -4,7 +4,7 @@ from typing import Any, Optional
from pydantic import ConfigDict
from common.enums import CardStatus
from common.enums import CardStatus, CardUsageType
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
@ -18,6 +18,7 @@ class Req_CreateCard(CardProtocol):
number: Optional[str] = None
script: Optional[str] = None
edit_script: Optional[Any] = None
usage_type: int = CardUsageType.COMMON.value # 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
status: int = CardStatus.ACTIVE.value # 와일드카드 적용 여부(available 매핑). 일반카드는 무시.
condition: Optional[str] = None # 와일드카드 전용
memo: Optional[str] = None # 와일드카드 전용
@ -28,6 +29,7 @@ class Req_UpdateCard(CardProtocol):
number: Optional[str] = None
script: Optional[str] = None
edit_script: Optional[Any] = None
usage_type: Optional[int] = None # 카드 적용 견적 구분(CardUsageType): 1=공통 2=신규견적전용 3=재견적전용
status: Optional[int] = None
condition: Optional[str] = None
memo: Optional[str] = None
@ -44,6 +46,7 @@ class CardData(WebPacketProtocol):
number: Optional[str] = None
script: Optional[str] = None
edit_script: Optional[Any] = None
usage_type: CardUsageType = CardUsageType.COMMON # 카드 적용 견적 구분: 1=공통 2=신규견적전용 3=재견적전용
status: CardStatus = CardStatus.ACTIVE
condition: Optional[str] = None
memo: Optional[str] = None

View File

@ -0,0 +1,58 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import ConfigDict
from common.enums import UserRole, UserStatus
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
# 최고관리자가 자기 회사 유저를 관리하는 도메인. company_id 는 토큰값으로 강제된다.
class CompanyUserProtocol(WebPacketProtocol):
pass
class Req_CreateCompanyUser(CompanyUserProtocol):
# role 은 받지 않는다 — 최고관리자가 만드는 계정은 항상 일반(USER) 로 서버에서 고정.
id: str = ""
password: str = ""
name: str = ""
email: str = ""
contact_number: str = ""
class Req_UpdateCompanyUser(CompanyUserProtocol):
name: Optional[str] = None
email: Optional[str] = None
contact_number: Optional[str] = None
status: Optional[UserStatus] = None # 활성/비활성 전환
password: Optional[str] = None # 비밀번호 초기화(옵션)
class CompanyUserData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
user_id: uuid.UUID
company_id: uuid.UUID
id: str
name: Optional[str] = None
email: Optional[str] = None
contact_number: Optional[str] = None
status: UserStatus
role: UserRole
last_accessed_at: Optional[datetime] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class Res_CompanyUser(Res_WebPacketProtocol):
user: Optional[CompanyUserData] = None
class Res_CompanyUserList(Res_PageProtocol):
users: list[CompanyUserData] = []
class Res_DeleteCompanyUser(Res_WebPacketProtocol):
pass

View File

@ -0,0 +1,51 @@
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import RemoveNoneResponse, RequireOwner
from services.company_user_service import CompanyUserService
from .protocol import (
Req_CreateCompanyUser,
Req_UpdateCompanyUser,
Res_CompanyUser,
Res_CompanyUserList,
Res_DeleteCompanyUser,
)
# 최고관리자(OWNER) 전용. 모든 엔드포인트가 RequireOwner 로 게이트되며 company_id 는 토큰값으로 스코프된다.
router = APIRouter(prefix="/v1/company/user", tags=["CompanyUser"], responses={404: {"description": "Not found"}})
@router.get(path="/list", response_model=Res_CompanyUserList, summary="회사 유저 목록(최고관리자)")
async def list_users(
service: CompanyUserService = Depends(),
owner: UserInfo = Depends(RequireOwner),
search: str | None = Query(None, description="로그인ID/이름/이메일 검색"),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_users(owner.company_id, search, pg))
@router.post(path="/create", response_model=Res_CompanyUser, summary="회사 유저 생성(일반 권한 고정)")
async def create_user(
req: Req_CreateCompanyUser, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner)
):
return RemoveNoneResponse(await service.create_user(owner.company_id, req))
@router.get(path="/{user_id}", response_model=Res_CompanyUser, summary="회사 유저 조회")
async def get_user(user_id: UUID, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner)):
return RemoveNoneResponse(await service.get_user(owner.company_id, str(user_id)))
@router.patch(path="/update/{user_id}", response_model=Res_CompanyUser, summary="회사 유저 수정")
async def update_user(
user_id: UUID, req: Req_UpdateCompanyUser, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner)
):
return RemoveNoneResponse(await service.update_user(owner.company_id, str(user_id), req))
@router.delete(path="/delete/{user_id}", response_model=Res_DeleteCompanyUser, summary="회사 유저 삭제")
async def delete_user(user_id: UUID, service: CompanyUserService = Depends(), owner: UserInfo = Depends(RequireOwner)):
return RemoveNoneResponse(await service.delete_user(owner.company_id, str(user_id)))

View File

@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends
from common.models.gmodel import UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.dashboard_service import DashboardService
from .protocol import Res_DashboardSummary
# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))의 UserInfo 로 company/user 스코프 집계를 한 번에 내린다.
router = APIRouter(prefix="/v1/dashboard", tags=["Dashboard"], responses={404: {"description": "Not found"}})
@router.get(path="/summary", response_model=Res_DashboardSummary, summary="대시보드 요약(회사 전체 + 내 견적)")
async def get_dashboard_summary(service: DashboardService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_summary(user_info.company_id, user_info.user_id))

View File

@ -0,0 +1,49 @@
import uuid
from datetime import datetime
from typing import Optional
from pydantic import Field
from common.models.gmodel import Res_WebPacketProtocol, WebPacketProtocol
class DashboardProtocol(WebPacketProtocol):
pass
class DashboardQuotationRef(WebPacketProtocol):
qt_id: uuid.UUID
name: str = ""
end_time: Optional[datetime] = None # 마감 임박 위젯에서만 채움(나머지는 None)
class DashboardActionList(WebPacketProtocol):
total: int = 0 # 전수 COUNT(최신 N개 아님)
items: list[DashboardQuotationRef] = [] # 정렬해서 상위 N개만(클릭 → /quotation?detail=qt_id)
class DashboardEmailUnsentItem(WebPacketProtocol):
qt_id: uuid.UUID
name: str = ""
unsent_count: int = 0 # 해당 견적의 미발송 세션(협력사) 수
class DashboardEmailUnsent(WebPacketProtocol):
total: int = 0 # 미발송 견적 수(distinct qt_id) — 리스트와 일치하는 헤드라인 숫자
quotations: list[DashboardEmailUnsentItem] = [] # 견적 단위로 묶은 상위 N개
class DashboardScope(WebPacketProtocol):
in_progress: int = 0 # 진행중(마감 전) 견적 수 = status != 마감
this_month: int = 0 # 이번 달 생성 견적 수(created_at 기준)
awarded_this_month: int = 0 # 이번 달 낙찰 견적 수(CLOSED·preferred_sp_yn=True, 마감 시각 기준)
deadline_soon: DashboardActionList = Field(default_factory=DashboardActionList)
email_unsent: DashboardEmailUnsent = Field(default_factory=DashboardEmailUnsent)
awarded: DashboardActionList = Field(default_factory=DashboardActionList) # 낙찰(단독 최저가 선정, preferred_sp_yn=True)
equal_bid: DashboardActionList = Field(default_factory=DashboardActionList) # 동가 마감(자동 다음 차수 생성, equal_bid_yn=True)
ruptured: DashboardActionList = Field(default_factory=DashboardActionList) # 결렬(낙찰자 없이 마감)
class Res_DashboardSummary(Res_WebPacketProtocol):
company: DashboardScope = Field(default_factory=DashboardScope) # 회사 전체(company_id 스코프)
mine: DashboardScope = Field(default_factory=DashboardScope) # 내가 만든 견적(user_id 추가 스코프)

View File

@ -24,6 +24,9 @@ class Req_CreateItem(ItemProtocol):
made_in: Optional[str] = None
price: Optional[int] = None
internet_lowest_price_yn: bool = False
internet_lowest_price: Optional[int] = None # 인터넷 최저가 실값(원). 목표가 산정용
purchase_price: Optional[int] = None # 매입가(원). 재견적 목표가 후보(그대로)
selling_price: Optional[int] = None # 판매가(원). 유통형 목표가 후보(×(1−마진율))
moq: Optional[str] = None
lead_time: Optional[int] = None
quantity_unit: Optional[str] = None
@ -44,6 +47,9 @@ class Req_UpdateItem(ItemProtocol):
made_in: Optional[str] = None
price: Optional[int] = None
internet_lowest_price_yn: Optional[bool] = None
internet_lowest_price: Optional[int] = None # 인터넷 최저가 실값(원). 목표가 산정용
purchase_price: Optional[int] = None # 매입가(원). 재견적 목표가 후보(그대로)
selling_price: Optional[int] = None # 판매가(원). 유통형 목표가 후보(×(1−마진율))
moq: Optional[str] = None
lead_time: Optional[int] = None
quantity_unit: Optional[str] = None
@ -69,6 +75,9 @@ class ItemData(WebPacketProtocol):
made_in: Optional[str] = None
price: Optional[int] = None
internet_lowest_price_yn: bool = False
internet_lowest_price: Optional[int] = None # 인터넷 최저가 실값(원). 목표가 산정용
purchase_price: Optional[int] = None # 매입가(원). 재견적 목표가 후보(그대로)
selling_price: Optional[int] = None # 판매가(원). 유통형 목표가 후보(×(1−마진율))
moq: Optional[str] = None
lead_time: Optional[int] = None
quantity_unit: Optional[str] = None

View File

@ -0,0 +1,32 @@
from uuid import UUID
from fastapi import APIRouter, Depends
from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.notification import NotificationService
from .protocol import Res_NotificationList, Res_NotificationRead
# 알림(인박스) 라우터. 항상 로그인 유저(user_info.user_id) 기준으로 조회/처리한다.
router = APIRouter(prefix="/v1/notification", tags=["Notification"], responses={404: {"description": "Not found"}})
@router.get(path="/list", response_model=Res_NotificationList, summary="알림 목록(+안읽음 수)")
async def list_notifications(
service: NotificationService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_notifications(user_info.user_id, pg))
@router.post(path="/read-all", response_model=Res_NotificationRead, summary="전체 읽음 처리")
async def read_all(service: NotificationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.mark_all_read(user_info.user_id))
@router.post(path="/{notification_id}/read", response_model=Res_NotificationRead, summary="알림 읽음 처리")
async def read_one(
notification_id: UUID, service: NotificationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.mark_read(user_info.user_id, str(notification_id)))

View File

@ -0,0 +1,33 @@
import uuid
from datetime import datetime
from typing import Any, Optional
from pydantic import ConfigDict
from common.enums import NotificationType
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
class NotificationProtocol(WebPacketProtocol):
pass
class NotificationData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
notification_id: uuid.UUID
type: NotificationType
ref_qt_id: Optional[uuid.UUID] = None
ref_session_id: Optional[uuid.UUID] = None
data: Optional[Any] = None # 렌더 스냅샷(유형별 필드)
read_at: Optional[datetime] = None
created_at: Optional[datetime] = None
class Res_NotificationList(Res_PageProtocol):
notifications: list[NotificationData] = []
unread: int = 0 # 안읽음 총수(헤더 빨콩이)
class Res_NotificationRead(Res_WebPacketProtocol):
pass

View File

@ -4,7 +4,7 @@ from typing import Any, Optional
from pydantic import ConfigDict
from common.enums import CardType, ChatSender, DeliveryType, QuotationStatus, QuotationType, SessionStatus
from common.enums import CardType, ChatSender, DeliveryType, QuotationStatus, QuotationType, SessionStatus, SupplierType
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
@ -25,6 +25,8 @@ class Req_CreateQuotation(QuotationProtocol):
manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None
memo: Optional[str] = None
md_price: Optional[int] = None # MD 제시가(원). 세션 목표가 산정 최우선값
supplier_type: Optional[int] = None # 협력사 유형(SupplierType). 재견적 1:1 → 견적에 기록
item_ids: list[uuid.UUID] = [] # 협상 대상 상품. item×supplier 조합마다 세션 1개 생성
supplier_ids: list[uuid.UUID] = [] # 협상 초청 공급사
card_ids: list[uuid.UUID] = [] # 선택 협상카드. 버전을 만들어 묶고 quotation.version_id 로 연결
@ -52,6 +54,8 @@ class QuotationData(WebPacketProtocol):
manager_email: Optional[str] = None
manager_contact_number: Optional[str] = None
memo: Optional[str] = None
md_price: Optional[int] = None
supplier_type: Optional[SupplierType] = None
iteration: int = 0
preferred_sp_yn: Optional[bool] = None
preferred_sp_id: Optional[uuid.UUID] = None
@ -61,6 +65,7 @@ class QuotationData(WebPacketProtocol):
participation_count: int = 0 # 견적별 참여 협력사 수(세션 distinct supplier). 목록 집계로 채움.
item_id: Optional[uuid.UUID] = None # 대표 상품 id(세션의 첫 item). 목록 조인으로 채움.
item_name: Optional[str] = None # 대표 상품명. 목록 조인으로 채움.
creator_name: Optional[str] = None # 등록자(작성자) 이름. user_id→company.users.name 조인으로 채움.
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@ -88,6 +93,7 @@ class SessionData(WebPacketProtocol):
qt_round: int
qt_type: QuotationType
target_price: int
target_anchoring_price: Optional[int] = None # 앵커링가(원). 목표가×(1−앵커링율)
status: SessionStatus
bid_price: Optional[int] = None
bid_at: Optional[datetime] = None
@ -95,6 +101,7 @@ class SessionData(WebPacketProtocol):
reject_reason: Optional[str] = None
reject_price: Optional[int] = None
reject_delivery_type: Optional[DeliveryType] = None
email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단
url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성
@ -103,6 +110,13 @@ class Res_CreateQuotation(Res_WebPacketProtocol):
session_count: int = 0 # 함께 생성된 협상 세션 수(토스트 표시용). 세션 풀바디는 별도 GET 으로 조회
class Res_NotifySessions(Res_WebPacketProtocol):
sent: int = 0 # 발송 성공 세션 수
failed: int = 0 # 발송 시도했으나 실패한 세션 수
skipped: int = 0 # 담당자 이메일이 없어 건너뛴 세션 수
total: int = 0 # 대상 세션 총수
class Res_QuotationStatus(Res_WebPacketProtocol):
qt_id: Optional[uuid.UUID] = None
job_status: int = 0
@ -162,3 +176,30 @@ class QuotationCardData(WebPacketProtocol):
class Res_QuotationCards(Res_WebPacketProtocol):
qt_id: Optional[uuid.UUID] = None
cards: list[QuotationCardData] = []
class Res_LastSupplierType(Res_WebPacketProtocol):
supplier_type: Optional[SupplierType] = None # 협력사 직전 견적의 유형(없으면 None)
qt_number: Optional[str] = None # 그 견적의 번호(이전 견적 값임을 표시용)
class TargetCandidate(WebPacketProtocol):
basis: str
label: str
value: int
class Res_TargetBreakdown(Res_WebPacketProtocol):
is_new: bool = False
is_inherited: bool = False
md_price: Optional[int] = None
internet_lowest: Optional[int] = None
purchase: Optional[int] = None
selling: Optional[int] = None
fee: float = 0.0
margin: float = 0.0
anchoring_value: float = 0.0
candidates: list[TargetCandidate] = []
chosen_basis: Optional[str] = None
target_price: int = 0
target_anchoring_price: Optional[int] = None

View File

@ -11,6 +11,8 @@ from .protocol import (
Req_RegenerateQuotation,
Res_CreateQuotation,
Res_DeleteQuotation,
Res_LastSupplierType,
Res_NotifySessions,
Res_Quotation,
Res_QuotationCards,
Res_QuotationList,
@ -18,10 +20,10 @@ from .protocol import (
Res_QuotationSessions,
Res_QuotationStatus,
Res_SessionChat,
Res_TargetBreakdown,
)
# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받는다.
# quotations 테이블에 company_id 가 없어 회사 스코핑은 하지 않는다(토큰 검증만).
# 라우터(컨트롤러). 인증(Depends(IsValidAccessToken))으로 UserInfo 를 받아 company_id 로 스코프(작성자 회사 기준).
# 라우팅 주의: 정적/하위 경로(/list, /create, /{qt_id}/status ...)를 /{qt_id} 보다 먼저 선언해야
# /{qt_id} 가 /list 등을 가로채지 않는다.
router = APIRouter(prefix="/v1/quotation", tags=["Quotation"], responses={404: {"description": "Not found"}})
@ -37,9 +39,11 @@ async def list_quotations(
type: str | None = Query(None, description="유형 필터(정확히 일치)"),
start_from: datetime | None = Query(None, description="시작일시 이후(ISO)"),
start_to: datetime | None = Query(None, description="시작일시 이전(ISO)"),
mine: bool = Query(False, description="내 견적만 보기(작성자=로그인 유저)"),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_quotations(search, status, type, start_from, start_to, pg))
owner = user_info.user_id if mine else None
return RemoveNoneResponse(await service.list_quotations(user_info.company_id, owner, search, status, type, start_from, start_to, pg))
@router.post(path="/create", response_model=Res_CreateQuotation, summary="견적 생성")
@ -51,48 +55,68 @@ async def create_quotation(
@router.post(path="/stop/{qt_id}", response_model=Res_Quotation, summary="견적 마감")
async def stop_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.stop_quotation(str(qt_id)))
return RemoveNoneResponse(await service.stop_quotation(str(qt_id), user_info.company_id))
@router.post(path="/regenerate/{qt_id}", response_model=Res_CreateQuotation, summary="견적 재생성(다음 라운드)")
async def regenerate_quotation(
qt_id: UUID, req: Req_RegenerateQuotation, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), req.supplier_ids))
return RemoveNoneResponse(await service.regenerate_quotation(str(qt_id), user_info.company_id, req.supplier_ids))
# ----- 견적 상세 (FK로 연결된 하위 데이터 / 일부는 모델 미존재로 스텁) -----
@router.get(path="/{qt_id}/status", response_model=Res_QuotationStatus, summary="견적 상태 조회")
async def get_quotation_status(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_status(str(qt_id)))
return RemoveNoneResponse(await service.get_status(str(qt_id), user_info.company_id))
@router.get(path="/{qt_id}/sessions", response_model=Res_QuotationSessions, summary="참여현황")
async def get_quotation_sessions(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_sessions(str(qt_id)))
return RemoveNoneResponse(await service.list_sessions(str(qt_id), user_info.company_id))
@router.post(path="/{qt_id}/notify", response_model=Res_NotifySessions, summary="협상 초청 메일 발송")
async def notify_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.notify_sessions(str(qt_id), user_info.company_id))
@router.get(path="/session/{session_id}/chat", response_model=Res_SessionChat, summary="채팅 상세")
async def get_session_chat(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_chats(str(session_id)))
return RemoveNoneResponse(await service.list_chats(str(session_id), user_info.company_id))
@router.get(path="/session/{session_id}/target-breakdown", response_model=Res_TargetBreakdown, summary="세션 목표가 산정내역")
async def get_target_breakdown(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_target_breakdown(str(session_id), user_info.company_id))
@router.post(path="/session/{session_id}/notify", response_model=Res_NotifySessions, summary="세션 초청 메일 재발송")
async def notify_session(session_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.notify_session(str(session_id), user_info.company_id))
@router.get(path="/{qt_id}/result", response_model=Res_QuotationResult, summary="낙찰 결과")
async def get_quotation_result(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_result(str(qt_id)))
return RemoveNoneResponse(await service.get_result(str(qt_id), user_info.company_id))
@router.get(path="/{qt_id}/cards", response_model=Res_QuotationCards, summary="견적 사용 카드")
async def get_quotation_cards(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.list_cards(str(qt_id)))
return RemoveNoneResponse(await service.list_cards(str(qt_id), user_info.company_id))
@router.delete(path="/delete/{qt_id}", response_model=Res_DeleteQuotation, summary="견적 삭제")
async def delete_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.delete_quotation(str(qt_id)))
return RemoveNoneResponse(await service.delete_quotation(str(qt_id), user_info.company_id))
@router.get(path="/supplier/{supplier_id}/last-type", response_model=Res_LastSupplierType, summary="협력사 직전 견적 유형")
async def get_supplier_last_type(supplier_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_last_supplier_type(str(supplier_id), user_info.company_id))
# ----- 단건 조회 (정적/하위 경로 뒤에 선언) -----
@router.get(path="/{qt_id}", response_model=Res_Quotation, summary="견적 조회")
async def get_quotation(qt_id: UUID, service: QuotationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.get_quotation(str(qt_id)))
return RemoveNoneResponse(await service.get_quotation(str(qt_id), user_info.company_id))

View File

@ -10,8 +10,10 @@ from jose import jwt, JWTError, ExpiredSignatureError
from common.enums import (
EXCEPTION_ACCESS_TOKEN_EXPIRED,
EXCEPTION_FORBIDDEN,
EXCEPTION_INVALID_CLIENT_ACCESS,
EXCEPTION_REFRESH_TOKEN_EXPIRED,
UserRole,
)
from common.logger import LOG
from common.models.gmodel import UserInfo
@ -98,6 +100,13 @@ async def IsValidRefreshToken(credentials: HTTPAuthorizationCredentials = Depend
return DecodeRefreshToken(credentials.credentials)
# 최고관리자 전용 엔드포인트 게이트. 액세스 토큰 검증 + role==OWNER 가 아니면 403.
async def RequireOwner(user_info: UserInfo = Depends(IsValidAccessToken)) -> UserInfo:
if user_info.role != UserRole.OWNER.value:
raise EXCEPTION_FORBIDDEN
return user_info
# ---- ResponseNone 처리 -----------------------------------------------------
# 응답 객체에서 값이 None 인 필드를 재귀적으로 제거하여 페이로드를 줄인다.
# 모든 라우터는 return RemoveNoneResponse(await service....) 형태로 반환한다.

View File

@ -15,6 +15,27 @@ from crud.quotation_crud import QuotationCRUD
from services.quotation_service import QuotationService
async def _close_each(service: QuotationService, qt_ids) -> Counter:
"""대상 견적마다 close_and_decide 를 호출하되, 한 건의 예외가 배치 전체를 멈추지 않도록 격리한다.
(예전 per-item try/continue 보존 — 한 견적의 DB 오류 등으로 나머지 견적이 이번 tick 에서 누락되면 안 됨.)
반환: 결과(CloseOutcome) 카운트 + 예외 발생 건수('error')."""
results = Counter()
for qt_id in qt_ids:
try:
results[await service.close_and_decide(qt_id)] += 1
except Exception as ex:
results["error"] += 1
LOG.e_no_callstack(f"[scheduler] close_and_decide 실패 qt={qt_id}: {ex}")
return results
def _format_results(results: Counter) -> str:
return (
f"낙찰 {results[CloseOutcome.AWARDED]} / 재생성 {results[CloseOutcome.REGENERATED]} / "
f"재생성실패 {results[CloseOutcome.REGEN_FAILED]} / 마감 {results[CloseOutcome.CLOSED]} / 오류 {results['error']}"
)
async def close_expired_quotations() -> int:
"""[잡①] 마감일이 지난 견적을 자동 마감 처리한다. 하루 한 번 실행.
대상: 마감 시각이 이미 지났는데 아직 마감되지 않은(삭제되지도 않은) 견적.
@ -32,11 +53,9 @@ async def close_expired_quotations() -> int:
LOG.e_no_callstack(f"[scheduler] close_expired 대상 조회 실패: {err_type.name}")
return 0
results = Counter()
for qt_id in qt_ids:
results[await service.close_and_decide(qt_id)] += 1
results = await _close_each(service, qt_ids)
if results:
LOG.i(f"[scheduler] close_expired: 낙찰 {results[CloseOutcome.AWARDED]} / 재생성 {results[CloseOutcome.REGENERATED]} / 마감 {results[CloseOutcome.CLOSED]}")
LOG.i(f"[scheduler] close_expired: {_format_results(results)}")
return sum(results.values())
@ -56,9 +75,7 @@ async def close_negotiated_quotations() -> int:
LOG.e_no_callstack(f"[scheduler] close_negotiated 대상 조회 실패: {err_type.name}")
return 0
results = Counter()
for qt_id in qt_ids:
results[await service.close_and_decide(qt_id)] += 1
results = await _close_each(service, qt_ids)
if results:
LOG.i(f"[scheduler] close_negotiated: 낙찰 {results[CloseOutcome.AWARDED]} / 재생성 {results[CloseOutcome.REGENERATED]} / 마감 {results[CloseOutcome.CLOSED]}")
LOG.i(f"[scheduler] close_negotiated: {_format_results(results)}")
return sum(results.values())

View File

@ -4,11 +4,11 @@ from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users
from common.enums import DBWRType, ErrorType, UserStatus, UserRole
from common.enums import DBWRType, ErrorType, UserRole, UserStatus
from common.logger import LOG
from common.models.gmodel import UserInfo
from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.auth.protocol import CompanyData, Res_CreateAccount, Res_Login, Res_Me, Res_RefreshToken
from router.v1.auth.protocol import CompanyData, Req_UpdateMe, Res_Login, Res_Me, Res_RefreshToken
from router.v1.validator.dependencies import (
CreateAccessToken,
CreateRefreshToken,
@ -38,6 +38,7 @@ class AuthService:
user_id=str(user.user_id),
id=user.id,
company_id=str(user.company_id),
role=user.role,
)
async def attempt_login(self, login_id: str, password: str, connect_ip: str) -> Res_Login:
@ -82,50 +83,6 @@ class AuthService:
return res
async def create_account(
self, login_id: str, password: str, company_id: str, name: str, email: str, contact_number: str, role: int
) -> Res_CreateAccount:
LOG.i(f"CREATE : id={login_id}, company_id={company_id}")
res = Res_CreateAccount()
# 1) 중복 ID 확인 (Read DB)
err_type = await DB_SESSION_MNG.execute_lambda(
users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.is_user(s, login_id),
)
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
return res
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 2) 계정 생성 (비밀번호는 bcrypt 해시로 저장)
user = users(
company_id=uuid.UUID(company_id),
id=login_id,
password=await GetHashedPW(password),
name=name or None,
email=email or None,
contact_number=contact_number or None,
role=role or UserRole.USER.value,
)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()],
[lambda s: self.user_crud.add_user(s, user)],
)
if err_type != ErrorType.SUCCESS:
# 사전 검사와 INSERT 사이의 경쟁 조건에서 unique 위반이 나면 동일 코드로 매핑.
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
else:
res.result.SetResult(err_type)
return res
res.user_id = str(user.user_id)
return res
async def get_me(self, user_info: UserInfo) -> Res_Me:
res = Res_Me()
@ -155,10 +112,36 @@ class AuthService:
res.name = user.name
res.email = user.email
res.contact_number = user.contact_number
res.role = user.role
res.role = UserRole(user.role)
res.company = company
return res
async def update_me(self, user_info: UserInfo, req: Req_UpdateMe) -> Res_Me:
res = Res_Me()
data = req.model_dump(exclude_unset=True)
# 비밀번호: 값 있으면 해시 교체, 비었으면 변경 안 함.
if data.get("password"):
data["password"] = await GetHashedPW(data["password"])
else:
data.pop("password", None)
# 빈 문자열은 NULL 로 저장(미입력 = 값 비움).
for k in ("name", "email", "contact_number"):
if k in data and data[k] == "":
data[k] = None
if data:
err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()],
[lambda s: self.user_crud.update_user(s, uuid.UUID(user_info.user_id), data)],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 갱신 후 최신 정보로 응답(프론트가 스토어 갱신에 사용).
return await self.get_me(user_info)
async def refresh_token(self, refresh_token: str) -> Res_RefreshToken:
res = Res_RefreshToken()
# refresh 토큰 검증은 라우터 Depends(IsValidRefreshToken) 에서 1차 수행됨.

View File

@ -29,6 +29,7 @@ class CardService:
number=row.number,
script=row.script,
edit_script=row.edit_script,
usage_type=row.usage_type,
status=CardStatus.ACTIVE.value,
created_at=row.created_at,
updated_at=row.updated_at,
@ -45,6 +46,7 @@ class CardService:
number=row.number,
script=row.script,
edit_script=row.edit_script,
usage_type=row.usage_type,
status=CardStatus.ACTIVE.value if row.available else CardStatus.INACTIVE.value,
condition=row.condition,
memo=row.memo,
@ -146,6 +148,7 @@ class CardService:
number=req.number,
script=req.script,
edit_script=req.edit_script,
usage_type=req.usage_type,
)
if is_wildcard:
card = wild_cards(
@ -182,7 +185,7 @@ class CardService:
return res
# 해당 테이블에 있는 컬럼만 추린다(없는 필드는 무시). status → available(와일드 전용).
allowed = {"name", "number", "script", "edit_script"}
allowed = {"name", "number", "script", "edit_script", "usage_type"}
if is_wild:
allowed |= {"condition", "memo"}
payload = {k: v for k, v in data.items() if k in allowed}

View File

@ -0,0 +1,158 @@
import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import users
from common.enums import DBWRType, ErrorType, UserRole, UserStatus
from common.utils.gtime import GTime
from crud.user_crud import IUserCRUD, UserCRUD
from router.v1.company.protocol import (
CompanyUserData,
Req_CreateCompanyUser,
Req_UpdateCompanyUser,
Res_CompanyUser,
Res_CompanyUserList,
Res_DeleteCompanyUser,
)
from router.v1.validator.dependencies import GetHashedPW
class CompanyUserService:
"""최고관리자(OWNER)의 자기 회사 유저 관리 로직.
- 라우터에서 RequireOwner 로 1차 권한을 거른 뒤 호출된다.
- company_id 는 토큰값만 쓴다(요청 body 무시) → 남의 회사 데이터 불가.
- 변경 대상이 OWNER 면 거부한다(최고관리자는 앱에서 수정·삭제 불가).
"""
def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)):
self.user_crud = user_crud
async def _fetch_managed(self, company_uuid: uuid.UUID, user_id: uuid.UUID):
"""대상 유저 조회 + 같은 회사 + 비-OWNER 확인. (ErrorType, user|None) 반환."""
err_type, user = await DB_SESSION_MNG.execute_lambda(
users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.get_by_user_id(s, user_id),
)
if err_type != ErrorType.SUCCESS or user is None:
return ErrorType.ACCOUNT_NOT_FOUND, None
if user.company_id != company_uuid:
return ErrorType.ACCOUNT_NOT_FOUND, None
if user.role == UserRole.OWNER.value:
return ErrorType.ACCOUNT_FORBIDDEN, None
return ErrorType.SUCCESS, user
async def list_users(self, company_id: str, search, pg) -> Res_CompanyUserList:
res = Res_CompanyUserList(page=pg.page, size=pg.size)
company_uuid = uuid.UUID(company_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.list_by_company(s, company_uuid, search, pg.skip, pg.size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.users = [CompanyUserData.model_validate(r) for r in rows]
res.total = total
return res
async def get_user(self, company_id: str, user_id: str) -> Res_CompanyUser:
res = Res_CompanyUser()
err_type, user = await self._fetch_managed(uuid.UUID(company_id), uuid.UUID(user_id))
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.user = CompanyUserData.model_validate(user)
return res
async def create_user(self, company_id: str, req: Req_CreateCompanyUser) -> Res_CompanyUser:
res = Res_CompanyUser()
company_uuid = uuid.UUID(company_id)
# 1) 로그인 ID 중복 확인 (id 는 전역 unique)
err_type = await DB_SESSION_MNG.execute_lambda(
users.DBType(),
DBWRType.DB_READ.value,
lambda s: self.user_crud.is_user(s, req.id),
)
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
return res
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 2) 생성 — 회사는 토큰값, 권한은 항상 USER 로 고정.
user = users(
company_id=company_uuid,
id=req.id,
password=await GetHashedPW(req.password),
name=req.name or None,
email=req.email or None,
contact_number=req.contact_number or None,
role=UserRole.USER.value,
)
err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()],
[lambda s: self.user_crud.add_user(s, user)],
)
if err_type != ErrorType.SUCCESS:
if err_type == ErrorType.DB_ALREADY_SAME_KEY:
res.result.SetResult(ErrorType.ACCOUNT_ALREADY_EXIST)
else:
res.result.SetResult(err_type)
return res
# 서버 기본값(created_at 등)은 insert 후 객체에 안 실리므로 재조회.
return await self.get_user(company_id, str(user.user_id))
async def update_user(self, company_id: str, user_id: str, req: Req_UpdateCompanyUser) -> Res_CompanyUser:
res = Res_CompanyUser()
company_uuid = uuid.UUID(company_id)
user_uuid = uuid.UUID(user_id)
err_type, _ = await self._fetch_managed(company_uuid, user_uuid)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
data = req.model_dump(exclude_unset=True)
if data.get("status") is not None:
s = data["status"] # pydantic 은 enum 멤버로 돌려준다 → SMALLINT 값으로 환원
data["status"] = s.value if isinstance(s, UserStatus) else int(s)
if data.get("password"):
data["password"] = await GetHashedPW(data["password"])
else:
data.pop("password", None) # 빈 비밀번호는 변경하지 않음
err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()],
[lambda s: self.user_crud.update_user(s, user_uuid, data)],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
return await self.get_user(company_id, user_id)
async def delete_user(self, company_id: str, user_id: str) -> Res_DeleteCompanyUser:
res = Res_DeleteCompanyUser()
company_uuid = uuid.UUID(company_id)
user_uuid = uuid.UUID(user_id)
err_type, _ = await self._fetch_managed(company_uuid, user_uuid)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
err_type = await DB_SESSION_MNG.execute_lambda_run(
[users.DBType()],
[lambda s: self.user_crud.update_user(s, user_uuid, {"deleted": True, "updated_at": GTime.UTC()})],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res

View File

@ -0,0 +1,101 @@
import uuid
from datetime import timedelta
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations
from common.enums import DBWRType, ErrorType
from common.utils.gtime import GTime
from crud.dashboard_crud import DashboardCRUD, IDashboardCRUD
from router.v1.dashboard.protocol import (
DashboardActionList,
DashboardEmailUnsent,
DashboardEmailUnsentItem,
DashboardQuotationRef,
DashboardScope,
Res_DashboardSummary,
)
class DashboardService:
"""대시보드 요약 집계. 회사 전체(company)와 내가 만든 견적(mine) 두 스코프를 한 응답으로 내린다.
KPI 숫자는 전수 COUNT, 액션 리스트는 정렬해서 상위 N개만. 읽기 전용(사이드이펙트 없음).
"""
LIST_LIMIT = 5 # 액션 위젯 리스트당 표시 개수
DEADLINE_HOURS = 72 # 마감 임박 기준(3일 이내)
def __init__(self, dashboard_crud: IDashboardCRUD = Depends(DashboardCRUD)):
self.dashboard_crud = dashboard_crud
async def get_summary(self, company_id: str, user_id: str) -> Res_DashboardSummary:
res = Res_DashboardSummary()
company_uuid = uuid.UUID(company_id)
user_uuid = uuid.UUID(user_id)
now = GTime.UTC()
horizon = now + timedelta(hours=self.DEADLINE_HOURS)
month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
res.company = await self._scope_summary(company_uuid, None, now, horizon, month_start)
res.mine = await self._scope_summary(company_uuid, user_uuid, now, horizon, month_start)
return res
async def _scope_summary(self, company_uuid, owner_uuid, now, horizon, month_start) -> DashboardScope:
scope = DashboardScope()
scope.in_progress = await self._count(
lambda s: self.dashboard_crud.count_in_progress(s, company_uuid, owner_uuid)
)
scope.this_month = await self._count(
lambda s: self.dashboard_crud.count_created_since(s, company_uuid, owner_uuid, month_start)
)
scope.awarded_this_month = await self._count(
lambda s: self.dashboard_crud.count_awarded_since(s, company_uuid, owner_uuid, month_start)
)
scope.deadline_soon = await self._action(
lambda s: self.dashboard_crud.deadline_soon(s, company_uuid, owner_uuid, now, horizon, self.LIST_LIMIT),
with_end_time=True,
)
scope.awarded = await self._action(
lambda s: self.dashboard_crud.awarded(s, company_uuid, owner_uuid, self.LIST_LIMIT)
)
scope.equal_bid = await self._action(
lambda s: self.dashboard_crud.equal_bid(s, company_uuid, owner_uuid, self.LIST_LIMIT)
)
scope.ruptured = await self._action(
lambda s: self.dashboard_crud.ruptured(s, company_uuid, owner_uuid, self.LIST_LIMIT)
)
scope.email_unsent = await self._email_unsent(company_uuid, owner_uuid)
return scope
async def _count(self, fn) -> int:
err, n = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn)
return n if err == ErrorType.SUCCESS else 0
async def _action(self, fn, with_end_time: bool = False) -> DashboardActionList:
out = DashboardActionList()
err, rows, total = await DB_SESSION_MNG.execute_lambda(quotations.DBType(), DBWRType.DB_READ.value, fn)
if err != ErrorType.SUCCESS:
return out
out.total = total
out.items = [
DashboardQuotationRef(qt_id=r[0], name=r[1] or "", end_time=(r[2] if with_end_time else None))
for r in rows
]
return out
async def _email_unsent(self, company_uuid, owner_uuid) -> DashboardEmailUnsent:
out = DashboardEmailUnsent()
err, rows, total = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.dashboard_crud.email_unsent(s, company_uuid, owner_uuid, self.LIST_LIMIT),
)
if err != ErrorType.SUCCESS:
return out
out.total = total
out.quotations = [
DashboardEmailUnsentItem(qt_id=r[0], name=r[1] or "", unsent_count=int(r[2] or 0)) for r in rows
]
return out

View File

@ -0,0 +1,125 @@
"""협상 초청 메일 발송.
1순위: Azure Communication Services(ACS) Email (endpoint + accesskey + 검증된 sender).
2순위(폴백): SMTP (aiosmtplib).
둘 다 미설정이면 EmailUnavailable 을 던진다(조용한 실패 0).
설정 출처: config.<APP_ENV>.toml 의 [MailConfig] (config/config_models.py:MailConfig).
HTML 본문 템플릿: services/email_templates/*.html ($placeholder 치환).
"""
from __future__ import annotations
from datetime import datetime
from email.message import EmailMessage
from html import escape
from pathlib import Path
from string import Template
from zoneinfo import ZoneInfo
from common.logger import LOG
from config.server_configs import mail_config
_KST = ZoneInfo("Asia/Seoul")
# HTML 본문은 코드에 박지 않고 파일에서 읽는다(모듈 로드 시 1회). $placeholder 는 string.Template 로 치환.
_TEMPLATE_DIR = Path(__file__).parent / "email_templates"
_INVITE_HTML = Template((_TEMPLATE_DIR / "invite_email.html").read_text(encoding="utf-8"))
class EmailUnavailable(RuntimeError):
"""ACS·SMTP 모두 미설정이라 발송 채널이 없음."""
async def _send_acs(to: str, subject: str, html: str, text: str) -> None:
"""Azure Communication Services Email — accesskey 인증."""
from azure.communication.email.aio import EmailClient
conn = f"endpoint={mail_config.azure_acs_endpoint};accesskey={mail_config.azure_acs_accesskey}"
message = {
"senderAddress": mail_config.azure_acs_sender,
"recipients": {"to": [{"address": to}]},
"content": {"subject": subject, "plainText": text, "html": html},
}
async with EmailClient.from_connection_string(conn) as client:
poller = await client.begin_send(message)
await poller.result()
LOG.i(f"email sent via ACS → {to} ({subject})")
async def _send_smtp(to: str, subject: str, html: str, text: str) -> None:
import aiosmtplib
msg = EmailMessage()
msg["From"] = mail_config.smtp_from
msg["To"] = to
msg["Subject"] = subject
msg.set_content(text)
msg.add_alternative(html, subtype="html")
await aiosmtplib.send(
msg,
hostname=mail_config.smtp_host,
port=mail_config.smtp_port,
username=mail_config.smtp_user or None,
password=mail_config.smtp_password or None,
start_tls=mail_config.smtp_starttls,
)
LOG.i(f"email sent via SMTP → {to} ({subject})")
async def send_email(to: str, subject: str, html: str, text: str) -> None:
"""ACS(설정 시) → SMTP(폴백) 순으로 1통 발송. 둘 다 없으면 EmailUnavailable."""
if mail_config.acs_configured:
await _send_acs(to, subject, html, text)
return
if mail_config.smtp_host:
await _send_smtp(to, subject, html, text)
return
# endpoint/accesskey 만 있고 sender 누락 시 원인을 명확히 안내.
if mail_config.azure_acs_endpoint and not mail_config.azure_acs_sender:
raise EmailUnavailable("AZURE_ACS_SENDER(검증된 MailFrom 주소) 미설정")
raise EmailUnavailable("이메일 미설정 — ACS(endpoint+accesskey+sender) 또는 SMTP 필요")
def _fmt_deadline(end_time: datetime | None) -> str:
"""협상 마감 시각 → 한국시간 'YYYY-MM-DD HH:MM' 표기. 값 없으면 빈 문자열."""
if not end_time:
return ""
dt = end_time if end_time.tzinfo else end_time.replace(tzinfo=ZoneInfo("UTC"))
return dt.astimezone(_KST).strftime("%Y-%m-%d %H:%M")
def build_invite_email(
*,
supplier_name: str,
quotation_name: str,
qt_number: str,
end_time: datetime | None,
chat_url: str,
) -> tuple[str, str, str]:
"""협상 초청 메일 (제목/HTML/텍스트) 생성.
목표가·앵커링가는 협상 전략 값이라 메일에 담지 않는다(공급사에게 노출 금지).
공급사는 링크로 협상 화면에 진입해 입찰한다.
"""
sp = supplier_name or "협력사"
deadline = _fmt_deadline(end_time) or "미정"
subject = f"[협상 견적 {qt_number}] {quotation_name} — 협상 참여 요청"
# HTML 본문은 invite_email.html 에서 읽어 치환. 값은 escape 해 HTML 인젝션 방지(견적명 등은 사용자 입력).
html = _INVITE_HTML.substitute(
supplier_name=escape(sp),
quotation_name=escape(quotation_name),
qt_number=escape(qt_number),
deadline=escape(deadline),
chat_url=escape(chat_url),
)
text = (
f"{sp} 담당자님, 아래 견적 건의 협상에 참여해 주세요.\n\n"
f" - 견적명: {quotation_name}\n"
f" - 견적번호: {qt_number}\n"
f" - 협상 마감: {deadline}\n\n"
f"협상 참여 링크: {chat_url}\n"
)
return subject, html, text

View File

@ -0,0 +1,70 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f4f5f7;margin:0;padding:24px 12px;">
<tr>
<td align="center">
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="width:480px;max-width:480px;background-color:#ffffff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb;font-family:'Apple SD Gothic Neo',-apple-system,'Segoe UI',Roboto,'Malgun Gothic',sans-serif;">
<!-- 헤더 바 -->
<tr>
<td style="background-color:#2563eb;padding:18px 28px;">
<span style="color:#ffffff;font-size:16px;font-weight:700;letter-spacing:1px;">NEGODATA</span>
</td>
</tr>
<!-- 본문 -->
<tr>
<td style="padding:32px 28px 4px 28px;">
<h1 style="margin:0 0 10px 0;font-size:20px;font-weight:700;color:#111827;">협상 참여 요청</h1>
<p style="margin:0 0 24px 0;font-size:14px;line-height:1.7;color:#4b5563;">
<strong style="color:#111827;">$supplier_name</strong> 담당자님,<br>
아래 견적 건의 협상에 참여해 주세요.
</p>
<!-- 견적 정보 카드 -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f9fafb;border:1px solid #eef0f3;border-radius:10px;margin-bottom:28px;">
<tr>
<td style="padding:14px 16px;font-size:13px;color:#6b7280;width:88px;">견적명</td>
<td style="padding:14px 16px;font-size:13px;color:#111827;font-weight:600;text-align:right;">$quotation_name</td>
</tr>
<tr>
<td style="padding:14px 16px;font-size:13px;color:#6b7280;border-top:1px solid #eef0f3;">견적번호</td>
<td style="padding:14px 16px;font-size:13px;color:#374151;text-align:right;border-top:1px solid #eef0f3;">$qt_number</td>
</tr>
<tr>
<td style="padding:14px 16px;font-size:13px;color:#6b7280;border-top:1px solid #eef0f3;">협상 마감</td>
<td style="padding:14px 16px;font-size:13px;color:#dc2626;font-weight:700;text-align:right;border-top:1px solid #eef0f3;">$deadline</td>
</tr>
</table>
<!-- CTA 버튼 (td bgcolor = Outlook 대응 bulletproof) -->
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin-bottom:4px;">
<tr>
<td align="center">
<table role="presentation" cellpadding="0" cellspacing="0">
<tr>
<td align="center" bgcolor="#2563eb" style="border-radius:8px;">
<!-- TODO: 세션별 협상링크 연결 시 href 를 $$chat_url 로 복원 -->
<a href="https://nego.o2o.kr" style="display:inline-block;padding:14px 34px;font-size:15px;font-weight:700;color:#ffffff;text-decoration:none;border-radius:8px;">협상 참여하기 →</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
<!-- 푸터 -->
<tr>
<td style="padding:18px 28px 26px 28px;">
<p style="margin:0;font-size:11px;line-height:1.7;color:#9ca3af;border-top:1px solid #f0f1f3;padding-top:16px;">
버튼이 열리지 않으면 아래 링크를 복사해 접속하세요.<br>
<a href="https://nego.o2o.kr" style="color:#6b7280;word-break:break-all;">https://nego.o2o.kr</a>
</p>
</td>
</tr>
</table>
<p style="margin:16px 0 0 0;font-size:11px;color:#b0b4bb;font-family:sans-serif;">본 메일은 협상 견적 시스템에서 자동 발송되었습니다.</p>
</td>
</tr>
</table>

View File

@ -0,0 +1,88 @@
import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import notifications
from common.enums import DBWRType, ErrorType, NotificationType
from common.logger import LOG
from common.models.gmodel import PageParams
from common.utils.gtime import GTime
from crud.notification_crud import INotificationCRUD, NotificationCRUD
from router.v1.notification.protocol import NotificationData, Res_NotificationList, Res_NotificationRead
def _as_uuid(v):
return uuid.UUID(v) if isinstance(v, str) else v
async def create_notification(
user_id,
ntype: NotificationType,
data: dict,
ref_qt_id=None,
ref_session_id=None,
) -> None:
"""알림 1건 기록(인박스). 수신자=user_id(견적 작성자), data=렌더 스냅샷(유형별 필드).
부가 효과라 실패해도 본 흐름을 막지 않는다(raise 안 함, 로그만)."""
notif = notifications(
user_id=_as_uuid(user_id),
type=ntype.value,
ref_qt_id=_as_uuid(ref_qt_id) if ref_qt_id else None,
ref_session_id=_as_uuid(ref_session_id) if ref_session_id else None,
data=data,
)
err = await DB_SESSION_MNG.execute_lambda_run(
[notifications.DBType()],
[lambda s: DB_SESSION_MNG.insert(s, notif, raise_error=False)],
)
if err != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[notify] 알림 기록 실패 user={user_id} type={ntype.name}")
class NotificationService:
"""알림 조회/읽음 처리(헤더 벨·알림 페이지). 항상 로그인 유저(user_id)로 스코프."""
def __init__(self, crud: INotificationCRUD = Depends(NotificationCRUD)):
self.crud = crud
async def list_notifications(self, user_id: str, pg: PageParams) -> Res_NotificationList:
res = Res_NotificationList(page=pg.page, size=pg.size)
uid = uuid.UUID(user_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
notifications.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_for_user(s, uid, pg.skip, pg.size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.notifications = [NotificationData.model_validate(r) for r in rows]
res.total = total
_e, unread = await DB_SESSION_MNG.execute_lambda(
notifications.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.count_unread(s, uid),
)
res.unread = unread if _e == ErrorType.SUCCESS else 0
return res
async def mark_read(self, user_id: str, notification_id: str) -> Res_NotificationRead:
res = Res_NotificationRead()
err_type = await DB_SESSION_MNG.execute_lambda_run(
[notifications.DBType()],
[lambda s: self.crud.mark_read(s, uuid.UUID(user_id), uuid.UUID(notification_id), GTime.UTC())],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
async def mark_all_read(self, user_id: str) -> Res_NotificationRead:
res = Res_NotificationRead()
err_type = await DB_SESSION_MNG.execute_lambda_run(
[notifications.DBType()],
[lambda s: self.crud.mark_all_read(s, uuid.UUID(user_id), GTime.UTC())],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res

View File

@ -1,13 +1,14 @@
import re
import uuid
from datetime import timezone
from datetime import timezone, timedelta
from typing import Optional
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards
from common.enums import CloseOutcome, DBWRType, ErrorType, QuotationStatus, QuotationType, SessionStatus
from common.enums import CloseOutcome, DBWRType, ErrorType, NotificationType, QuotationStatus, QuotationType, SessionStatus
from common.logger import LOG
from common.models.gmodel import PageParams
from common.utils.gtime import GTime
from config.server_configs import web_server_config
@ -20,6 +21,8 @@ from router.v1.quotation.protocol import (
Req_CreateQuotation,
Res_CreateQuotation,
Res_DeleteQuotation,
Res_LastSupplierType,
Res_NotifySessions,
Res_Quotation,
Res_QuotationCards,
Res_QuotationList,
@ -27,14 +30,18 @@ from router.v1.quotation.protocol import (
Res_QuotationSessions,
Res_QuotationStatus,
Res_SessionChat,
Res_TargetBreakdown,
TargetCandidate,
)
from services.email import EmailUnavailable, build_invite_email, send_email
from services.notification import create_notification
class QuotationService:
"""견적 비즈니스 로직.
quotations 테이블에는 company_id 가 없어 회사 스코핑은 하지 않는다(토큰 검증만).
user_id 는 생성 시 소유자로만 기록한다(조회/변경 시 소유권 필터 없음).
회사 스코프(멀티테넌트)는 작성자(user_id)→users.company_id 조인으로 건다(quotations 에 company_id 컬럼이 없음).
목록(list_quotations)은 회사 스코프로 제한한다. user_id 는 '내 견적만' 추가 필터로도 쓴다.
"""
# 기본 전략 버전(card.versions 시드). 견적 생성 시 version_id 미지정이면 이 값으로 채운다.
@ -43,6 +50,18 @@ class QuotationService:
# 재생성 한도: 한 체인(같은 견적번호)에서 사유(미참여/동가)별 최대 1번까지 재생성(순서 무관, 같은 사유 2번 불가).
MAX_REGEN_PER_CAUSE = 1
# 재생성 라운드의 최소 협상기간(방어적 하한). 원본 협상기간이 비정상적으로 짧으면(또는 0/음수면)
# 새 라운드가 생성 즉시 만료돼 다음 크론 tick(*/5분)에 또 마감되는 연쇄를 막는다.
# 정상 견적(수 시간~수일)은 원본 기간을 그대로 쓰며, 이 하한은 비정상적으로 짧은 경우에만 적용된다.
# TODO 하한값 변경 해야함 !!! feat. MarineYang
MIN_REGEN_DURATION = timedelta(hours=1)
# 인터넷 평균 수수료율(상수). 시장 평균값이라 견적/세팅별로 두지 않고 고정. 목표가=인터넷최저가×(1−값).
INTERNET_AVERAGE_FEE = 0.078
# 목표가 후보 basis 코드 ↔ 표시 라벨(산정내역 응답에서 프론트가 그대로 표기).
_CANDIDATE_LABELS = {"md": "MD 입력가", "internet": "인터넷 최저가", "purchase": "매입가", "selling": "판매가"}
def __init__(self, quotation_crud: IQuotationCRUD = Depends(QuotationCRUD)):
self.quotation_crud = quotation_crud
@ -53,22 +72,43 @@ class QuotationService:
return f"{base}/chat?session_id={session_id}"
@staticmethod
def _calc_target_price(price, margin) -> int:
"""세션 목표가(원). 단가 있으면 목표 마진율 적용가, 없으면 0."""
if not price:
return 0
if margin and margin > 0:
return int(int(price) / (1 + margin))
return int(price)
def _candidates(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False):
"""목표가 후보 [(basis, value_float)] 목록(빈 값/0 은 제외). md 있으면 md 단독.
값은 float(인터넷=가격×(1−수수료), 판매가=가격×(1−마진))이며 채택 시 int() 절삭한다.
_calc_target_price(생성)와 get_target_breakdown(표시)가 공유하는 단일 산정 로직."""
if md_price:
return [("md", float(int(md_price)))]
out = []
if internet_lowest:
out.append(("internet", int(internet_lowest) * (1 - (fee or 0.0))))
if not is_new: # 재(협상·견적)만 매입가·판매가를 후보에 추가. 신규는 인터넷최저가만.
if purchase:
out.append(("purchase", float(int(purchase))))
if selling:
out.append(("selling", int(selling) * (1 - (margin or 0.0))))
return out
@staticmethod
def _naive_utc(dt):
"""DB 컬럼이 naive(TIMESTAMP WITHOUT TIME ZONE)라, tz-aware 입력(프론트 toISOString 등)은 UTC naive 로 변환."""
if dt is None:
return dt
if getattr(dt, "tzinfo", None) is not None:
return dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt
def _calc_target_price(md_price=None, internet_lowest=None, purchase=None, selling=None, fee=0.0, margin=0.0, is_new=False) -> int:
"""세션 목표가 (KTC 신규/재 분리 로직, 회사 데이터 풍부도에 graceful 적응)
① md_price 있으면 → 그대로
② 없으면:
· 신규(NEW_NEGO/NEW_QUOTE) → 인터넷최저가 × (1 − fee) [인터넷최저가만]
· 재(RENEGO/REQUOTE) → 유효 후보 중 min:
- 인터넷최저가 × (1 − fee) ← fee=quotation_settings.internet_average_fee
- 매입가 (그대로)
- 판매가 × (1 − margin) ← margin=quotation_settings.target_margin_rate
③ 후보 0개 → 견적 생성 불가(ValueError)."""
if not md_price:
# 율은 비율(0~1 미만)이어야 한다. 1 이상이면 (1−율)≤0 → 목표가가 0/음수가 되므로 설정 오류로 막는다.
if not 0.0 <= (fee or 0.0) < 1.0:
raise ValueError(f"인터넷 수수료율은 0 이상 1 미만이어야 합니다: fee={fee}")
if not is_new and not 0.0 <= (margin or 0.0) < 1.0:
raise ValueError(f"목표 마진율은 0 이상 1 미만이어야 합니다: margin={margin}")
cands = QuotationService._candidates(md_price, internet_lowest, purchase, selling, fee, margin, is_new)
if not cands:
raise ValueError("타겟 가격 계산 불가: md_price·인터넷최저가" + ("" if is_new else "·매입가·판매가") + " 모두 없음")
return int(min(v for _, v in cands))
@staticmethod
def _gen_number() -> str:
@ -76,34 +116,98 @@ class QuotationService:
now = GTime.UTC()
return f"EST-{now:%Y%m}-{uuid.uuid4().hex[:4].upper()}"
async def _fetch(self, qt_id: uuid.UUID):
"""견적 단건 조회. (ErrorType, quotation|None) 반환. (회사 스코프 없음)"""
async def _fetch(self, qt_id: uuid.UUID, company_id=None):
"""견적 단건 조회. (ErrorType, quotation|None) 반환.
company_id 가 주어지면 회사 스코프(작성자 회사) 가드 — 남의 회사 견적은 NOT_FOUND. 내부/스케줄러 호출은 None."""
err_type, quotation = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_by_id(s, qt_id),
lambda s: self.quotation_crud.get_by_id(s, qt_id, company_id),
)
if err_type != ErrorType.SUCCESS or quotation is None:
return ErrorType.QUOTATION_NOT_FOUND, None
return ErrorType.SUCCESS, quotation
async def list_quotations(self, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
async def get_target_breakdown(self, session_id: str, company_id=None) -> Res_TargetBreakdown:
"""세션 목표가 산정내역(후보·채택). 저장된 target_price/anchoring 은 그대로 표기하고,
후보값은 생성과 동일한 _candidates 로직으로 계산해 내려준다(프론트 재계산 제거 → 항상 일치).
상속분(재생성 라운드)은 현재 후보와 무관하므로 is_inherited=True, 채택 표시는 비운다."""
res = Res_TargetBreakdown()
err_type, got = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_session_with_supplier(s, uuid.UUID(session_id)),
)
if err_type != ErrorType.SUCCESS or got is None:
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
return res
sess = got[0]
err_type, quotation = await self._fetch(sess.quotation_id, company_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
_e, prices = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_item_prices(s, [sess.item_id]),
)
internet, purchase, selling = (prices or {}).get(sess.item_id) or (None, None, None)
_e, rates = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_setting_rates(s, quotation.qt_setting_id),
)
rates = rates or {}
fee = self.INTERNET_AVERAGE_FEE
margin = rates.get("margin") or 0.0
anchoring = rates.get("anchoring") or 0.0
is_new = QuotationType.is_new(quotation.type)
md = quotation.md_price
cands = self._candidates(md, internet, purchase, selling, fee, margin, is_new)
chosen_basis, computed = None, None
if cands:
chosen_basis, chosen_val = min(cands, key=lambda c: c[1])
computed = int(chosen_val)
is_inherited = computed is None or computed != sess.target_price
res.is_new = is_new
res.is_inherited = is_inherited
res.md_price = int(md) if md else None
res.internet_lowest = int(internet) if internet is not None else None
res.purchase = int(purchase) if purchase is not None else None
res.selling = int(selling) if selling is not None else None
res.fee = fee
res.margin = margin
res.anchoring_value = anchoring
res.candidates = [TargetCandidate(basis=b, label=self._CANDIDATE_LABELS.get(b, b), value=int(v)) for b, v in cands]
res.chosen_basis = None if is_inherited else chosen_basis
res.target_price = sess.target_price
res.target_anchoring_price = sess.target_anchoring_price
return res
async def list_quotations(self, company_id, owner, search, status, type_, start_from, start_to, pg: PageParams) -> Res_QuotationList:
"""견적 목록. 회사(company_id) 스코프로 제한하고, owner(user_id) 가 주어지면 '내 견적만'으로 더 좁힌다."""
res = Res_QuotationList(page=pg.page, size=pg.size)
company_uuid = uuid.UUID(company_id)
owner_uuid = uuid.UUID(owner) if owner else None
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.search(s, search, status, type_, start_from, start_to, pg.skip, pg.size),
lambda s: self.quotation_crud.search(s, company_uuid, owner_uuid, search, status, type_, start_from, start_to, pg.skip, pg.size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 참여 협력사 수(세션 distinct supplier)와 대표 상품(세션 item)을 이 페이지 견적들에 대해
# 각각 한 방으로 모아 합친다(메인 쿼리 비건드림).
# 참여 협력사 수(세션 distinct supplier)·대표 상품(세션 item)·작성자명(user→users.name)을
# 이 페이지 견적들에 대해 각각 한 방으로 모아 합친다(메인 쿼리 비건드림).
qt_ids = [r.qt_id for r in rows]
counts = {}
item_map = {}
name_map = {}
if qt_ids:
cnt_err, got = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
@ -119,25 +223,50 @@ class QuotationService:
)
if im_err == ErrorType.SUCCESS:
item_map = got_im
user_ids = list({r.user_id for r in rows})
nm_err, got_nm = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.user_name_map(s, user_ids),
)
if nm_err == ErrorType.SUCCESS:
name_map = got_nm
for r in rows:
r.participation_count = counts.get(r.qt_id, 0)
item = item_map.get(r.qt_id)
if item:
r.item_id, r.item_name = item
r.creator_name = name_map.get(r.user_id)
res.quotations = [QuotationData.model_validate(r) for r in rows]
res.total = total
return res
async def get_quotation(self, qt_id: str) -> Res_Quotation:
async def get_quotation(self, qt_id: str, company_id=None) -> Res_Quotation:
res = Res_Quotation()
err_type, quotation = await self._fetch(uuid.UUID(qt_id))
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.quotation = QuotationData.model_validate(quotation)
return res
async def get_last_supplier_type(self, supplier_id: str, company_id=None) -> Res_LastSupplierType:
"""협력사의 직전 견적 supplier_type(견적생성 모달 프리필용). 이력 없으면 비워서 반환."""
res = Res_LastSupplierType()
err_type, got = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_last_supplier_type(s, uuid.UUID(supplier_id), company_id),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
if got:
res.supplier_type = got[0]
res.qt_number = got[1]
return res
async def create_quotation(self, user_id: str, req: Req_CreateQuotation) -> Res_CreateQuotation:
"""[프론트] 신규 견적 생성. 요청값을 보정한 뒤 공통 빌더(_build_quotation)에 위임한다."""
return await self._build_quotation(
@ -149,12 +278,14 @@ class QuotationService:
type_=req.type,
status=req.status or QuotationStatus.CREATED.value,
round_=req.round or 1,
start_time=self._naive_utc(req.start_time or GTime.UTC()),
end_time=self._naive_utc(req.end_time),
start_time=req.start_time or GTime.UTC(),
end_time=req.end_time,
manager_name=req.manager_name,
manager_email=req.manager_email,
manager_contact_number=req.manager_contact_number,
memo=req.memo,
md_price=req.md_price,
supplier_type=req.supplier_type,
item_ids=req.item_ids,
supplier_ids=req.supplier_ids,
card_ids=req.card_ids,
@ -184,15 +315,26 @@ class QuotationService:
lambda s: self.quotation_crud.list_sessions(s, original_qt_id),
)
item_ids = list({r.item_id for r in rows}) if err_type == ErrorType.SUCCESS else []
# 재생성은 목표가/앵커링가를 재계산하지 않고 직전 라운드 세션 값을 그대로 상속(KTC 방식).
inherited = {r.item_id: (r.target_price, r.target_anchoring_price) for r in rows} if err_type == ErrorType.SUCCESS else {}
# 2) 타입 결정: 공급사 1곳 → 재협상 / 여러 곳 → 재견적
next_type = QuotationType.RENEGO.value if len(supplier_ids) <= 1 else QuotationType.REQUOTE.value
# 3) 다음 라운드의 견적 생성
now = GTime.UTC()
duration = original.end_time - original.start_time
# 진입 경로(크론 마감 / 수동 regenerate_quotation) 모두 '마지막 차수'만 넘기므로 +1 이 곧 체인 다음 차수.
next_round = original.round + 1
# 원본 협상기간을 이어쓰되, 비정상적으로 짧으면 최소 하한을 적용(즉시 만료→연쇄 재마감 방지).
duration = max(original.end_time - original.start_time, self.MIN_REGEN_DURATION)
# 다음 차수는 '원본 round+1' 이 아니라 '체인(같은 번호) 최신 round+1'.
# 크론 마감과 수동 regenerate_quotation 이 같은 체인을 처리하는 타이밍이 엇갈려도
# 항상 체인 끝에 이어붙어 uq_quotations_number(number, round) 충돌을 막는다.
_e, chain_max = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.chain_max_round(s, original.number),
)
base_round = chain_max if (_e == ErrorType.SUCCESS and chain_max) else original.round
next_round = base_round + 1
# 이름에 '(N차)' 표기. 원래 이름 기준(기존 '(M차)' 표기는 떼고 새로) + name 컬럼 50자 제한 보호.
suffix = f" ({next_round}차)"
base_name = re.sub(r"\s*\(\d+차\)\s*$", "", original.name or "")[: 50 - len(suffix)]
@ -211,22 +353,26 @@ class QuotationService:
manager_email=original.manager_email,
manager_contact_number=original.manager_contact_number,
memo=original.memo,
md_price=original.md_price,
supplier_type=original.supplier_type,
item_ids=item_ids,
supplier_ids=list(supplier_ids),
card_ids=[], # 새 버전 안 만듦(원본 version_id 재사용)
inherited=inherited, # 직전 라운드 목표가·앵커링가 상속(재계산 안 함)
)
async def _build_quotation(
self, *,
user_id: str, qt_setting_id, version_id, name: str, number: str,
type_: int, status: int, round_: int, start_time, end_time,
manager_name, manager_email, manager_contact_number, memo,
manager_name, manager_email, manager_contact_number, memo, md_price, supplier_type,
item_ids: list, supplier_ids: list, card_ids: list,
inherited: Optional[dict] = None, # 재생성 시 {item_id: (target_price, target_anchoring_price)} 상속(KTC) — 있으면 재계산 안 함
) -> Res_CreateQuotation:
"""견적 1건 + (상품×공급사) 세션들을 한 트랜잭션으로 생성하는 공통 빌더."""
res = Res_CreateQuotation()
# 세션 목표가 입력(상품 단가 + 견적 세팅 목표 마진율). 읽기 트랜잭션에서 먼저 조회.
# 세션 목표가 입력(상품별 인터넷최저가/매입가/판매가 + 세팅 율). 읽기 트랜잭션에서 먼저 조회.
prices = {}
if item_ids:
_err, prices = await DB_SESSION_MNG.execute_lambda(
@ -235,12 +381,15 @@ class QuotationService:
lambda s: self.quotation_crud.get_item_prices(s, item_ids),
)
prices = prices if _err == ErrorType.SUCCESS else {}
_err, margin = await DB_SESSION_MNG.execute_lambda(
_err, rates = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_target_margin(s, qt_setting_id),
lambda s: self.quotation_crud.get_setting_rates(s, qt_setting_id),
)
margin = margin if _err == ErrorType.SUCCESS else None
rates = rates if _err == ErrorType.SUCCESS else {}
fee = self.INTERNET_AVERAGE_FEE # 인터넷가 차감 수수료율(상수)
margin = rates.get("margin") or 0.0 # 판매가 차감 목표마진율
anchoring = rates.get("anchoring") or 0.0 # 앵커링가 = 목표가×(1−값)
# 선택 협상카드가 있으면 새 버전을 만들어 카드들을 묶고, quotation.version_id 로 연결한다.
# (quotation↔card 는 version → version_nego_cards/version_wild_cards 로 연결.)
@ -286,27 +435,47 @@ class QuotationService:
manager_email=manager_email,
manager_contact_number=manager_contact_number,
memo=memo,
md_price=md_price,
supplier_type=supplier_type,
)
# 상품 × 공급사 조합마다 세션 1개.
# 상품 × 공급사 조합마다 세션 1개. md/매입/판매/인터넷 후보가 하나도 없으면 목표가 산정 불가 → 생성 실패.
# 신규(NEW_NEGO/NEW_QUOTE)는 인터넷최저가만, 재(RENEGO/REQUOTE)는 매입가·판매가까지 후보(KTC 신규/재 분리).
is_new = QuotationType.is_new(type_)
session_objs = []
for iid in item_ids:
tp = self._calc_target_price(prices.get(iid), margin)
for sid in supplier_ids:
session_objs.append(
sessions(
session_id=uuid.uuid4(),
quotation_id=qt_id,
item_id=iid,
supplier_id=sid,
qt_number=quotation.number,
qt_round=quotation.round,
qt_type=quotation.type,
target_price=tp,
status=SessionStatus.CREATED.value,
end_time=quotation.end_time,
try:
for iid in item_ids:
if inherited and iid in inherited:
tp, ap = inherited[iid] # 재생성: 직전 라운드 목표가·앵커링가 그대로 상속(KTC) — 재계산 안 함
else:
internet, purchase, selling = prices.get(iid) or (None, None, None)
tp = self._calc_target_price(md_price, internet, purchase, selling, fee, margin, is_new=is_new)
if not 0.0 <= anchoring < 1.0: # 율 1 이상이면 앵커링가가 0/음수 → 설정 오류로 막는다.
raise ValueError(f"앵커링 값은 0 이상 1 미만이어야 합니다: anchoring={anchoring}")
ap = int(tp * (1 - anchoring)) # 앵커링가 = floor(목표가×(1−앵커링율)); 율 0이면 목표가와 동일
for sid in supplier_ids:
session_objs.append(
sessions(
session_id=uuid.uuid4(),
quotation_id=qt_id,
item_id=iid,
supplier_id=sid,
qt_number=quotation.number,
qt_round=quotation.round,
qt_type=quotation.type,
target_price=tp,
target_anchoring_price=ap,
status=SessionStatus.CREATED.value,
end_time=quotation.end_time,
)
)
)
except ValueError as ex:
LOG.w(
f"[목표가 산정불가] qt_id={qt_id} item={iid} is_new={is_new} "
f"md={md_price} internet={internet} purchase={purchase} selling={selling} :: {ex}"
)
res.result.SetResult(ErrorType.QUOTATION_TARGET_PRICE_UNAVAILABLE)
return res
# 버전 → (버전-카드 매핑) → 견적 → 세션 순으로 한 트랜잭션에 insert(FK 순서 보장).
ops = []
@ -372,6 +541,26 @@ class QuotationService:
],
)
async def _close_as_no_show(self, qt_uuid) -> None:
"""전원 미참여로 '다음 라운드 재생성' 하며 마감 + 미완료 세션 미참여.
재생성 사유(미참여)를 체인에 남기기 위해 preferred_sp_yn=False, equal_bid_yn=False 로 양성 표식한다
(단독낙찰=preferred_sp_yn True / 동가=equal_bid_yn True / 거부·한도 등 그냥 마감=둘 다 NULL 과 구분).
_chain_regen_counts 가 이 표식으로 '미참여 재생성 이력'만 정확히 센다."""
data = {
"status": QuotationStatus.CLOSED.value,
"preferred_sp_yn": False,
"equal_bid_yn": False,
}
await DB_SESSION_MNG.execute_lambda_run(
[quotations.DBType()],
[
lambda s: self.quotation_crud.update_quotation(s, qt_uuid, data),
lambda s: self.quotation_crud.update_sessions_status(
s, qt_uuid, [SessionStatus.CREATED.value, SessionStatus.IN_PROGRESS.value], SessionStatus.NOT_PARTICIPATED.value
),
],
)
async def _close_as_equal(self, qt_uuid, equal) -> None:
"""동가로 마감 + 미완료 세션 미참여. equal_bid_yn/data 를 기록해 둔다
(재생성 한도 계산이 이 플래그로 동가 라운드를 식별하고, 프론트도 동가 정보를 그대로 쓴다)."""
@ -407,6 +596,17 @@ class QuotationService:
if err_type != ErrorType.SUCCESS or original is None:
return CloseOutcome.CLOSED
# [동시 마감 가드] 마감 판정 전에 원자적으로 status→CLOSED 를 선점한다.
# 두 크론 잡(close_expired / close_negotiated)이나 수동 stop_quotation 이 같은 견적을
# 동시에 닫으려 해도, 실제로 CLOSED 로 전이한 호출자만 통과하고 진 호출자는 여기서 끝난다
# → 이중 재생성·uq(number,round) 충돌 방지. (이미 닫힌 견적의 재처리도 여기서 차단)
claim_err, claimed = await DB_SESSION_MNG.execute_lambda_claim(
quotations.DBType(),
lambda s: self.quotation_crud.claim_for_close(s, qt_uuid),
)
if claim_err != ErrorType.SUCCESS or claimed == 0:
return CloseOutcome.CLOSED
err_type, rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
@ -421,6 +621,13 @@ class QuotationService:
# 1) 단독 낙찰 → 확정
if winner is not None:
await self._award_and_close(qt_uuid, winner)
winner_price = min((int(bp) for _, bp, _ in done if bp is not None), default=None)
await create_notification(
original.user_id, NotificationType.SUCCESS,
{"qt_name": original.name, "qt_number": original.number,
"winner_name": winner["name"], "winner_price": winner_price},
ref_qt_id=qt_uuid,
)
return CloseOutcome.AWARDED
# 동가/미참여 재생성은 사유별 한도(각 1번, 순서 무관) 확인 후
@ -430,45 +637,83 @@ class QuotationService:
if equal is not None and equal_used < self.MAX_REGEN_PER_CAUSE:
tied_ids = [uuid.UUID(sp["supplier_id"]) for sp in equal["suppliers"]]
await self._close_as_equal(qt_uuid, equal) # 동가 기록(equal_bid_yn) 후 마감
await self.regenerate_next_round(qt_uuid, tied_ids)
regen = await self.regenerate_next_round(qt_uuid, tied_ids)
if not regen.result.success:
# 원본은 이미 CLOSED 인데 다음 라운드 생성이 실패 → 체인이 끊긴 상태. 성공으로 위장하지 않고 드러낸다.
LOG.e_no_callstack(
f"[close] 동가 재생성 실패 qt={qt_uuid} number={original.number} round={original.round} "
f"code={regen.result.code}({regen.result.desc})"
)
return CloseOutcome.REGEN_FAILED
await create_notification(
original.user_id, NotificationType.REGENERATED,
{"qt_name": original.name, "qt_number": original.number, "reason": "equal",
"next_round": original.round + 1, "tied_price": equal["price"], "tied_count": len(equal["suppliers"])},
ref_qt_id=regen.qt_id,
)
return CloseOutcome.REGENERATED
# 3) 협상거부 있음 → 마감만 (재생성 안 함)
if has_rejected:
await self._just_close(qt_uuid)
await create_notification(
original.user_id, NotificationType.FAILURE,
{"qt_name": original.name, "qt_number": original.number, "reason": "rejected"},
ref_qt_id=qt_uuid,
)
return CloseOutcome.CLOSED
# 4) 전원 미참여 → 공급사 전체로 다음 라운드 (체인에 미참여 재생성 이력 없을 때만)
if not done and rows and no_part_used < self.MAX_REGEN_PER_CAUSE:
supplier_ids = list({r.supplier_id for r in rows})
await self._just_close(qt_uuid)
await self.regenerate_next_round(qt_uuid, supplier_ids)
await self._close_as_no_show(qt_uuid) # 미참여 재생성 표식(preferred_sp_yn=False, equal_bid_yn=False) 후 마감
regen = await self.regenerate_next_round(qt_uuid, supplier_ids)
if not regen.result.success:
# 원본은 이미 CLOSED 인데 다음 라운드 생성이 실패 → 체인이 끊긴 상태. 성공으로 위장하지 않고 드러낸다.
LOG.e_no_callstack(
f"[close] 미참여 재생성 실패 qt={qt_uuid} number={original.number} round={original.round} "
f"code={regen.result.code}({regen.result.desc})"
)
return CloseOutcome.REGEN_FAILED
await create_notification(
original.user_id, NotificationType.REGENERATED,
{"qt_name": original.name, "qt_number": original.number, "reason": "no_show", "next_round": original.round + 1},
ref_qt_id=regen.qt_id,
)
return CloseOutcome.REGENERATED
# 5) 그 외 / 한도 도달 → 마감만
await self._just_close(qt_uuid)
await create_notification(
original.user_id, NotificationType.FAILURE,
{"qt_name": original.name, "qt_number": original.number, "reason": "closed"},
ref_qt_id=qt_uuid,
)
return CloseOutcome.CLOSED
async def _chain_regen_counts(self, number: str, current_round: int) -> tuple[int, int]:
"""체인(같은 견적번호) 이전 라운드들의 재생성 사유 횟수. 반환: (미참여 횟수, 동가 횟수).
동가로 닫힌 라운드는 equal_bid_yn=True 로 기록되므로 그 플래그로 센다.
(이전 라운드는 동가 아니면 미참여 둘뿐 — 단독낙찰·거부는 체인을 끝냄 → equal_bid_yn 이 True 아니면 미참여)."""
"""체인(같은 견적번호) 이전 라운드들의 '재생성 사유' 횟수. 반환: (미참여 횟수, 동가 횟수).
마감 시 남긴 양성 표식으로만 센다(오집계 방지):
- 동가 재생성 → equal_bid_yn=True
- 미참여 재생성 → preferred_sp_yn=False AND equal_bid_yn=False
단독낙찰(preferred_sp_yn=True)·거부/한도 그냥 마감(둘 다 NULL)은 어느 쪽에도 세지 않는다.
(수동 regenerate_quotation 으로 단독낙찰·거부 라운드를 이어붙여도 자동 재생성 한도에 영향 없음.)"""
err_type, flags = await DB_SESSION_MNG.execute_lambda(
quotations.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.list_chain_equal_flags(s, number, current_round),
lambda s: self.quotation_crud.list_chain_close_flags(s, number, current_round),
)
if err_type != ErrorType.SUCCESS:
return 0, 0
equal = sum(1 for f in flags if f is True)
no_part = len(flags) - equal
equal = sum(1 for _pref, eq in flags if eq is True)
no_part = sum(1 for pref, eq in flags if pref is False and eq is False)
return no_part, equal
async def regenerate_quotation(self, qt_id: str, supplier_ids: list) -> Res_CreateQuotation:
async def regenerate_quotation(self, qt_id: str, company_id, supplier_ids: list) -> Res_CreateQuotation:
"""[프론트] 마감된 견적을 골라 수동으로 다음 라운드를 생성한다.
크론/수동마감의 자동 재생성과 달리 사유·체인 한도 판정 없이, 프론트가 고른 공급사로 바로 만든다.
상품·기간·견적번호·카드버전은 원 견적에서 이어받는다(regenerate_next_round)."""
res = Res_CreateQuotation()
qt_uuid = uuid.UUID(qt_id)
err_type, original = await self._fetch(qt_uuid)
err_type, original = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS or original is None:
res.result.SetResult(err_type)
return res
@ -494,26 +739,26 @@ class QuotationService:
return await self.regenerate_next_round(qt_uuid, supplier_ids)
async def stop_quotation(self, qt_id: str) -> Res_Quotation:
async def stop_quotation(self, qt_id: str, company_id=None) -> Res_Quotation:
"""[프론트] 수동 견적마감. 크론과 똑같은 마감 판정(close_and_decide)을 탄다
(단독낙찰 확정 / 동가·미참여면 다음 라운드 재생성 / 거부·한도면 그냥 마감)."""
res = Res_Quotation()
qt_uuid = uuid.UUID(qt_id)
# 존재 확인
err_type, _ = await self._fetch(qt_uuid)
# 존재 확인(+회사 가드)
err_type, _ = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
await self.close_and_decide(qt_uuid)
return await self.get_quotation(qt_id)
return await self.get_quotation(qt_id, company_id)
async def delete_quotation(self, qt_id: str) -> Res_DeleteQuotation:
async def delete_quotation(self, qt_id: str, company_id=None) -> Res_DeleteQuotation:
res = Res_DeleteQuotation()
qt_uuid = uuid.UUID(qt_id)
err_type, _ = await self._fetch(qt_uuid)
err_type, _ = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
@ -526,9 +771,9 @@ class QuotationService:
res.result.SetResult(err_type)
return res
async def get_status(self, qt_id: str) -> Res_QuotationStatus:
async def get_status(self, qt_id: str, company_id=None) -> Res_QuotationStatus:
res = Res_QuotationStatus()
err_type, quotation = await self._fetch(uuid.UUID(qt_id))
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
@ -537,9 +782,9 @@ class QuotationService:
res.message = "ok"
return res
async def get_result(self, qt_id: str) -> Res_QuotationResult:
async def get_result(self, qt_id: str, company_id=None) -> Res_QuotationResult:
res = Res_QuotationResult()
err_type, quotation = await self._fetch(uuid.UUID(qt_id))
err_type, quotation = await self._fetch(uuid.UUID(qt_id), company_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
@ -552,10 +797,10 @@ class QuotationService:
res.result_count = 0
return res
async def list_sessions(self, qt_id: str) -> Res_QuotationSessions:
async def list_sessions(self, qt_id: str, company_id=None) -> Res_QuotationSessions:
res = Res_QuotationSessions()
qt_uuid = uuid.UUID(qt_id)
err_type, quotation = await self._fetch(qt_uuid)
err_type, quotation = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
@ -581,6 +826,7 @@ class QuotationService:
qt_round=r.qt_round,
qt_type=r.qt_type,
target_price=r.target_price,
target_anchoring_price=r.target_anchoring_price,
status=r.status,
bid_price=r.bid_price,
bid_at=r.bid_at,
@ -588,6 +834,7 @@ class QuotationService:
reject_reason=r.reject_reason,
reject_price=r.reject_price,
reject_delivery_type=r.reject_delivery_type,
email_sent_at=r.email_sent_at,
url=self._session_chat_url(r.session_id),
)
for r in rows
@ -595,11 +842,126 @@ class QuotationService:
res.total = len(res.sessions)
return res
async def list_chats(self, session_id: str) -> Res_SessionChat:
# ----- 협상 초청 메일 (수동 발송)
async def notify_sessions(self, qt_id: str, company_id=None) -> Res_NotifySessions:
"""[수동 발송] 견적의 '미발송' 세션(공급사 담당자)에게 협상 초청 메일을 일괄 발송한다.
대상 = email_sent_at IS NULL + 담당자 이메일 보유."""
res = Res_NotifySessions()
qt_uuid = uuid.UUID(qt_id)
err_type, quotation = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
err_type, rows = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.list_sessions_with_supplier(s, qt_uuid),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
# 행 언팩: (session, supplier_name, manager_email).
targets = [] # [(session, name, email)]
for r in rows:
sess, sp_name, email = r[0], r[1], r[2]
res.total += 1
if sess.email_sent_at is not None:
continue # 이미 발송됨 — 재발송은 행 단위 endpoint 로
if not email:
res.skipped += 1
continue
targets.append((sess, sp_name, email))
sent_ids = await self._send_invites(quotation, targets, res)
if sent_ids:
await self._mark_emailed(sent_ids)
return res
async def notify_session(self, session_id: str, company_id=None) -> Res_NotifySessions:
"""[수동 재발송] 단일 세션(공급사)에 초청 메일 발송(이미 보냈어도 강제 재발송)."""
res = Res_NotifySessions()
sess_uuid = uuid.UUID(session_id)
err_type, got = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_session_with_supplier(s, sess_uuid),
)
if err_type != ErrorType.SUCCESS or got is None:
res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
return res
sess, sp_name, email = got[0], got[1], got[2]
res.total = 1
err_type, quotation = await self._fetch(sess.quotation_id, company_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
if not email:
res.skipped = 1
return res
sent_ids = await self._send_invites(quotation, [(sess, sp_name, email)], res)
if sent_ids:
await self._mark_emailed(sent_ids)
return res
async def _send_invites(self, quotation, targets: list, res: Res_NotifySessions) -> list:
"""targets [(session, supplier_name, email)] 에 초청 메일 발송. res.sent/failed 를 채우고
성공한 session_id 목록을 반환. ACS/SMTP 미설정이면 첫 발송에서 중단(EMAIL_NOT_CONFIGURED)."""
sent_ids = []
for sess, sp_name, email in targets:
subject, html, text = build_invite_email(
supplier_name=sp_name or "",
quotation_name=quotation.name,
qt_number=quotation.number,
end_time=quotation.end_time,
chat_url=self._session_chat_url(sess.session_id),
)
try:
await send_email(email, subject, html, text)
sent_ids.append(sess.session_id)
res.sent += 1
except EmailUnavailable as e:
res.result.SetResult(ErrorType.EMAIL_NOT_CONFIGURED) # 발송 채널 없음 — 더 시도해도 무의미
res.msg = str(e)
break
except Exception as ex:
LOG.e_no_callstack(ex)
res.failed += 1
# 보낼 대상이 있었는데 전부 실패면 명시적 실패 코드(설정은 됐으나 발송 실패).
if res.sent == 0 and res.failed > 0 and res.result.success:
res.result.SetResult(ErrorType.EMAIL_SEND_FAILED)
return sent_ids
async def _mark_emailed(self, session_ids: list) -> None:
"""발송 성공 세션들의 email_sent_at 갱신(write 트랜잭션)."""
now = GTime.UTC()
await DB_SESSION_MNG.execute_lambda_run(
[sessions.DBType()],
[lambda s: self.quotation_crud.mark_sessions_emailed(s, session_ids, now)],
)
async def list_chats(self, session_id: str, company_id=None) -> Res_SessionChat:
res = Res_SessionChat()
sess_uuid = uuid.UUID(session_id)
res.session_id = sess_uuid
# 회사 가드: chats 는 session 키라 세션→견적→회사로 확인한다(남의 회사 세션이면 NOT_FOUND).
if company_id is not None:
g_err, got = await DB_SESSION_MNG.execute_lambda(
sessions.DBType(),
DBWRType.DB_READ.value,
lambda s: self.quotation_crud.get_session_with_supplier(s, sess_uuid),
)
if g_err != ErrorType.SUCCESS or got is None:
res.result.SetResult(g_err if g_err != ErrorType.SUCCESS else ErrorType.QUOTATION_NOT_FOUND)
return res
guard_err, _ = await self._fetch(got[0].quotation_id, company_id)
if guard_err != ErrorType.SUCCESS:
res.result.SetResult(guard_err)
return res
err_type, rows = await DB_SESSION_MNG.execute_lambda(
chats.DBType(),
DBWRType.DB_READ.value,
@ -629,10 +991,10 @@ class QuotationService:
]
return res
async def list_cards(self, qt_id: str) -> Res_QuotationCards:
async def list_cards(self, qt_id: str, company_id=None) -> Res_QuotationCards:
res = Res_QuotationCards()
qt_uuid = uuid.UUID(qt_id)
err_type, quotation = await self._fetch(qt_uuid)
err_type, quotation = await self._fetch(qt_uuid, company_id)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res

View File

@ -1,34 +1,15 @@
"""auth 도메인 e2e 테스트 (negodata: users/companies 기반).
"""auth 도메인 e2e — 로그인 / 내정보 / 인증거부 흐름.
실행 전제: PostgreSQL(negodata_db)이 떠 있어야 한다.
docker compose up -d # 또는 로컬 postgres
cd negodata/backend && python -m pytest
계정 생성은 company_id 를 요구하므로 company_id 픽스처(conftest)가 소속사를 시드한다.
계정 생성·중복·최고관리자 스코프는 test_company_user.py. 유저 시드/로그인은 auth_headers 픽스처.
"""
async def test_create_and_login_flow(client, company_id):
# 1) 계정 생성 (회사 하위로)
r = await client.post(
"/v1/auth/create",
json={"id": "user1", "password": "pw1234", "company_id": company_id, "name": "홍길동"},
)
assert r.status_code == 200
body = r.json()
assert body["result"]["success"] is True
assert body["user_id"]
async def test_login_and_me_flow(auth_headers, client, company_id):
"""검증: 시드된 유저가 로그인해 받은 토큰으로 /me 호출.
기대결과: 200, 본인 id·name·소속사(company_id)가 그대로 반환."""
h = await auth_headers("user1", name="홍길동")
# 2) 로그인 -> 토큰 발급
r = await client.post("/v1/auth/login", json={"id": "user1", "password": "pw1234"})
assert r.status_code == 200
body = r.json()
assert body["result"]["success"] is True
assert body["access_token"]
assert body["refresh_token"]
access_token = body["access_token"]
# 3) 보호된 엔드포인트(/me) — 토큰의 유저 + 소속사 반환
r = await client.get("/v1/auth/me", headers={"Authorization": f"Bearer {access_token}"})
r = await client.get("/v1/auth/me", headers=h)
assert r.status_code == 200
me = r.json()
assert me["id"] == "user1"
@ -36,43 +17,27 @@ async def test_create_and_login_flow(client, company_id):
assert me["company"]["company_id"] == company_id
async def test_login_with_wrong_password(client, company_id):
await client.post(
"/v1/auth/create",
json={"id": "user2", "password": "correct", "company_id": company_id, "name": "n"},
)
async def test_login_with_wrong_password(auth_headers, client):
"""검증: 존재하는 계정에 '틀린 비밀번호'로 로그인.
기대결과: 로그인 실패 — success=False, code=1200(ACCOUNT_INVALID_INFO), 토큰 빈 문자열."""
await auth_headers("user2") # pw1234 로 시드
r = await client.post("/v1/auth/login", json={"id": "user2", "password": "wrong"})
assert r.status_code == 200
body = r.json()
assert body["result"]["success"] is False
# 자격증명 오류는 ACCOUNT_INVALID_INFO(1200)
assert body["result"]["code"] == 1200
assert body.get("access_token", "") == "" # 실패 시 토큰은 빈 문자열
assert body.get("access_token", "") == ""
async def test_login_nonexistent_account(client):
"""검증: 존재하지 않는 계정으로 로그인.
기대결과: 실패 — success=False (계정 유무를 '틀린 비번'과 구분해 흘리지 않음)."""
r = await client.post("/v1/auth/login", json={"id": "ghost", "password": "whatever"})
assert r.json()["result"]["success"] is False
async def test_duplicate_account_create(client, company_id):
r1 = await client.post(
"/v1/auth/create",
json={"id": "dup", "password": "pw1234", "company_id": company_id, "name": "n"},
)
assert r1.json()["result"]["success"] is True
r2 = await client.post(
"/v1/auth/create",
json={"id": "dup", "password": "pw5678", "company_id": company_id, "name": "n2"},
)
body = r2.json()
assert body["result"]["success"] is False
# ACCOUNT_ALREADY_EXIST(1201)
assert body["result"]["code"] == 1201
async def test_me_without_token_is_rejected(client):
"""검증: 토큰 없이 보호 엔드포인트 /me 호출.
기대결과: 인증 단계에서 거부 — HTTP 401 또는 403."""
r = await client.get("/v1/auth/me")
assert r.status_code in (401, 403) # HTTPBearer 가 자격증명 없음을 거부
assert r.status_code in (401, 403)

View File

@ -0,0 +1,180 @@
"""close_and_decide 동시성·정합성 수정 검증 (코드리뷰 후속).
검증 대상:
- #2 동시 이중 마감 가드: 같은 견적을 동시에 close_and_decide 해도 다음 라운드는 1개만 생성
- #3 차수 매김: 다음 라운드 round = 체인 최신 round + 1
- #4 재생성 사유 집계: 단독낙찰(preferred_sp_yn=True) 이전 라운드를 '미참여'로 오집계하지 않음
- #6 재생성 라운드 최소 협상기간 하한(즉시 재마감 캐스케이드 방지)
용어: 체인 = 같은 견적번호(number)로 이어지는 라운드들 / 미참여 = 공급사가 협상에 안 들어온 채 마감됨 /
재생성 = 결판 안 난 견적의 '다음 라운드'를 자동 생성 / 재생성 한도 = 사유(미참여·동가)별로 체인당 1번까지만.
"""
import asyncio
import uuid
from datetime import datetime, timedelta
import pytest_asyncio
from sqlalchemy import text
from common.enums import CloseOutcome, QuotationStatus, QuotationType, SessionStatus
from crud.quotation_crud import QuotationCRUD
from services.quotation_service import QuotationService
PAST = datetime(2020, 1, 1)
@pytest_asyncio.fixture
async def clean(db_engine):
async with db_engine.begin() as conn:
await conn.execute(text("TRUNCATE TABLE sessions, quotations RESTART IDENTITY CASCADE"))
return db_engine
async def test_concurrent_close_creates_only_one_next_round(clean):
"""검증: 같은 견적(전원 미참여)을 5번 동시에 close_and_decide.
기대결과: 재생성은 1번만(REGENERATED=1), 체인은 [1,2] — 이중 재생성/충돌 없음."""
engine = clean
number = "C-CONCURRENT"
qt = await _seed_quotation(engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value)
# 전원 미참여(미시작 세션만) → close_and_decide 가 '다음 라운드 재생성' 경로를 탄다
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
service = QuotationService(QuotationCRUD())
outcomes = await asyncio.gather(*[service.close_and_decide(qt) for _ in range(5)])
regenerated = sum(1 for o in outcomes if o == CloseOutcome.REGENERATED)
rounds = await _rounds(engine, number)
round_numbers = [r.round for r in rounds]
assert regenerated == 1, f"재생성은 1번만 일어나야 함, 실제 {regenerated} ({outcomes})"
assert round_numbers == [1, 2], f"체인은 [1,2] 여야 함(중복/충돌 없음), 실제 {round_numbers}"
async def test_next_round_numbering_and_min_duration(clean):
"""검증: 협상기간이 0인 견적을 미참여로 재생성.
기대결과: 체인 [1,2](round=최신+1), 새 라운드 협상기간 ≥ MIN_REGEN_DURATION(즉시 재마감 방지)."""
engine = clean
number = "C-DURATION"
# start==end (협상기간 0) → 하한이 적용되지 않으면 새 라운드도 0 길이가 된다
qt = await _seed_quotation(
engine, number=number, round_=1, status=QuotationStatus.IN_PROGRESS.value,
start_time=PAST, end_time=PAST,
)
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
service = QuotationService(QuotationCRUD())
outcome = await service.close_and_decide(qt)
assert outcome == CloseOutcome.REGENERATED
rounds = await _rounds(engine, number)
assert [r.round for r in rounds] == [1, 2]
nxt = rounds[1]
duration = nxt.end_time - nxt.start_time
assert duration >= QuotationService.MIN_REGEN_DURATION, (
f"재생성 라운드 협상기간({duration})이 최소 하한({QuotationService.MIN_REGEN_DURATION}) 이상이어야 함"
)
async def test_awarded_prior_round_not_counted_as_no_show(clean):
"""검증: round1=단독낙찰 + round2=전원 미참여 인 체인에서 round2 를 마감.
기대결과: REGENERATED, 체인 [1,2,3] — 단독낙찰 라운드를 '미참여'로 오집계해 재생성을 막지 않는다."""
engine = clean
number = "C-AWARDED-PRIOR"
# round 1: 단독낙찰로 마감(preferred_sp_yn=True). 수동 재생성 등으로 체인이 이어진 상황을 가정.
await _seed_quotation(
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
preferred_sp_yn=True, equal_bid_yn=False,
)
# round 2: 전원 미참여 → 미참여 재생성이 일어나야 한다(round 1 은 미참여로 세면 안 됨)
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
service = QuotationService(QuotationCRUD())
outcome = await service.close_and_decide(qt2)
rounds = await _rounds(engine, number)
round_numbers = [r.round for r in rounds]
assert outcome == CloseOutcome.REGENERATED, (
f"단독낙찰 이전 라운드는 미참여 예산을 소진하지 않아 round2 가 재생성돼야 함, 실제 {outcome}"
)
assert round_numbers == [1, 2, 3], f"round 3 이 생성돼야 함, 실제 {round_numbers}"
async def test_no_show_prior_round_consumes_budget(clean):
"""검증: round1=미참여 재생성 + round2=전원 미참여 인 체인에서 round2 를 마감.
기대결과: CLOSED, 체인 [1,2] — 미참여 재생성 한도(1) 소진돼 재생성 없이 그냥 마감(round3 없음)."""
engine = clean
number = "C-NOSHOW-PRIOR"
# round 1: 미참여로 마감(양성 표식) → no_part 예산 1 소진
await _seed_quotation(
engine, number=number, round_=1, status=QuotationStatus.CLOSED.value,
preferred_sp_yn=False, equal_bid_yn=False,
)
# round 2: 또 전원 미참여 → 한도 도달이라 재생성 없이 그냥 마감
qt2 = await _seed_quotation(engine, number=number, round_=2, status=QuotationStatus.IN_PROGRESS.value)
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
service = QuotationService(QuotationCRUD())
outcome = await service.close_and_decide(qt2)
rounds = await _rounds(engine, number)
assert outcome == CloseOutcome.CLOSED, f"미참여 예산 소진 → 그냥 마감이어야 함, 실제 {outcome}"
assert [r.round for r in rounds] == [1, 2], "재생성되면 안 됨(round 3 없음)"
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 상태·마감 표식을 SQL 로 직접 세팅) =====
async def _seed_quotation(
engine, *, number, round_, status, start_time=PAST, end_time=PAST,
preferred_sp_yn=None, equal_bid_yn=None,
):
"""견적 1건 시드. number/round_ 로 체인을, preferred_sp_yn·equal_bid_yn 으로 '이전 라운드가 어떻게 마감됐는지'를 만든다."""
qt_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn) VALUES "
"(:qt_id, :user_id, :qt_setting_id, :version_id, :name, :number, :type, :status, "
" :round, 0, :start_time, :end_time, false, :pref, :eq)"
),
{
"qt_id": qt_id, "user_id": uuid.uuid4(), "qt_setting_id": uuid.uuid4(),
"version_id": uuid.uuid4(), "name": "견적", "number": number,
"type": QuotationType.REQUOTE.value, "status": status, "round": round_,
"start_time": start_time, "end_time": end_time,
"pref": preferred_sp_yn, "eq": equal_bid_yn,
},
)
return qt_id
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None):
"""세션 1건 시드(공급사 협상 1건)."""
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
" target_price, status, bid_price, end_time) VALUES "
"(:session_id, :quotation_id, :item_id, :supplier_id, :qt_number, :qt_round, :qt_type, "
" 0, :status, :bid_price, :end_time)"
),
{
"session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(),
"supplier_id": supplier_id or uuid.uuid4(), "qt_number": "Q", "qt_round": 1,
"qt_type": QuotationType.REQUOTE.value, "status": status,
"bid_price": bid_price, "end_time": PAST,
},
)
async def _rounds(engine, number):
"""체인(number)의 (round, status, start_time, end_time) 목록 — round 오름차순."""
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT round, status, start_time, end_time FROM quotations "
"WHERE number = :n ORDER BY round"),
{"n": number},
)).all()

View File

@ -0,0 +1,140 @@
"""회사 스코프(멀티테넌트) — 회사 소유 자원은 '내 회사 것'만 보이고, 남의 회사 것은 막힌다(보안 회귀 방지).
회사 A 자원을 만들어 두고 회사 B 유저 토큰으로 접근하면 '없음'으로 막히는지 확인한다.
막힘 코드: 견적 1500 / 상품 1300 / 협력사 1400. 견적 하위(세션·상태·결과·카드)도 견적 통해 1500.
견적세팅만 예외 — 회사가 아니라 '유저' 스코프라, 같은 회사라도 다른 유저면 못 본다(1600).
"""
import uuid
from datetime import datetime
from sqlalchemy import text
from common.enums import QuotationStatus, QuotationType, SessionStatus
PAST = datetime(2020, 1, 1)
FUTURE = datetime(2999, 1, 1)
# ----- 견적 -----
async def test_quotation_hidden_across_company(client, auth_headers, other_company_id, db_engine):
"""검증: 회사A 견적을 A·B 유저가 각각 단건 조회.
기대결과: A는 success=True / B는 code=1500(없는 것처럼 막힘)."""
ha = await auth_headers("qA")
qt = await _seed_quotation(db_engine, await _user_id(db_engine, "qA"))
assert (await client.get(f"/v1/quotation/{qt}", headers=ha)).json()["result"]["success"] is True
hb = await auth_headers("qB", other_company_id)
assert (await client.get(f"/v1/quotation/{qt}", headers=hb)).json()["result"]["code"] == 1500
async def test_quotation_list_is_company_scoped(client, auth_headers, other_company_id, db_engine):
"""검증: 회사A만 견적을 가진 상태에서 A·B 유저가 목록 조회.
기대결과: A 목록 total≥1 / B 목록 total=0."""
ha = await auth_headers("qlA")
await _seed_quotation(db_engine, await _user_id(db_engine, "qlA"), number="Q-LIST-A")
assert (await client.get("/v1/quotation/list", headers=ha)).json()["total"] >= 1
hb = await auth_headers("qlB", other_company_id)
assert (await client.get("/v1/quotation/list", headers=hb)).json()["total"] == 0
async def test_quotation_subresources_hidden_across_company(client, auth_headers, other_company_id, db_engine):
"""검증: 회사A 견적의 하위자원(세션·상태·결과·카드)을 회사B 유저가 조회.
기대결과: 넷 다 code=1500 으로 막힘 (같은 견적을 A 는 정상 조회)."""
ha = await auth_headers("qsA")
qt = await _seed_quotation(db_engine, await _user_id(db_engine, "qsA"), number="Q-SUB")
hb = await auth_headers("qsB", other_company_id)
for path in (f"/v1/quotation/{qt}/sessions", f"/v1/quotation/{qt}/status",
f"/v1/quotation/{qt}/result", f"/v1/quotation/{qt}/cards"):
assert (await client.get(path, headers=hb)).json()["result"]["code"] == 1500, path
assert (await client.get(f"/v1/quotation/{qt}/status", headers=ha)).json()["result"]["success"] is True
# ----- 상품(item) -----
async def test_item_hidden_across_company(client, auth_headers, other_company_id):
"""검증: 회사A 상품을 회사B 유저가 목록·단건 조회.
기대결과: 목록 total=0, 단건 code=1300(ITEM_NOT_FOUND)."""
ha = await auth_headers("iA")
a_item = (await client.post("/v1/item/create", json={"name": "A상품"}, headers=ha)).json()["item"]["item_id"]
hb = await auth_headers("iB", other_company_id)
assert (await client.get("/v1/item/list", headers=hb)).json()["total"] == 0
assert (await client.get(f"/v1/item/{a_item}", headers=hb)).json()["result"]["code"] == 1300
# ----- 협력사(supplier) -----
async def test_supplier_hidden_across_company(client, auth_headers, other_company_id):
"""검증: 회사A 협력사를 회사B 유저가 목록·단건 조회.
기대결과: 목록 total=0, 단건 code=1400(SUPPLIER_NOT_FOUND)."""
ha = await auth_headers("sA")
a_sup = (await client.post("/v1/supplier/create", json={"name": "A협력사", "code": "SA"}, headers=ha)).json()["supplier"]["supplier_id"]
hb = await auth_headers("sB", other_company_id)
assert (await client.get("/v1/supplier/list", headers=hb)).json()["total"] == 0
assert (await client.get(f"/v1/supplier/{a_sup}", headers=hb)).json()["result"]["code"] == 1400
# ----- 대시보드 -----
async def test_dashboard_is_company_scoped(client, auth_headers, other_company_id, db_engine):
"""검증: 회사A만 진행중 견적을 보유. A·B 유저가 각각 대시보드 요약 조회.
기대결과: A 는 company.in_progress≥1 / B 는 0 (타사 견적이 내 회사 집계에 안 섞임)."""
ha = await auth_headers("dA")
await _seed_quotation(db_engine, await _user_id(db_engine, "dA"), number="Q-DASH")
assert (await client.get("/v1/dashboard/summary", headers=ha)).json()["company"]["in_progress"] >= 1
hb = await auth_headers("dB", other_company_id)
assert (await client.get("/v1/dashboard/summary", headers=hb)).json()["company"]["in_progress"] == 0
# ----- 견적세팅(회사 아님 — '유저' 스코프) -----
async def test_quotation_setting_is_user_scoped(client, auth_headers):
"""검증: 유저A 견적세팅을 '같은 회사 다른 유저' B 가 목록/수정 시도.
기대결과: B 목록엔 안 보이고(total=0), 수정은 code=1600(내 소유 아님) — 견적세팅은 유저 단위."""
ha = await auth_headers("stA")
a_setting = (await client.post(
"/v1/quotation-setting/create", json={"target_margin_rate": 0.15}, headers=ha
)).json()["setting"]["qt_setting_id"]
hb = await auth_headers("stB") # 같은 회사(company_id 기본), 다른 유저
assert (await client.get("/v1/quotation-setting/list", headers=hb)).json()["total"] == 0
r = await client.patch(f"/v1/quotation-setting/update/{a_setting}", json={"target_margin_rate": 0.2}, headers=hb)
assert r.json()["result"]["code"] == 1600
# ===== 헬퍼 (위 테스트들이 쓰는 도우미) =====
async def _user_id(engine, login_id):
"""auth_headers 로 시드된 유저의 user_id."""
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT user_id FROM users WHERE id = :id"), {"id": login_id}
)).scalar_one()
async def _seed_quotation(engine, user_id, *, number="Q-SCOPE"):
"""작성자=user_id 인 견적 1건 + 세션 1건 시드(진행중)."""
qt_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
" round, iteration, start_time, end_time, deleted) VALUES "
"(:qt_id, :uid, :setting, :version, '견적A', :number, :type, :status, 1, 0, :past, :future, false)"
),
{"qt_id": qt_id, "uid": user_id, "setting": uuid.uuid4(), "version": uuid.uuid4(),
"number": number, "type": QuotationType.REQUOTE.value,
"status": QuotationStatus.IN_PROGRESS.value, "past": PAST, "future": FUTURE},
)
await conn.execute(
text(
"INSERT INTO sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
" target_price, status, end_time) VALUES "
"(:sid, :qt, :item, :sup, :number, 1, :type, 0, :st, :future)"
),
{"sid": uuid.uuid4(), "qt": qt_id, "item": uuid.uuid4(), "sup": uuid.uuid4(),
"number": number, "type": QuotationType.REQUOTE.value,
"st": SessionStatus.CREATED.value, "future": FUTURE},
)
return qt_id

View File

@ -0,0 +1,88 @@
"""직원 계정 관리(/v1/company/user/*) 테스트 — '최고관리자만' 쓸 수 있고, '자기 회사'만 다뤄지는지 확인.
- 일반 직원 계정으로는 이 기능을 못 쓴다(HTTP 403 으로 막힘).
- 최고관리자는 자기 회사 직원만 목록에 보이고, 생성도 자기 회사로 된다(남의 회사 직원은 안 보임).
- 로그인 아이디는 전체에서 유일해야 해서, 같은 아이디로 또 만들면 거부된다(코드 1201).
"""
import uuid
from sqlalchemy import text
from common.enums import UserRole, UserStatus
async def test_regular_user_forbidden_on_owner_endpoints(client, auth_headers):
"""검증: 일반 USER 토큰으로 최고관리자 전용 엔드포인트(list·create) 호출.
기대결과: 둘 다 HTTP 403(RequireOwner 차단)."""
h = await auth_headers("plainuser") # role=USER 기본
r = await client.get("/v1/company/user/list", headers=h)
assert r.status_code == 403
r = await client.post(
"/v1/company/user/create", json={"id": "x", "password": "p", "name": "n"}, headers=h
)
assert r.status_code == 403
async def test_owner_lists_only_own_company_users(client, auth_headers, company_id, other_company_id, db_engine):
"""검증: 회사A OWNER + A직원 + B직원(타사)을 두고 OWNER 가 유저 목록 조회.
기대결과: 본인·A직원은 목록에 있고 타사(B) 직원은 없음(회사 스코프)."""
owner_h = await auth_headers("ownerA", role=UserRole.OWNER.value) # 회사 A owner
await _seed_user(db_engine, company_id, "empA") # 같은 회사 직원
await _seed_user(db_engine, other_company_id, "empB") # 다른 회사 직원
r = await client.get("/v1/company/user/list", headers=owner_h)
ids = {u["id"] for u in r.json()["users"]}
assert "ownerA" in ids # 본인
assert "empA" in ids # 자기 회사 직원
assert "empB" not in ids # 타사 직원은 안 보임
async def test_owner_creates_user_in_own_company(client, auth_headers):
"""검증: OWNER 가 직원 계정을 생성한 뒤 목록 조회.
기대결과: 생성 success=True, 생성한 유저가 자기 회사 목록에 노출."""
owner_h = await auth_headers("ownerC", role=UserRole.OWNER.value)
r = await client.post(
"/v1/company/user/create",
json={"id": "newemp", "password": "pw1234", "name": "직원"},
headers=owner_h,
)
assert r.json()["result"]["success"] is True
r = await client.get("/v1/company/user/list", headers=owner_h)
ids = {u["id"] for u in r.json()["users"]}
assert "newemp" in ids
async def test_duplicate_login_id_rejected(client, auth_headers):
"""검증: OWNER 가 같은 로그인 ID 로 직원 계정을 2번 생성.
기대결과: 1번째 success=True, 2번째 success=False, code=1201(ACCOUNT_ALREADY_EXIST)."""
owner_h = await auth_headers("ownerD", role=UserRole.OWNER.value)
r1 = await client.post(
"/v1/company/user/create", json={"id": "dup", "password": "pw1234", "name": "n"}, headers=owner_h
)
assert r1.json()["result"]["success"] is True
r2 = await client.post(
"/v1/company/user/create", json={"id": "dup", "password": "pw5678", "name": "n2"}, headers=owner_h
)
body = r2.json()
assert body["result"]["success"] is False
assert body["result"]["code"] == 1201
# ===== 헬퍼 (위 테스트들이 쓰는 도우미) =====
async def _seed_user(engine, company_id, login_id, *, role=UserRole.USER.value):
"""로그인 안 하는 소속 직원 시드(목록 스코프 확인용). 비번은 임의값."""
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO users (user_id, company_id, id, password, name, status, role, last_accessed_at) "
"VALUES (:uid, :cid, :id, 'x', 'n', :status, :role, now())"
),
{"uid": uuid.uuid4(), "cid": uuid.UUID(company_id), "id": login_id,
"status": UserStatus.ACTIVE.value, "role": role},
)

View File

@ -1,27 +1,22 @@
"""supplier / quotation_setting / quotation 슬라이스 런타임 스모크.
"""협력사·견적세팅·견적을 '만들고 → 목록/단건으로 다시 조회'하는 기본 동작 확인.
create(재조회로 created_at 적재) + list + get 경로를 라이브 DB 로 확인한다.
만든 뒤 다시 읽어와, 서버가 자동으로 채우는 값(생성시각 등)이 제대로 들어갔는지까지 본다. 로그인은 auth_headers.
"""
import uuid
async def _headers(client, company_id, login_id):
await client.post(
"/v1/auth/create",
json={"id": login_id, "password": "pw1234", "company_id": company_id, "name": "n"},
)
r = await client.post("/v1/auth/login", json={"id": login_id, "password": "pw1234"})
return {"Authorization": f"Bearer {r.json()['access_token']}"}
from common.enums import QuotationStatus, QuotationType
async def test_supplier_crud(client, company_id):
h = await _headers(client, company_id, "supuser")
async def test_supplier_crud(client, auth_headers):
"""검증: 협력사 생성 후 목록·단건 조회.
기대결과: 생성 success=True, 목록 total=1, 단건 supplier_id 일치, created_at 적재."""
h = await auth_headers("supuser")
r = await client.post("/v1/supplier/create", json={"name": "공급사A", "code": "S1"}, headers=h)
body = r.json()
assert body["result"]["success"] is True
sup = body["supplier"]
assert sup["name"] == "공급사A"
assert sup["created_at"] # 재조회 픽스: 서버 기본값 적재 확인
assert sup["created_at"] # 재조회로 서버 기본값 적재 확인
sid = sup["supplier_id"]
r = await client.get("/v1/supplier/list", headers=h)
@ -31,8 +26,10 @@ async def test_supplier_crud(client, company_id):
assert r.json()["supplier"]["supplier_id"] == sid
async def test_quotation_setting_crud(client, company_id):
h = await _headers(client, company_id, "qsuser")
async def test_quotation_setting_crud(client, auth_headers):
"""검증: 견적 세팅 생성(마진율 0.15) 후 목록 조회.
기대결과: success=True, target_margin_rate=0.15, card_count 기본 3, 목록 total≥1."""
h = await auth_headers("qsuser")
r = await client.post("/v1/quotation-setting/create", json={"target_margin_rate": 0.15}, headers=h)
body = r.json()
assert body["result"]["success"] is True
@ -45,24 +42,30 @@ async def test_quotation_setting_crud(client, company_id):
assert r.json()["total"] >= 1
async def test_quotation_create(client, company_id):
h = await _headers(client, company_id, "qtuser")
async def test_quotation_create(client, auth_headers):
"""검증: 견적 생성(number 는 서버 생성) 후 qt_id 로 재조회.
기대결과: 생성 success=True, 재조회 시 name 일치·created_at 적재, 목록 total≥1."""
h = await auth_headers("qtuser")
body = {
"qt_setting_id": str(uuid.uuid4()),
"version_id": str(uuid.uuid4()),
"name": "견적A",
"number": "Q-001",
"type": "재견적",
"status": "진행중",
"type": QuotationType.REQUOTE.value,
"status": QuotationStatus.IN_PROGRESS.value,
"start_time": "2026-06-16T00:00:00",
"end_time": "2026-06-17T00:00:00",
}
r = await client.post("/v1/quotation/create", json=body, headers=h)
res = r.json()
# 생성 응답엔 quotation 본문이 없고 qt_id/session_count 만 온다 → qt_id 로 재조회
assert res["result"]["success"] is True
q = res["quotation"]
qt_id = res["qt_id"]
assert qt_id
r = await client.get(f"/v1/quotation/{qt_id}", headers=h)
q = r.json()["quotation"]
assert q["name"] == "견적A"
assert q["created_at"] # 재조회 픽스
assert q["created_at"]
r = await client.get("/v1/quotation/list", headers=h)
assert r.json()["total"] >= 1

View File

@ -1,37 +1,12 @@
"""item 도메인 e2e — CRUD + company 멀티테넌트 스코프 검증.
실행 전제: PostgreSQL(negodata_db). docker compose up -d 후 python -m pytest.
"""
"""item 도메인 e2e — 상품 CRUD. 회사 스코프(타사 격리)는 test_company_scope.py. 로그인은 auth_headers."""
import uuid
import pytest_asyncio
from sqlalchemy import text
async def test_item_crud_flow(client, auth_headers):
"""검증: 상품 생성→목록→단건→부분수정→soft삭제 전체 흐름.
기대결과: 각 단계 success, 부분수정은 준 필드만 변경(나머지 유지), soft삭제 후 목록 total=0."""
h = await auth_headers("itemuser")
async def _headers(client, company_id, login_id="itemuser", pw="pw1234"):
await client.post(
"/v1/auth/create",
json={"id": login_id, "password": pw, "company_id": company_id, "name": "n"},
)
r = await client.post("/v1/auth/login", json={"id": login_id, "password": pw})
return {"Authorization": f"Bearer {r.json()['access_token']}"}
@pytest_asyncio.fixture
async def other_company_id(db_engine) -> str:
cid = uuid.uuid4()
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO companies (company_id, name) VALUES (:cid, :name)"),
{"cid": cid, "name": "다른회사"},
)
return str(cid)
async def test_item_crud_flow(client, company_id):
h = await _headers(client, company_id)
# 등록
r = await client.post("/v1/item/create", json={"name": "상품A", "price": 1000, "code": "C1"}, headers=h)
assert r.status_code == 200
body = r.json()
@ -39,47 +14,28 @@ async def test_item_crud_flow(client, company_id):
item_id = body["item"]["item_id"]
assert body["item"]["name"] == "상품A"
# 목록
r = await client.get("/v1/item/list", headers=h)
body = r.json()
assert body["total"] == 1 and len(body["items"]) == 1
# 단건 조회
r = await client.get(f"/v1/item/{item_id}", headers=h)
assert r.json()["item"]["item_id"] == item_id
# 수정 (부분)
# 부분 수정: 준 필드(price)만 바뀌고 안 준 필드(name)는 유지돼야 한다
r = await client.patch(f"/v1/item/update/{item_id}", json={"price": 2000}, headers=h)
assert r.json()["item"]["price"] == 2000
assert r.json()["item"]["name"] == "상품A" # 미지정 필드 유지
assert r.json()["item"]["name"] == "상품A"
# 삭제 (soft)
r = await client.delete(f"/v1/item/delete/{item_id}", headers=h)
assert r.json()["result"]["success"] is True
# 삭제 후 목록 0
r = await client.get("/v1/item/list", headers=h)
assert r.json()["total"] == 0
# soft delete → 행은 남지만 목록엔 안 잡힌다
assert (await client.delete(f"/v1/item/delete/{item_id}", headers=h)).json()["result"]["success"] is True
assert (await client.get("/v1/item/list", headers=h)).json()["total"] == 0
async def test_item_not_found(client, company_id):
h = await _headers(client, company_id)
async def test_item_not_found(client, auth_headers):
"""검증: 존재하지 않는 상품 단건 조회.
기대결과: success=False, code=1300(ITEM_NOT_FOUND)."""
h = await auth_headers("itemuser")
r = await client.get(f"/v1/item/{uuid.uuid4()}", headers=h)
body = r.json()
assert body["result"]["success"] is False
assert body["result"]["code"] == 1300 # ITEM_NOT_FOUND
async def test_item_company_scope(client, company_id, other_company_id):
# 회사 A 가 상품 등록
ha = await _headers(client, company_id, login_id="userA")
r = await client.post("/v1/item/create", json={"name": "A상품"}, headers=ha)
a_item_id = r.json()["item"]["item_id"]
# 회사 B 유저는 A 의 상품을 목록/단건에서 볼 수 없다
hb = await _headers(client, other_company_id, login_id="userB")
r = await client.get("/v1/item/list", headers=hb)
assert r.json()["total"] == 0
r = await client.get(f"/v1/item/{a_item_id}", headers=hb)
assert r.json()["result"]["code"] == 1300 # 타사 자원은 ITEM_NOT_FOUND
assert body["result"]["code"] == 1300

View File

@ -0,0 +1,99 @@
"""알림함 '읽는' 쪽 테스트 — 목록 조회, 안 읽은 개수, 읽음 처리(하나/전체), 그리고 남의 알림은 안 보이는지.
'마감하면 알림이 쌓이는지'(쓰는 쪽)는 test_quotation_close_notify 가 본다. 여기선 겹치지 않게 '읽는' 동작만 본다.
알림은 원래 견적 마감 때 생기지만, 여기선 테스트를 위해 알림 행을 DB 에 직접 넣는다.
"""
import json
import uuid
from sqlalchemy import text
from common.enums import NotificationType
async def test_list_and_unread(client, auth_headers, db_engine):
"""검증: 내 알림 2건을 시드하고 인박스 목록 조회.
기대결과: total=2, unread=2, 안읽음이라 read_at 없음(None)."""
h = await auth_headers("notilist")
uid = await _user_id(db_engine, "notilist")
await _seed_notification(db_engine, uid)
await _seed_notification(db_engine, uid, ntype=NotificationType.REGENERATED.value)
r = await client.get("/v1/notification/list", headers=h)
body = r.json()
assert body["result"]["success"] is True
assert body["total"] == 2
assert body["unread"] == 2
assert len(body["notifications"]) == 2
# 안읽음은 read_at=None → RemoveNoneResponse 가 키를 제거하므로 .get() 으로 확인
assert all(n.get("read_at") is None for n in body["notifications"])
async def test_inbox_is_user_scoped(client, auth_headers, db_engine):
"""검증: 내 알림 1건 + 남의 알림 1건을 시드하고 내 인박스 조회.
기대결과: total=1, unread=1 — 내 것만 보인다(남의 알림 제외)."""
h = await auth_headers("notiscope")
me = await _user_id(db_engine, "notiscope")
await _seed_notification(db_engine, me) # 내 알림
await _seed_notification(db_engine, uuid.uuid4()) # 남의 알림(안 보여야 함)
r = await client.get("/v1/notification/list", headers=h)
body = r.json()
assert body["total"] == 1 and body["unread"] == 1
async def test_read_all_clears_unread(client, auth_headers, db_engine):
"""검증: 안읽음 2건 상태에서 read-all 호출 후 다시 목록 조회.
기대결과: unread=0, 목록엔 그대로 남고(total=2) 모든 read_at 채워짐."""
h = await auth_headers("notireadall")
uid = await _user_id(db_engine, "notireadall")
await _seed_notification(db_engine, uid)
await _seed_notification(db_engine, uid)
r = await client.post("/v1/notification/read-all", headers=h)
assert r.json()["result"]["success"] is True
r = await client.get("/v1/notification/list", headers=h)
body = r.json()
assert body["total"] == 2 and body["unread"] == 0
assert all(n["read_at"] is not None for n in body["notifications"])
async def test_read_one_decrements_unread(client, auth_headers, db_engine):
"""검증: 안읽음 2건 중 1건만 읽음 처리.
기대결과: unread 2 → 1."""
h = await auth_headers("notireadone")
uid = await _user_id(db_engine, "notireadone")
await _seed_notification(db_engine, uid)
await _seed_notification(db_engine, uid)
r = await client.get("/v1/notification/list", headers=h)
target_id = r.json()["notifications"][0]["notification_id"]
r = await client.post(f"/v1/notification/{target_id}/read", headers=h)
assert r.json()["result"]["success"] is True
r = await client.get("/v1/notification/list", headers=h)
assert r.json()["unread"] == 1
# ===== 헬퍼 (위 테스트들이 쓰는 도우미) =====
async def _user_id(engine, login_id):
"""auth_headers 로 시드된 유저의 user_id(알림 시드/스코프 확인용)."""
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT user_id FROM users WHERE id = :id"), {"id": login_id}
)).scalar_one()
async def _seed_notification(engine, user_id, *, ntype=NotificationType.SUCCESS.value, data=None):
"""안읽음(read_at NULL) 알림 1건 시드."""
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO notifications (notification_id, user_id, type, data, read_at) "
"VALUES (:nid, :uid, :type, CAST(:data AS JSONB), NULL)"
),
{"nid": uuid.uuid4(), "uid": user_id, "type": ntype,
"data": json.dumps(data or {"qt_name": "견적A"})},
)

View File

@ -0,0 +1,224 @@
"""견적 마감(close_and_decide) 테스트 — 마감하면 상황별로 결과가 맞게 판정되고, 그 결과가 작성자에게 알림으로 남는지 확인.
핵심은 '재견적(다음 라운드 재생성)이 나오는 경우 vs 안 나오는 경우'의 구분이다.
각 경우에 (1) 판정이 맞고 (2) 작성자 알림함에 알맞은 알림 1건이 남는지 본다:
· 단독 최저가 → 낙찰 (SUCCESS) [재견적 X]
· 협상 거부 → 결렬 (FAILURE, reason=rejected) [재견적 X]
· 동가/미참여 + 한도 남음 → 재생성 (REGENERATED) [재견적 O]
· 동가/미참여 + 한도 소진 → 결렬 (FAILURE, reason=closed) [재견적 X]
재생성 한도: 사유(동가·미참여)별로 한 체인(같은 견적번호)에서 각 1번까지만.
공급사의 협상 결과(협상완료/거부/입찰가)는 협상 화면에서만 생기는 값이라 API 로 못 만든다 → SQL 로 직접 넣는다.
마감 판정 로직 자체를 더 깊게 파는 건 test_scheduler·test_close_and_decide_fixes.
"""
import uuid
from datetime import datetime
import pytest_asyncio
from sqlalchemy import text
from common.enums import CloseOutcome, NotificationType, QuotationStatus, QuotationType, SessionStatus
from crud.quotation_crud import QuotationCRUD
from services.quotation_service import QuotationService
PAST = datetime(2020, 1, 1)
@pytest_asyncio.fixture
async def clean(db_engine):
"""conftest 는 notifications 를 비우지 않는다 → 알림 단언이 다른 테스트에 안 흔들리게 여기서 함께 비운다."""
async with db_engine.begin() as conn:
await conn.execute(text("TRUNCATE TABLE sessions, quotations, notifications RESTART IDENTITY CASCADE"))
return db_engine
# ----- 재견적 X (낙찰·거부) -----
async def test_award_notifies_success(clean):
"""검증: 협상완료 세션 2건(입찰 100·200) — 단독 최저가로 마감.
기대결과: 재견적 X, 판정 = 낙찰(AWARDED) + 알림 SUCCESS(winner_price=100=최저가, ref_qt_id=그 견적)."""
engine = clean
user_id = uuid.uuid4()
winner = uuid.uuid4()
qt = await _seed_quotation(engine, user_id=user_id, number="N-AWARD")
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=winner)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=200)
outcome = await _service().close_and_decide(qt)
assert outcome == CloseOutcome.AWARDED
notis = await _notifications(engine, user_id)
assert len(notis) == 1
type_, data, ref = notis[0]
assert type_ == NotificationType.SUCCESS.value
assert data["winner_price"] == 100
assert str(ref) == str(qt)
async def test_rejected_notifies_failure(clean):
"""검증: 협상거부 세션만 있는 상태로 마감.
기대결과: 재견적 X, 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=rejected)."""
engine = clean
user_id = uuid.uuid4()
qt = await _seed_quotation(engine, user_id=user_id, number="N-REJECT")
await _add_session(engine, qt, status=SessionStatus.REJECTED.value)
outcome = await _service().close_and_decide(qt)
assert outcome == CloseOutcome.CLOSED
notis = await _notifications(engine, user_id)
assert len(notis) == 1
type_, data, ref = notis[0]
assert type_ == NotificationType.FAILURE.value
assert data["reason"] == "rejected"
assert str(ref) == str(qt)
# ----- 재견적 O (동가·미참여, 한도 남음) -----
async def test_equal_bid_regenerates(clean):
"""검증: 협상완료 세션 2건이 '동가'(둘 다 100), 체인에 동가 재생성 이력 없음(한도 남음).
기대결과: 재견적 O, 판정 = 재생성(REGENERATED) + 알림 REGENERATED(reason=equal, tied_price=100, next_round=2)."""
engine = clean
user_id = uuid.uuid4()
qt = await _seed_quotation(engine, user_id=user_id, number="N-EQUAL")
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
outcome = await _service().close_and_decide(qt)
assert outcome == CloseOutcome.REGENERATED
notis = await _notifications(engine, user_id)
assert len(notis) == 1
type_, data, _ = notis[0]
assert type_ == NotificationType.REGENERATED.value
assert data["reason"] == "equal"
assert data["tied_price"] == 100
assert data["next_round"] == 2
async def test_no_show_regenerates(clean):
"""검증: 전원 미참여(미시작 세션만), 체인에 미참여 재생성 이력 없음(한도 남음).
기대결과: 재견적 O, 판정 = 재생성(REGENERATED) + 알림 REGENERATED(reason=no_show, next_round=2)."""
engine = clean
user_id = uuid.uuid4()
qt = await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW")
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
await _add_session(engine, qt, status=SessionStatus.CREATED.value)
outcome = await _service().close_and_decide(qt)
assert outcome == CloseOutcome.REGENERATED
notis = await _notifications(engine, user_id)
assert len(notis) == 1
type_, data, _ = notis[0]
assert type_ == NotificationType.REGENERATED.value
assert data["reason"] == "no_show"
assert data["next_round"] == 2
# ----- 재견적 X (동가·미참여지만 한도 소진 → 결렬) -----
async def test_equal_bid_limit_exhausted_fails(clean):
"""검증: 1차가 이미 '동가'로 재생성된 체인(동가 한도 1 소진)에서, 2차도 또 동가로 마감.
기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed)."""
engine = clean
user_id = uuid.uuid4()
# 1차: 동가로 마감돼 2차를 만든 상황(equal_bid_yn=True 가 동가 재생성 표식) → 동가 한도 소진
await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=1,
status=QuotationStatus.CLOSED.value, equal_bid_yn=True)
# 2차: 또 동가
qt2 = await _seed_quotation(engine, user_id=user_id, number="N-EQUAL-LIMIT", round_=2)
await _add_session(engine, qt2, status=SessionStatus.DONE.value, bid_price=100)
await _add_session(engine, qt2, status=SessionStatus.DONE.value, bid_price=100)
outcome = await _service().close_and_decide(qt2)
assert outcome == CloseOutcome.CLOSED # 동가 한도 소진 → 재생성 없이 결렬
notis = await _notifications(engine, user_id)
assert len(notis) == 1
type_, data, ref = notis[0]
assert type_ == NotificationType.FAILURE.value
assert data["reason"] == "closed"
assert str(ref) == str(qt2)
async def test_no_show_limit_exhausted_fails(clean):
"""검증: 1차가 이미 '미참여'로 재생성된 체인(미참여 한도 1 소진)에서, 2차도 또 전원 미참여로 마감.
기대결과: 재견적 X — 판정 = 결렬(CLOSED) + 알림 FAILURE(reason=closed)."""
engine = clean
user_id = uuid.uuid4()
# 1차: 미참여로 마감돼 2차를 만든 상황(preferred_sp_yn=False·equal_bid_yn=False 가 미참여 재생성 표식) → 미참여 한도 소진
await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=1,
status=QuotationStatus.CLOSED.value, preferred_sp_yn=False, equal_bid_yn=False)
# 2차: 또 전원 미참여
qt2 = await _seed_quotation(engine, user_id=user_id, number="N-NOSHOW-LIMIT", round_=2)
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
await _add_session(engine, qt2, status=SessionStatus.CREATED.value)
outcome = await _service().close_and_decide(qt2)
assert outcome == CloseOutcome.CLOSED # 미참여 한도 소진 → 재생성 없이 결렬
notis = await _notifications(engine, user_id)
assert len(notis) == 1
type_, data, ref = notis[0]
assert type_ == NotificationType.FAILURE.value
assert data["reason"] == "closed"
assert str(ref) == str(qt2)
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. 세션 입찰값·이전 라운드 표식을 SQL 로 직접 세팅) =====
async def _seed_quotation(
engine, *, user_id, number, round_=1, status=QuotationStatus.IN_PROGRESS.value,
preferred_sp_yn=None, equal_bid_yn=None,
):
"""견적 1건 시드(작성자=user_id). preferred_sp_yn·equal_bid_yn 으로 '이전 라운드가 어떤 사유로 재생성됐는지'를 표식한다
(동가 재생성=equal_bid_yn True / 미참여 재생성=preferred_sp_yn False AND equal_bid_yn False)."""
qt_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO quotations "
"(qt_id, user_id, qt_setting_id, version_id, name, number, type, status, "
" round, iteration, start_time, end_time, deleted, preferred_sp_yn, equal_bid_yn) VALUES "
"(:qt_id, :user_id, :qt_setting_id, :version_id, '견적A', :number, :type, :status, "
" :round, 0, :start_time, :end_time, false, :pref, :eq)"
),
{
"qt_id": qt_id, "user_id": user_id, "qt_setting_id": uuid.uuid4(),
"version_id": uuid.uuid4(), "number": number,
"type": QuotationType.REQUOTE.value, "status": status, "round": round_,
"start_time": PAST, "end_time": PAST,
"pref": preferred_sp_yn, "eq": equal_bid_yn,
},
)
return qt_id
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None):
"""세션 1건 시드(공급사 협상 1건). status/bid_price 로 협상완료·거부·입찰가를 만든다."""
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO sessions "
"(session_id, quotation_id, item_id, supplier_id, qt_number, qt_round, qt_type, "
" target_price, status, bid_price, end_time) VALUES "
"(:session_id, :quotation_id, :item_id, :supplier_id, 'Q', 1, :qt_type, "
" 0, :status, :bid_price, :end_time)"
),
{
"session_id": uuid.uuid4(), "quotation_id": qt_id, "item_id": uuid.uuid4(),
"supplier_id": supplier_id or uuid.uuid4(), "qt_type": QuotationType.REQUOTE.value,
"status": status, "bid_price": bid_price, "end_time": PAST,
},
)
async def _notifications(engine, user_id):
"""user_id(작성자) 인박스 알림 (type, data, ref_qt_id) — 생성순."""
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT type, data, ref_qt_id FROM notifications WHERE user_id = :uid ORDER BY created_at"),
{"uid": user_id},
)).all()
def _service():
return QuotationService(QuotationCRUD())

View File

@ -0,0 +1,98 @@
"""견적 생성 — item×supplier 조합마다 세션이 생기고, 목표가가 산정되는지 검증.
기존 test_features.test_quotation_create 는 item/supplier 없이 '세션 0건' 경로만 본다.
여기선 상품(인터넷최저가)을 시드해 세션 생성 + 목표가 계산(신규=인터넷최저가×(1−수수료))까지 본다.
서비스(create_quotation)를 직접 호출한다 — HTTP/auth 경로(현재 /v1/auth/create 미존재)를 안 타고 생성 로직만 격리.
"""
import uuid
from datetime import datetime
from sqlalchemy import text
from common.enums import QuotationType
from crud.quotation_crud import QuotationCRUD
from router.v1.quotation.protocol import Req_CreateQuotation
from services.quotation_service import QuotationService
FUTURE = datetime(2999, 1, 1) # 마감시각 미래 — 생성 직후 크론에 안 잡히게
async def test_create_builds_sessions_with_target_price(db_engine, company_id):
"""검증: 신규견적을 상품2×공급사2로 생성.
기대결과: success=True, 세션 4개, 각 목표가 = int(인터넷최저가 × (1−0.078))."""
item1 = await _seed_item(db_engine, company_id, internet_lowest=100_000)
item2 = await _seed_item(db_engine, company_id, internet_lowest=50_000)
suppliers = [uuid.uuid4(), uuid.uuid4()]
req = Req_CreateQuotation(
qt_setting_id=uuid.uuid4(), # FK 미설정 — 세팅 없으면 율 0(신규는 인터넷최저가만 쓰므로 무관)
name="신규견적A",
type=QuotationType.NEW_QUOTE.value,
end_time=FUTURE,
item_ids=[item1, item2],
supplier_ids=suppliers,
)
res = await _service().create_quotation(str(uuid.uuid4()), req)
assert res.result.success is True
assert res.session_count == 4 # 상품 2 × 공급사 2
fee = QuotationService.INTERNET_AVERAGE_FEE
expected = {item1: int(100_000 * (1 - fee)), item2: int(50_000 * (1 - fee))}
rows = await _session_target_prices(db_engine, res.qt_id)
assert len(rows) == 4
for item_id, target_price in rows:
assert target_price == expected[item_id] # 상품별 목표가가 공급사 수만큼 동일
async def test_create_without_price_fails(db_engine, company_id):
"""검증: 가격 후보(인터넷최저가·md 등)가 전무한 상품으로 견적 생성.
기대결과: 목표가 산정 불가로 success=False, 세션 0건(미생성)."""
item = await _seed_item(db_engine, company_id, internet_lowest=None)
req = Req_CreateQuotation(
qt_setting_id=uuid.uuid4(),
name="가격없음",
type=QuotationType.NEW_QUOTE.value,
end_time=FUTURE,
item_ids=[item],
supplier_ids=[uuid.uuid4()],
)
res = await _service().create_quotation(str(uuid.uuid4()), req)
assert res.result.success is False # QUOTATION_TARGET_PRICE_UNAVAILABLE
rows = await _session_target_prices(db_engine, res.qt_id) if res.qt_id else []
assert rows == []
# ===== 헬퍼 (위 테스트들이 쓰는 도우미) =====
def _service():
return QuotationService(QuotationCRUD())
async def _seed_item(engine, company_id, *, internet_lowest):
"""상품 1건 시드(인터넷최저가만). category_type·internet_lowest_price_yn 은 NOT NULL —
ORM default 는 raw INSERT 에 안 먹으므로 명시한다(conftest companies.status 와 같은 이유)."""
item_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO items "
"(item_id, company_id, user_id, name, category_type, "
" internet_lowest_price_yn, internet_lowest_price) VALUES "
"(:item_id, :company_id, :user_id, '상품', 1, false, :ilp)"
),
{"item_id": item_id, "company_id": uuid.UUID(company_id),
"user_id": uuid.uuid4(), "ilp": internet_lowest},
)
return item_id
async def _session_target_prices(engine, qt_id):
"""생성된 견적의 (item_id -> target_price) 매핑."""
async with engine.begin() as conn:
rows = (await conn.execute(
text("SELECT item_id, target_price FROM sessions WHERE quotation_id = :qt"),
{"qt": qt_id},
)).all()
return rows

View File

@ -1,8 +1,15 @@
"""scheduler 잡 e2e — '대상 선정'(어떤 견적을 고르나) + close_and_decide 위임 결과 검증.
"""scheduler(마감 크론 잡) e2e 테스트 — 어떤 견적을 고르고, 마감하면 결과가 어떻게 나오는지 확인.
실행 전제: PostgreSQL(negodata_db). docker compose up -d 후 python -m pytest tests/test_scheduler.py.
잡은 HTTP 엔드포인트가 없어 scheduler.jobs 함수를 직접 호출한다(앱과 같은 DB_SESSION_MNG 사용 → mock 불필요).
세션 상태(DONE/REJECTED/bid_price 등)는 협상 프론트가 만드는 값이라 API 로 못 만든다 → SQL 로 직접 시드.
용어: 견적 = 한 건의 입찰 공고 / 세션 = 그 견적에 참여한 공급사별 협상 1건 / 마감 = 견적을 닫고 낙찰자를 정함.
마감을 자동으로 돌리는 크론 잡이 2개 있다(scheduler/jobs.py):
· 잡① close_expired_quotations : 마감시각(end_time)이 지났는데 아직 안 닫힌 견적을 닫는다.
· 잡② close_negotiated_quotations : 참여 세션이 전부 끝난(협상 종료) 견적을 닫는다.
두 잡 모두, 고른 견적마다 close_and_decide() 를 불러 결과(낙찰 / 다음 라운드 재생성 / 그냥 마감)를 정한다.
이 파일은 그 두 잡이 (1) 마감할 견적을 올바로 고르는지, (2) 마감 결과가 맞는지 확인한다.
잡에는 HTTP 엔드포인트가 없어 scheduler.jobs 함수를 직접 부른다(앱과 같은 DB 연결을 써서 mock 불필요).
세션 상태(협상완료/거부/입찰가 등)는 협상 화면에서만 생기는 값이라 API 로 못 만든다 → SQL 로 직접 넣는다.
"""
import asyncio
import uuid
@ -16,21 +23,144 @@ from sqlalchemy import text
from common.enums import QuotationStatus, QuotationType, SessionStatus
from scheduler import jobs
PAST = datetime(2020, 1, 1) # 마감시각 지남(잡① 대상)
FUTURE = datetime(2999, 1, 1) # 마감시각 미래(잡① 제외)
PAST = datetime(2020, 1, 1) # 마감시각이 이미 지난 시점(잡①의 마감 대상)
FUTURE = datetime(2999, 1, 1) # 마감시각이 아직 안 온 시점(잡①에서 제외)
@pytest_asyncio.fixture
async def clean(db_engine):
"""conftest 의 db_engine 은 quotations 만 비우고 sessions 는 안 비운다(FK 미설정 → CASCADE 대상 아님).
잡②(close_negotiated)는 전체 견적을 스캔하므로 다른 테스트가 남긴 세션이 결과를 흔든다 → sessions 도 비워 격리."""
"""각 테스트 시작 전에 quotations·sessions 를 모두 비워 깨끗한 상태로 만든다.
공용 db_engine 픽스처는 quotations 만 비운다. 그런데 잡②는 '세션이 다 끝난 견적'을 전체 견적에서 찾으므로,
앞선 다른 테스트가 남긴 세션이 남아 있으면 엉뚱한 견적이 대상에 끼어든다 → 그래서 여기서 sessions 까지 비운다.
"""
async with db_engine.begin() as conn:
await conn.execute(text("TRUNCATE TABLE sessions, quotations RESTART IDENTITY CASCADE"))
return db_engine
# ----- 시드 헬퍼 (FK 미설정이라 user/item/supplier 없이 임의 uuid 로 충분) -----
async def _add_quotation(engine, *, status=QuotationStatus.ACTIVE.value, end_time=PAST, deleted=False):
async def test_close_expired_picks_only_due_and_open(clean):
"""검증: 잡①을 돌린다. 견적 4개를 섞어둔다 —
① 마감시각 지난 미마감 ② 마감시각 안 지난 것 ③ 이미 마감된 것 ④ 삭제된 것.
기대결과: ①(due) 1건만 새로 마감(CLOSED)되고, ②③④ 는 그대로 둔다."""
engine = clean
due = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST) # 마감시각 지남 + 미마감 → 마감 대상
future = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE) # 마감시각 안 지남 → 제외
already = await _add_quotation(engine, status=QuotationStatus.CLOSED.value, end_time=PAST) # 이미 마감 → 제외
deleted = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST, deleted=True) # 삭제됨 → 제외
n = await jobs.close_expired_quotations()
assert n == 1 # 새로 마감된 건 due 1건뿐
assert (await _quotation_row(engine, due)).status == QuotationStatus.CLOSED.value
assert (await _quotation_row(engine, future)).status == QuotationStatus.IN_PROGRESS.value # 마감시각 전이라 그대로
assert (await _quotation_row(engine, already)).status == QuotationStatus.CLOSED.value # 원래부터 마감
assert (await _quotation_row(engine, deleted)).status == QuotationStatus.IN_PROGRESS.value # 삭제분은 건드리지 않음
async def test_close_negotiated_picks_when_all_sessions_ended(clean):
"""검증: 잡②를 돌린다. 견적 3개를 섞어둔다 —
① 세션이 전부 끝난 것 ② 아직 진행중인 세션이 있는 것 ③ 세션이 아예 없는 것.
기대결과: ①(세션 다 끝남)만 마감(CLOSED)되고, ②③ 은 제외."""
engine = clean
# ① 세션이 전부 끝남(거부로 종료) → 마감 대상
ended = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE)
await _add_session(engine, ended, status=SessionStatus.REJECTED.value)
# ② 아직 진행중인 세션이 하나라도 있음 → 제외
pending = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE)
await _add_session(engine, pending, status=SessionStatus.DONE.value, bid_price=100)
await _add_session(engine, pending, status=SessionStatus.IN_PROGRESS.value)
# ③ 세션이 아예 없음 → 제외(끝났다고 볼 세션 자체가 없음)
no_session = await _add_quotation(engine, status=QuotationStatus.IN_PROGRESS.value, end_time=FUTURE)
await jobs.close_negotiated_quotations()
assert (await _quotation_row(engine, ended)).status == QuotationStatus.CLOSED.value
assert (await _quotation_row(engine, pending)).status == QuotationStatus.IN_PROGRESS.value
assert (await _quotation_row(engine, no_session)).status == QuotationStatus.IN_PROGRESS.value
async def test_award_single_lowest(clean):
"""검증: 두 공급사가 각각 100·200 으로 협상완료(DONE)한, 마감시각 지난 견적을 잡①로 마감.
기대결과: 마감(CLOSED)되고, 더 싼 100 공급사가 단독 낙찰(낙찰 있음 + 낙찰자=그 공급사)."""
engine = clean
qt = await _add_quotation(engine, end_time=PAST)
winner = uuid.uuid4()
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=winner) # 더 싼 쪽
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=200)
await jobs.close_expired_quotations()
row = await _quotation_row(engine, qt)
assert row.status == QuotationStatus.CLOSED.value
assert row.preferred_sp_yn is True # 낙찰자 있음
assert str(row.preferred_sp_id) == str(winner) # 최저가가 단독이라 그 공급사로 확정
async def test_rejected_just_closes(clean):
"""검증: 입찰 없이 '거부'만 있는, 마감시각 지난 견적을 잡①로 마감.
기대결과: 마감(CLOSED)되지만 낙찰자는 없음(살 사람이 없으니 그냥 닫힘)."""
engine = clean
qt = await _add_quotation(engine, end_time=PAST)
await _add_session(engine, qt, status=SessionStatus.REJECTED.value) # 입찰가 없이 거부만
await jobs.close_expired_quotations()
row = await _quotation_row(engine, qt)
assert row.status == QuotationStatus.CLOSED.value
assert not row.preferred_sp_yn # 거부뿐이라 낙찰 없이 마감
async def test_scheduler_disabled_without_env(monkeypatch):
"""검증: SCHEDULER_ENABLED 환경변수 없이 start_scheduler() 호출.
기대결과: 스케줄러가 켜지지 않는다(운영에서 실수로 자동 마감이 도는 걸 막는 안전장치)."""
import scheduler
monkeypatch.delenv("SCHEDULER_ENABLED", raising=False)
scheduler._scheduler = None
scheduler.start_scheduler()
assert scheduler._scheduler is None # 환경변수가 1이 아니면 미기동
async def test_scheduler_registers_both_jobs(monkeypatch):
"""검증: SCHEDULER_ENABLED=1 로 start_scheduler() 호출.
기대결과: 마감 잡 2개(close_expired·close_negotiated)가 스케줄에 등록된다."""
import scheduler
monkeypatch.setenv("SCHEDULER_ENABLED", "1")
scheduler._scheduler = None
scheduler.start_scheduler()
try:
ids = {j.id for j in scheduler._scheduler.get_jobs()}
assert ids == {"close_expired_quotations", "close_negotiated_quotations"}
finally:
scheduler.shutdown_scheduler()
assert scheduler._scheduler is None
async def test_scheduler_actually_runs_job_and_closes(clean):
"""검증: 스케줄러에 잡을 걸어 실제로 발화시킨다(1초 간격으로).
기대결과: 스케줄러가 잡을 호출해 마감시각 지난 견적이 몇 초 안에 마감(CLOSED)된다 — '스케줄러→잡→마감' 경로 확인."""
engine = clean
qt = await _add_quotation(engine, end_time=PAST)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
sched = AsyncIOScheduler(timezone="Asia/Seoul")
sched.add_job(jobs.close_expired_quotations, IntervalTrigger(seconds=1), max_instances=1)
sched.start()
try:
row = None
for _ in range(25): # 잡은 1초 뒤 첫 발화 → 최대 ~5초 동안 0.2초 간격으로 확인
await asyncio.sleep(0.2)
row = await _quotation_row(engine, qt)
if row.status == QuotationStatus.CLOSED.value:
break
assert row is not None and row.status == QuotationStatus.CLOSED.value # 스케줄러가 잡을 호출해 마감됨
finally:
sched.shutdown(wait=False)
# ===== 헬퍼 (위 테스트들이 쓰는 도우미. FK 미설정이라 user/item/supplier 없이 임의 uuid 로 충분) =====
async def _add_quotation(engine, *, status=QuotationStatus.IN_PROGRESS.value, end_time=PAST, deleted=False):
"""견적 1건을 DB 에 직접 넣는다(시드). status/end_time/deleted 로 '대상/제외' 상황을 만든다."""
qt_id = uuid.uuid4()
async with engine.begin() as conn:
await conn.execute(
@ -52,6 +182,7 @@ async def _add_quotation(engine, *, status=QuotationStatus.ACTIVE.value, end_tim
async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=None):
"""세션(공급사 협상 1건)을 DB 에 직접 넣는다. status/bid_price 로 협상완료·거부·입찰가를 만든다."""
async with engine.begin() as conn:
await conn.execute(
text(
@ -71,119 +202,9 @@ async def _add_session(engine, qt_id, *, status, bid_price=None, supplier_id=Non
async def _quotation_row(engine, qt_id):
"""견적 1건을 다시 읽어온다(마감 후 status·낙찰자 확인용)."""
async with engine.begin() as conn:
return (await conn.execute(
text("SELECT status, preferred_sp_yn, preferred_sp_id FROM quotations WHERE qt_id = :id"),
{"id": qt_id},
)).first()
# ----- 잡① close_expired_quotations : 대상 선정(마감시각 지난 미마감만) -----
async def test_close_expired_picks_only_due_and_open(clean):
engine = clean
due = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=PAST)
future = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=FUTURE)
already = await _add_quotation(engine, status=QuotationStatus.CLOSED.value, end_time=PAST)
deleted = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=PAST, deleted=True)
n = await jobs.close_expired_quotations()
assert n == 1 # 마감 대상은 due 1건뿐
assert (await _quotation_row(engine, due)).status == QuotationStatus.CLOSED.value
assert (await _quotation_row(engine, future)).status == QuotationStatus.ACTIVE.value # 미래 → 안 건드림
assert (await _quotation_row(engine, already)).status == QuotationStatus.CLOSED.value # 원래부터 CLOSED
assert (await _quotation_row(engine, deleted)).status == QuotationStatus.ACTIVE.value # 삭제분 → 제외
# ----- 잡② close_negotiated_quotations : 대상 선정(전 세션 종결 + 세션 1개+) -----
async def test_close_negotiated_picks_when_all_sessions_ended(clean):
engine = clean
# 전 세션 종결(거부) → 대상
ended = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=FUTURE)
await _add_session(engine, ended, status=SessionStatus.REJECTED.value)
# 진행중 세션 하나라도 있으면 → 제외
pending = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=FUTURE)
await _add_session(engine, pending, status=SessionStatus.DONE.value, bid_price=100)
await _add_session(engine, pending, status=SessionStatus.IN_PROGRESS.value)
# 세션 0개 → 제외
no_session = await _add_quotation(engine, status=QuotationStatus.ACTIVE.value, end_time=FUTURE)
await jobs.close_negotiated_quotations()
assert (await _quotation_row(engine, ended)).status == QuotationStatus.CLOSED.value
assert (await _quotation_row(engine, pending)).status == QuotationStatus.ACTIVE.value
assert (await _quotation_row(engine, no_session)).status == QuotationStatus.ACTIVE.value
# ----- close_and_decide 위임 결과 스모크(잡①을 통해) -----
async def test_award_single_lowest(clean):
engine = clean
qt = await _add_quotation(engine, end_time=PAST)
winner = uuid.uuid4()
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100, supplier_id=winner)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=200)
await jobs.close_expired_quotations()
row = await _quotation_row(engine, qt)
assert row.status == QuotationStatus.CLOSED.value
assert row.preferred_sp_yn is True
assert str(row.preferred_sp_id) == str(winner) # 최저가 단독 → 낙찰 확정
async def test_rejected_just_closes(clean):
engine = clean
qt = await _add_quotation(engine, end_time=PAST)
await _add_session(engine, qt, status=SessionStatus.REJECTED.value) # 입찰 없는 거부만
await jobs.close_expired_quotations()
row = await _quotation_row(engine, qt)
assert row.status == QuotationStatus.CLOSED.value
assert not row.preferred_sp_yn # 거부 → 낙찰 없이 그냥 마감
# ----- 스케줄러 와이어링(start_scheduler) : DB 불필요 -----
async def test_scheduler_disabled_without_env(monkeypatch):
import scheduler
monkeypatch.delenv("SCHEDULER_ENABLED", raising=False)
scheduler._scheduler = None
scheduler.start_scheduler()
assert scheduler._scheduler is None # SCHEDULER_ENABLED != 1 → 미기동
async def test_scheduler_registers_both_jobs(monkeypatch):
import scheduler
monkeypatch.setenv("SCHEDULER_ENABLED", "1")
scheduler._scheduler = None
scheduler.start_scheduler()
try:
ids = {j.id for j in scheduler._scheduler.get_jobs()}
assert ids == {"close_expired_quotations", "close_negotiated_quotations"}
finally:
scheduler.shutdown_scheduler()
assert scheduler._scheduler is None
# ----- 스케줄러가 실제로 잡을 호출해 마감까지 가는지(라이브) -----
async def test_scheduler_actually_runs_job_and_closes(clean):
"""스케줄러에 잡을 걸면 정말 호출돼 견적이 마감되는지 확인.
운영 트리거는 CronTrigger(minute='*/5')라 분 경계까지 기다려야 하므로, 여기선
1초 IntervalTrigger 로 같은 잡을 걸어 '스케줄러 → 잡 호출 → 마감' 경로만 몇 초 안에 검증한다."""
engine = clean
qt = await _add_quotation(engine, end_time=PAST)
await _add_session(engine, qt, status=SessionStatus.DONE.value, bid_price=100)
sched = AsyncIOScheduler(timezone="Asia/Seoul")
sched.add_job(jobs.close_expired_quotations, IntervalTrigger(seconds=1), max_instances=1)
sched.start()
try:
row = None
for _ in range(25): # 최대 ~5초 폴링(잡은 1초 뒤 첫 발화)
await asyncio.sleep(0.2)
row = await _quotation_row(engine, qt)
if row.status == QuotationStatus.CLOSED.value:
break
assert row is not None and row.status == QuotationStatus.CLOSED.value # 크론이 잡을 호출해 마감
finally:
sched.shutdown(wait=False)

View File

@ -21,8 +21,9 @@ negosium/negodata 협상 플랫폼의 웹 프론트엔드.
docker compose up -d --build
```
- 프론트: **http://localhost:3001** (compose 가 컨테이너 `:3000` → 호스트 `:3001` 로 매핑)
- 프론트: **http://localhost:3000** (compose 서비스 `negodata-front`, `3000:3000` 매핑)
- 소스를 바인드마운트하므로 코드 수정은 HMR 로 자동 반영된다.
- (참고: 공급사용 `negosium-front` 는 별개로 `:3300`)
### 프론트만 단독 개발 (선택)
@ -80,7 +81,7 @@ src/
api/
generated/ # orval 자동생성 (직접 수정 금지)
mutator/ # custom-fetch (요청 공통 로직: baseURL·토큰·에러)
features/ # 도메인별: auth, products(상품), partners(협력사), quotations(견적), cards
features/ # 도메인별: auth, quotations(견적), products(상품), partners(협력사), cards(협상카드), dashboard, members(회원관리), onboarding
components/ # ui(shadcn), layout
pages/ # 화면
stores/ # zustand 스토어

View File

@ -25,9 +25,8 @@ import type {
import type {
HTTPValidationError,
ReqCreateAccount,
ReqLogin,
ResCreateAccount,
ReqUpdateMe,
ResLogin,
ResMe,
ResRefreshToken
@ -106,71 +105,6 @@ export const useLogin = <TError = void | HTTPValidationError,
return useMutation(mutationOptions, queryClient);
}
/**
* 새 계정을 생성한다.
* @summary 계정 생성
*/
export const createAccount = (
reqCreateAccount: ReqCreateAccount,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResCreateAccount>(
{url: `/v1/auth/create`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: reqCreateAccount, signal
},
options);
}
export const getCreateAccountMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createAccount>>, TError,{data: ReqCreateAccount}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof createAccount>>, TError,{data: ReqCreateAccount}, TContext> => {
const mutationKey = ['createAccount'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof createAccount>>, {data: ReqCreateAccount}> = (props) => {
const {data} = props ?? {};
return createAccount(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type CreateAccountMutationResult = NonNullable<Awaited<ReturnType<typeof createAccount>>>
export type CreateAccountMutationBody = ReqCreateAccount
export type CreateAccountMutationError = void | HTTPValidationError
/**
* @summary 계정 생성
*/
export const useCreateAccount = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createAccount>>, TError,{data: ReqCreateAccount}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof createAccount>>,
TError,
{data: ReqCreateAccount},
TContext
> => {
const mutationOptions = getCreateAccountMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* refresh 토큰으로 access 토큰을 재발급한다.
* @summary 액세스 토큰 갱신
*/
@ -326,3 +260,68 @@ export function useMe<TData = Awaited<ReturnType<typeof me>>, TError = void>(
/**
* 본인 이름/이메일/연락처/비밀번호를 수정한다(권한·소속·ID 변경 불가).
* @summary 내 정보 수정
*/
export const updateMe = (
reqUpdateMe: ReqUpdateMe,
options?: SecondParameter<typeof customFetch>,) => {
return customFetch<ResMe>(
{url: `/v1/auth/me`, method: 'PATCH',
headers: {'Content-Type': 'application/json', },
data: reqUpdateMe
},
options);
}
export const getUpdateMeMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMe>>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof updateMe>>, TError,{data: ReqUpdateMe}, TContext> => {
const mutationKey = ['updateMe'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateMe>>, {data: ReqUpdateMe}> = (props) => {
const {data} = props ?? {};
return updateMe(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type UpdateMeMutationResult = NonNullable<Awaited<ReturnType<typeof updateMe>>>
export type UpdateMeMutationBody = ReqUpdateMe
export type UpdateMeMutationError = void | HTTPValidationError
/**
* @summary 내 정보 수정
*/
export const useUpdateMe = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateMe>>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof updateMe>>,
TError,
{data: ReqUpdateMe},
TContext
> => {
const mutationOptions = getUpdateMeMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}

View File

@ -0,0 +1,417 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import {
useMutation,
useQuery
} from '@tanstack/react-query';
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
MutationFunction,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseMutationOptions,
UseMutationResult,
UseQueryOptions,
UseQueryResult
} from '@tanstack/react-query';
import type {
HTTPValidationError,
ListUsersParams,
ReqCreateCompanyUser,
ReqUpdateCompanyUser,
ResCompanyUser,
ResCompanyUserList,
ResDeleteCompanyUser
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary 회사 유저 목록(최고관리자)
*/
export const listUsers = (
params?: ListUsersParams,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResCompanyUserList>(
{url: `/v1/company/user/list`, method: 'GET',
params, signal
},
options);
}
export const getListUsersQueryKey = (params?: ListUsersParams,) => {
return [
`/v1/company/user/list`, ...(params ? [params]: [])
] as const;
}
export const getListUsersQueryOptions = <TData = Awaited<ReturnType<typeof listUsers>>, TError = void | HTTPValidationError>(params?: ListUsersParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListUsersQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listUsers>>> = ({ signal }) => listUsers(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type ListUsersQueryResult = NonNullable<Awaited<ReturnType<typeof listUsers>>>
export type ListUsersQueryError = void | HTTPValidationError
export function useListUsers<TData = Awaited<ReturnType<typeof listUsers>>, TError = void | HTTPValidationError>(
params: undefined | ListUsersParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listUsers>>,
TError,
Awaited<ReturnType<typeof listUsers>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListUsers<TData = Awaited<ReturnType<typeof listUsers>>, TError = void | HTTPValidationError>(
params?: ListUsersParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listUsers>>,
TError,
Awaited<ReturnType<typeof listUsers>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListUsers<TData = Awaited<ReturnType<typeof listUsers>>, TError = void | HTTPValidationError>(
params?: ListUsersParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 회사 유저 목록(최고관리자)
*/
export function useListUsers<TData = Awaited<ReturnType<typeof listUsers>>, TError = void | HTTPValidationError>(
params?: ListUsersParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listUsers>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListUsersQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary 회사 유저 생성(일반 권한 고정)
*/
export const createUser = (
reqCreateCompanyUser: ReqCreateCompanyUser,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResCompanyUser>(
{url: `/v1/company/user/create`, method: 'POST',
headers: {'Content-Type': 'application/json', },
data: reqCreateCompanyUser, signal
},
options);
}
export const getCreateUserMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createUser>>, TError,{data: ReqCreateCompanyUser}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof createUser>>, TError,{data: ReqCreateCompanyUser}, TContext> => {
const mutationKey = ['createUser'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof createUser>>, {data: ReqCreateCompanyUser}> = (props) => {
const {data} = props ?? {};
return createUser(data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type CreateUserMutationResult = NonNullable<Awaited<ReturnType<typeof createUser>>>
export type CreateUserMutationBody = ReqCreateCompanyUser
export type CreateUserMutationError = void | HTTPValidationError
/**
* @summary 회사 유저 생성(일반 권한 고정)
*/
export const useCreateUser = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof createUser>>, TError,{data: ReqCreateCompanyUser}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof createUser>>,
TError,
{data: ReqCreateCompanyUser},
TContext
> => {
const mutationOptions = getCreateUserMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* @summary 회사 유저 조회
*/
export const getUser = (
userId: string,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResCompanyUser>(
{url: `/v1/company/user/${userId}`, method: 'GET', signal
},
options);
}
export const getGetUserQueryKey = (userId?: string,) => {
return [
`/v1/company/user/${userId}`
] as const;
}
export const getGetUserQueryOptions = <TData = Awaited<ReturnType<typeof getUser>>, TError = void | HTTPValidationError>(userId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUser>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetUserQueryKey(userId);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getUser>>> = ({ signal }) => getUser(userId, requestOptions, signal);
return { queryKey, queryFn, enabled: !!(userId), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getUser>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetUserQueryResult = NonNullable<Awaited<ReturnType<typeof getUser>>>
export type GetUserQueryError = void | HTTPValidationError
export function useGetUser<TData = Awaited<ReturnType<typeof getUser>>, TError = void | HTTPValidationError>(
userId: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUser>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getUser>>,
TError,
Awaited<ReturnType<typeof getUser>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetUser<TData = Awaited<ReturnType<typeof getUser>>, TError = void | HTTPValidationError>(
userId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUser>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getUser>>,
TError,
Awaited<ReturnType<typeof getUser>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetUser<TData = Awaited<ReturnType<typeof getUser>>, TError = void | HTTPValidationError>(
userId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUser>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 회사 유저 조회
*/
export function useGetUser<TData = Awaited<ReturnType<typeof getUser>>, TError = void | HTTPValidationError>(
userId: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getUser>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetUserQueryOptions(userId,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary 회사 유저 수정
*/
export const updateUser = (
userId: string,
reqUpdateCompanyUser: ReqUpdateCompanyUser,
options?: SecondParameter<typeof customFetch>,) => {
return customFetch<ResCompanyUser>(
{url: `/v1/company/user/update/${userId}`, method: 'PATCH',
headers: {'Content-Type': 'application/json', },
data: reqUpdateCompanyUser
},
options);
}
export const getUpdateUserMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{userId: string;data: ReqUpdateCompanyUser}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{userId: string;data: ReqUpdateCompanyUser}, TContext> => {
const mutationKey = ['updateUser'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof updateUser>>, {userId: string;data: ReqUpdateCompanyUser}> = (props) => {
const {userId,data} = props ?? {};
return updateUser(userId,data,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type UpdateUserMutationResult = NonNullable<Awaited<ReturnType<typeof updateUser>>>
export type UpdateUserMutationBody = ReqUpdateCompanyUser
export type UpdateUserMutationError = void | HTTPValidationError
/**
* @summary 회사 유저 수정
*/
export const useUpdateUser = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof updateUser>>, TError,{userId: string;data: ReqUpdateCompanyUser}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof updateUser>>,
TError,
{userId: string;data: ReqUpdateCompanyUser},
TContext
> => {
const mutationOptions = getUpdateUserMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* @summary 회사 유저 삭제
*/
export const deleteUser = (
userId: string,
options?: SecondParameter<typeof customFetch>,) => {
return customFetch<ResDeleteCompanyUser>(
{url: `/v1/company/user/delete/${userId}`, method: 'DELETE'
},
options);
}
export const getDeleteUserMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteUser>>, TError,{userId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof deleteUser>>, TError,{userId: string}, TContext> => {
const mutationKey = ['deleteUser'];
const {mutation: mutationOptions, request: requestOptions} = options ?
options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ?
options
: {...options, mutation: {...options.mutation, mutationKey}}
: {mutation: { mutationKey, }, request: undefined};
const mutationFn: MutationFunction<Awaited<ReturnType<typeof deleteUser>>, {userId: string}> = (props) => {
const {userId} = props ?? {};
return deleteUser(userId,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type DeleteUserMutationResult = NonNullable<Awaited<ReturnType<typeof deleteUser>>>
export type DeleteUserMutationError = void | HTTPValidationError
/**
* @summary 회사 유저 삭제
*/
export const useDeleteUser = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof deleteUser>>, TError,{userId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof deleteUser>>,
TError,
{userId: string},
TContext
> => {
const mutationOptions = getDeleteUserMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}

View File

@ -0,0 +1,124 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import {
useQuery
} from '@tanstack/react-query';
import type {
DataTag,
DefinedInitialDataOptions,
DefinedUseQueryResult,
QueryClient,
QueryFunction,
QueryKey,
UndefinedInitialDataOptions,
UseQueryOptions,
UseQueryResult
} from '@tanstack/react-query';
import type {
ResDashboardSummary
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary 대시보드 요약(회사 전체 + 내 견적)
*/
export const getDashboardSummary = (
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResDashboardSummary>(
{url: `/v1/dashboard/summary`, method: 'GET', signal
},
options);
}
export const getGetDashboardSummaryQueryKey = () => {
return [
`/v1/dashboard/summary`
] as const;
}
export const getGetDashboardSummaryQueryOptions = <TData = Awaited<ReturnType<typeof getDashboardSummary>>, TError = void>( options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDashboardSummary>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetDashboardSummaryQueryKey();
const queryFn: QueryFunction<Awaited<ReturnType<typeof getDashboardSummary>>> = ({ signal }) => getDashboardSummary(requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getDashboardSummary>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetDashboardSummaryQueryResult = NonNullable<Awaited<ReturnType<typeof getDashboardSummary>>>
export type GetDashboardSummaryQueryError = void
export function useGetDashboardSummary<TData = Awaited<ReturnType<typeof getDashboardSummary>>, TError = void>(
options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDashboardSummary>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getDashboardSummary>>,
TError,
Awaited<ReturnType<typeof getDashboardSummary>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetDashboardSummary<TData = Awaited<ReturnType<typeof getDashboardSummary>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDashboardSummary>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getDashboardSummary>>,
TError,
Awaited<ReturnType<typeof getDashboardSummary>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetDashboardSummary<TData = Awaited<ReturnType<typeof getDashboardSummary>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDashboardSummary>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary 대시보드 요약(회사 전체 + 내 견적)
*/
export function useGetDashboardSummary<TData = Awaited<ReturnType<typeof getDashboardSummary>>, TError = void>(
options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDashboardSummary>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetDashboardSummaryQueryOptions(options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}

View File

@ -9,6 +9,7 @@ import type { CardDataName } from './cardDataName';
import type { CardDataNumber } from './cardDataNumber';
import type { CardDataScript } from './cardDataScript';
import type { CardDataEditScript } from './cardDataEditScript';
import type { CardUsageType } from './cardUsageType';
import type { CardStatus } from './cardStatus';
import type { CardDataCondition } from './cardDataCondition';
import type { CardDataMemo } from './cardDataMemo';
@ -23,6 +24,7 @@ export interface CardData {
number?: CardDataNumber;
script?: CardDataScript;
edit_script?: CardDataEditScript;
usage_type?: CardUsageType;
status?: CardStatus;
condition?: CardDataCondition;
memo?: CardDataMemo;

View File

@ -0,0 +1,20 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* nego_cards/wild_cards.usage_type 코드값. 협상카드 사용 범위(신규/재 견적·협상 양쪽 적용).
공통=모두 적용(기본), 신규견적전용, 재견적전용.
*/
export type CardUsageType = typeof CardUsageType[keyof typeof CardUsageType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const CardUsageType = {
COMMON: 1,
NEW: 2,
REUSE: 3,
} as const;

View File

@ -0,0 +1,28 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { CompanyUserDataName } from './companyUserDataName';
import type { CompanyUserDataEmail } from './companyUserDataEmail';
import type { CompanyUserDataContactNumber } from './companyUserDataContactNumber';
import type { UserStatus } from './userStatus';
import type { UserRole } from './userRole';
import type { CompanyUserDataLastAccessedAt } from './companyUserDataLastAccessedAt';
import type { CompanyUserDataCreatedAt } from './companyUserDataCreatedAt';
import type { CompanyUserDataUpdatedAt } from './companyUserDataUpdatedAt';
export interface CompanyUserData {
user_id: string;
company_id: string;
id: string;
name?: CompanyUserDataName;
email?: CompanyUserDataEmail;
contact_number?: CompanyUserDataContactNumber;
status: UserStatus;
role: UserRole;
last_accessed_at?: CompanyUserDataLastAccessedAt;
created_at?: CompanyUserDataCreatedAt;
updated_at?: CompanyUserDataUpdatedAt;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CompanyUserDataContactNumber = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CompanyUserDataCreatedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CompanyUserDataEmail = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CompanyUserDataLastAccessedAt = string | null;

View File

@ -5,4 +5,4 @@
* OpenAPI spec version: 0.1.0
*/
export type ResCreateAccountMsg = string | null;
export type CompanyUserDataName = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type CompanyUserDataUpdatedAt = string | null;

View File

@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { DashboardQuotationRef } from './dashboardQuotationRef';
export interface DashboardActionList {
total?: number;
items?: DashboardQuotationRef[];
}

View File

@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { DashboardEmailUnsentItem } from './dashboardEmailUnsentItem';
export interface DashboardEmailUnsent {
total?: number;
quotations?: DashboardEmailUnsentItem[];
}

View File

@ -0,0 +1,12 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export interface DashboardEmailUnsentItem {
qt_id: string;
name?: string;
unsent_count?: number;
}

View File

@ -0,0 +1,13 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { DashboardQuotationRefEndTime } from './dashboardQuotationRefEndTime';
export interface DashboardQuotationRef {
qt_id: string;
name?: string;
end_time?: DashboardQuotationRefEndTime;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type DashboardQuotationRefEndTime = string | null;

View File

@ -0,0 +1,19 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { DashboardActionList } from './dashboardActionList';
import type { DashboardEmailUnsent } from './dashboardEmailUnsent';
export interface DashboardScope {
in_progress?: number;
this_month?: number;
awarded_this_month?: number;
deadline_soon?: DashboardActionList;
email_unsent?: DashboardEmailUnsent;
awarded?: DashboardActionList;
equal_bid?: DashboardActionList;
ruptured?: DashboardActionList;
}

View File

@ -13,7 +13,7 @@ export type DeliveryType = typeof DeliveryType[keyof typeof DeliveryType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const DeliveryType = {
PARTNER: 1,
SUPPLIER: 1,
COURIER: 2,
PICKUP: 3,
} as const;

View File

@ -18,6 +18,7 @@ export * from './cardDataUpdatedAt';
export * from './cardDataUserId';
export * from './cardStatus';
export * from './cardType';
export * from './cardUsageType';
export * from './chatMessageData';
export * from './chatMessageDataCardId';
export * from './chatMessageDataCardType';
@ -27,6 +28,19 @@ export * from './chatMessageDataScript';
export * from './chatMessageDataStep';
export * from './chatSender';
export * from './companyData';
export * from './companyUserData';
export * from './companyUserDataContactNumber';
export * from './companyUserDataCreatedAt';
export * from './companyUserDataEmail';
export * from './companyUserDataLastAccessedAt';
export * from './companyUserDataName';
export * from './companyUserDataUpdatedAt';
export * from './dashboardActionList';
export * from './dashboardEmailUnsent';
export * from './dashboardEmailUnsentItem';
export * from './dashboardQuotationRef';
export * from './dashboardQuotationRefEndTime';
export * from './dashboardScope';
export * from './deliveryType';
export * from './errorInfo';
export * from './errorInfoCode';
@ -41,20 +55,32 @@ export * from './itemDataCreatedAt';
export * from './itemDataDeliveryFeeYn';
export * from './itemDataDeliveryType';
export * from './itemDataImageUrl';
export * from './itemDataInternetLowestPrice';
export * from './itemDataLeadTime';
export * from './itemDataMadeIn';
export * from './itemDataManufacturer';
export * from './itemDataModelName';
export * from './itemDataMoq';
export * from './itemDataPrice';
export * from './itemDataPurchasePrice';
export * from './itemDataQuantityUnit';
export * from './itemDataSellingPrice';
export * from './itemDataSpec';
export * from './itemDataUpdatedAt';
export * from './itemDataVatYn';
export * from './listCardsParams';
export * from './listItemsParams';
export * from './listNotificationsParams';
export * from './listQuotationsParams';
export * from './listSuppliersParams';
export * from './listUsersParams';
export * from './notificationData';
export * from './notificationDataCreatedAt';
export * from './notificationDataData';
export * from './notificationDataReadAt';
export * from './notificationDataRefQtId';
export * from './notificationDataRefSessionId';
export * from './notificationType';
export * from './quotationCardData';
export * from './quotationCardDataCondition';
export * from './quotationCardDataEditScript';
@ -68,6 +94,7 @@ export * from './quotationCardDataType';
export * from './quotationCardDataWildCardId';
export * from './quotationData';
export * from './quotationDataCreatedAt';
export * from './quotationDataCreatorName';
export * from './quotationDataEqualBidData';
export * from './quotationDataEqualBidYn';
export * from './quotationDataItemId';
@ -75,10 +102,12 @@ export * from './quotationDataItemName';
export * from './quotationDataManagerContactNumber';
export * from './quotationDataManagerEmail';
export * from './quotationDataManagerName';
export * from './quotationDataMdPrice';
export * from './quotationDataMemo';
export * from './quotationDataPreferredSpId';
export * from './quotationDataPreferredSpName';
export * from './quotationDataPreferredSpYn';
export * from './quotationDataSupplierType';
export * from './quotationDataUpdatedAt';
export * from './quotationSettingData';
export * from './quotationSettingDataCreatedAt';
@ -87,7 +116,6 @@ export * from './quotationSettingDataUserId';
export * from './quotationStatus';
export * from './quotationType';
export * from './reqCheckCodes';
export * from './reqCreateAccount';
export * from './reqCreateCard';
export * from './reqCreateCardCondition';
export * from './reqCreateCardEditScript';
@ -95,28 +123,34 @@ export * from './reqCreateCardMemo';
export * from './reqCreateCardName';
export * from './reqCreateCardNumber';
export * from './reqCreateCardScript';
export * from './reqCreateCompanyUser';
export * from './reqCreateItem';
export * from './reqCreateItemCategory';
export * from './reqCreateItemCode';
export * from './reqCreateItemDeliveryFeeYn';
export * from './reqCreateItemDeliveryType';
export * from './reqCreateItemImageUrl';
export * from './reqCreateItemInternetLowestPrice';
export * from './reqCreateItemLeadTime';
export * from './reqCreateItemMadeIn';
export * from './reqCreateItemManufacturer';
export * from './reqCreateItemModelName';
export * from './reqCreateItemMoq';
export * from './reqCreateItemPrice';
export * from './reqCreateItemPurchasePrice';
export * from './reqCreateItemQuantityUnit';
export * from './reqCreateItemSellingPrice';
export * from './reqCreateItemSpec';
export * from './reqCreateItemVatYn';
export * from './reqCreateQuotation';
export * from './reqCreateQuotationManagerContactNumber';
export * from './reqCreateQuotationManagerEmail';
export * from './reqCreateQuotationManagerName';
export * from './reqCreateQuotationMdPrice';
export * from './reqCreateQuotationMemo';
export * from './reqCreateQuotationSetting';
export * from './reqCreateQuotationStartTime';
export * from './reqCreateQuotationSupplierType';
export * from './reqCreateQuotationVersionId';
export * from './reqCreateSupplier';
export * from './reqCreateSupplierCode';
@ -134,6 +168,13 @@ export * from './reqUpdateCardName';
export * from './reqUpdateCardNumber';
export * from './reqUpdateCardScript';
export * from './reqUpdateCardStatus';
export * from './reqUpdateCardUsageType';
export * from './reqUpdateCompanyUser';
export * from './reqUpdateCompanyUserContactNumber';
export * from './reqUpdateCompanyUserEmail';
export * from './reqUpdateCompanyUserName';
export * from './reqUpdateCompanyUserPassword';
export * from './reqUpdateCompanyUserStatus';
export * from './reqUpdateItem';
export * from './reqUpdateItemCategory';
export * from './reqUpdateItemCategoryType';
@ -141,6 +182,7 @@ export * from './reqUpdateItemCode';
export * from './reqUpdateItemDeliveryFeeYn';
export * from './reqUpdateItemDeliveryType';
export * from './reqUpdateItemImageUrl';
export * from './reqUpdateItemInternetLowestPrice';
export * from './reqUpdateItemInternetLowestPriceYn';
export * from './reqUpdateItemLeadTime';
export * from './reqUpdateItemMadeIn';
@ -149,9 +191,16 @@ export * from './reqUpdateItemModelName';
export * from './reqUpdateItemMoq';
export * from './reqUpdateItemName';
export * from './reqUpdateItemPrice';
export * from './reqUpdateItemPurchasePrice';
export * from './reqUpdateItemQuantityUnit';
export * from './reqUpdateItemSellingPrice';
export * from './reqUpdateItemSpec';
export * from './reqUpdateItemVatYn';
export * from './reqUpdateMe';
export * from './reqUpdateMeContactNumber';
export * from './reqUpdateMeEmail';
export * from './reqUpdateMeName';
export * from './reqUpdateMePassword';
export * from './reqUpdateQuotationSetting';
export * from './reqUpdateQuotationSettingAnchoringValue';
export * from './reqUpdateQuotationSettingCardCount';
@ -170,13 +219,20 @@ export * from './resCardListMsg';
export * from './resCardMsg';
export * from './resCheckCodes';
export * from './resCheckCodesMsg';
export * from './resCreateAccount';
export * from './resCreateAccountMsg';
export * from './resCompanyUser';
export * from './resCompanyUserList';
export * from './resCompanyUserListMsg';
export * from './resCompanyUserMsg';
export * from './resCompanyUserUser';
export * from './resCreateQuotation';
export * from './resCreateQuotationMsg';
export * from './resCreateQuotationQtId';
export * from './resDashboardSummary';
export * from './resDashboardSummaryMsg';
export * from './resDeleteCard';
export * from './resDeleteCardMsg';
export * from './resDeleteCompanyUser';
export * from './resDeleteCompanyUserMsg';
export * from './resDeleteItem';
export * from './resDeleteItemMsg';
export * from './resDeleteQuotation';
@ -197,6 +253,10 @@ export * from './resItemItem';
export * from './resItemList';
export * from './resItemListMsg';
export * from './resItemMsg';
export * from './resLastSupplierType';
export * from './resLastSupplierTypeMsg';
export * from './resLastSupplierTypeQtNumber';
export * from './resLastSupplierTypeSupplierType';
export * from './resLogin';
export * from './resLoginMsg';
export * from './resLowestPriceResult';
@ -209,6 +269,12 @@ export * from './resMeContactNumber';
export * from './resMeEmail';
export * from './resMeMsg';
export * from './resMeName';
export * from './resNotificationList';
export * from './resNotificationListMsg';
export * from './resNotificationRead';
export * from './resNotificationReadMsg';
export * from './resNotifySessions';
export * from './resNotifySessionsMsg';
export * from './resQuotation';
export * from './resQuotationCards';
export * from './resQuotationCardsMsg';
@ -245,12 +311,22 @@ export * from './resSupplierList';
export * from './resSupplierListMsg';
export * from './resSupplierMsg';
export * from './resSupplierSupplier';
export * from './resTargetBreakdown';
export * from './resTargetBreakdownChosenBasis';
export * from './resTargetBreakdownInternetLowest';
export * from './resTargetBreakdownMdPrice';
export * from './resTargetBreakdownMsg';
export * from './resTargetBreakdownPurchase';
export * from './resTargetBreakdownSelling';
export * from './resTargetBreakdownTargetAnchoringPrice';
export * from './sessionData';
export * from './sessionDataBidAt';
export * from './sessionDataBidPrice';
export * from './sessionDataEmailSentAt';
export * from './sessionDataRejectDeliveryType';
export * from './sessionDataRejectPrice';
export * from './sessionDataRejectReason';
export * from './sessionDataTargetAnchoringPrice';
export * from './sessionStatus';
export * from './supplierData';
export * from './supplierDataCode';
@ -260,7 +336,10 @@ export * from './supplierDataManagerEmail';
export * from './supplierDataManagerName';
export * from './supplierDataPriority';
export * from './supplierDataUpdatedAt';
export * from './supplierType';
export * from './targetCandidate';
export * from './userRole';
export * from './userStatus';
export * from './validationError';
export * from './validationErrorCtx';
export * from './validationErrorLocItem';

View File

@ -12,6 +12,9 @@ import type { ItemDataSpec } from './itemDataSpec';
import type { ItemDataManufacturer } from './itemDataManufacturer';
import type { ItemDataMadeIn } from './itemDataMadeIn';
import type { ItemDataPrice } from './itemDataPrice';
import type { ItemDataInternetLowestPrice } from './itemDataInternetLowestPrice';
import type { ItemDataPurchasePrice } from './itemDataPurchasePrice';
import type { ItemDataSellingPrice } from './itemDataSellingPrice';
import type { ItemDataMoq } from './itemDataMoq';
import type { ItemDataLeadTime } from './itemDataLeadTime';
import type { ItemDataQuantityUnit } from './itemDataQuantityUnit';
@ -36,6 +39,9 @@ export interface ItemData {
made_in?: ItemDataMadeIn;
price?: ItemDataPrice;
internet_lowest_price_yn?: boolean;
internet_lowest_price?: ItemDataInternetLowestPrice;
purchase_price?: ItemDataPurchasePrice;
selling_price?: ItemDataSellingPrice;
moq?: ItemDataMoq;
lead_time?: ItemDataLeadTime;
quantity_unit?: ItemDataQuantityUnit;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ItemDataInternetLowestPrice = number | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ItemDataPurchasePrice = number | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ItemDataSellingPrice = number | null;

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ListNotificationsParams = {
/**
* @minimum 1
*/
page?: number;
/**
* @minimum 1
* @maximum 100
*/
size?: number;
};

View File

@ -26,6 +26,10 @@ start_from?: string | null;
* 시작일시 이전(ISO)
*/
start_to?: string | null;
/**
* 내 견적만 보기(작성자=로그인 유저)
*/
mine?: boolean;
/**
* @minimum 1
*/

View File

@ -0,0 +1,22 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ListUsersParams = {
/**
* 로그인ID/이름/이메일 검색
*/
search?: string | null;
/**
* @minimum 1
*/
page?: number;
/**
* @minimum 1
* @maximum 100
*/
size?: number;
};

View File

@ -0,0 +1,22 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { NotificationType } from './notificationType';
import type { NotificationDataRefQtId } from './notificationDataRefQtId';
import type { NotificationDataRefSessionId } from './notificationDataRefSessionId';
import type { NotificationDataData } from './notificationDataData';
import type { NotificationDataReadAt } from './notificationDataReadAt';
import type { NotificationDataCreatedAt } from './notificationDataCreatedAt';
export interface NotificationData {
notification_id: string;
type: NotificationType;
ref_qt_id?: NotificationDataRefQtId;
ref_session_id?: NotificationDataRefSessionId;
data?: NotificationDataData;
read_at?: NotificationDataReadAt;
created_at?: NotificationDataCreatedAt;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type NotificationDataCreatedAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type NotificationDataData = unknown | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type NotificationDataReadAt = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type NotificationDataRefQtId = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type NotificationDataRefSessionId = string | null;

View File

@ -0,0 +1,20 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* company.notifications.type 코드값. 견적 생애 이벤트를 작성자에게 통지. 마감 결과 3종(SUCCESS/REGENERATED/FAILURE)은 close_and_decide 와 1:1. 네이밍은 KTC.
*/
export type NotificationType = typeof NotificationType[keyof typeof NotificationType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const NotificationType = {
SUCCESS: 1,
REGENERATED: 2,
FAILURE: 3,
CREATED: 4,
} as const;

View File

@ -10,6 +10,8 @@ import type { QuotationDataManagerName } from './quotationDataManagerName';
import type { QuotationDataManagerEmail } from './quotationDataManagerEmail';
import type { QuotationDataManagerContactNumber } from './quotationDataManagerContactNumber';
import type { QuotationDataMemo } from './quotationDataMemo';
import type { QuotationDataMdPrice } from './quotationDataMdPrice';
import type { QuotationDataSupplierType } from './quotationDataSupplierType';
import type { QuotationDataPreferredSpYn } from './quotationDataPreferredSpYn';
import type { QuotationDataPreferredSpId } from './quotationDataPreferredSpId';
import type { QuotationDataPreferredSpName } from './quotationDataPreferredSpName';
@ -17,6 +19,7 @@ import type { QuotationDataEqualBidYn } from './quotationDataEqualBidYn';
import type { QuotationDataEqualBidData } from './quotationDataEqualBidData';
import type { QuotationDataItemId } from './quotationDataItemId';
import type { QuotationDataItemName } from './quotationDataItemName';
import type { QuotationDataCreatorName } from './quotationDataCreatorName';
import type { QuotationDataCreatedAt } from './quotationDataCreatedAt';
import type { QuotationDataUpdatedAt } from './quotationDataUpdatedAt';
@ -36,6 +39,8 @@ export interface QuotationData {
manager_email?: QuotationDataManagerEmail;
manager_contact_number?: QuotationDataManagerContactNumber;
memo?: QuotationDataMemo;
md_price?: QuotationDataMdPrice;
supplier_type?: QuotationDataSupplierType;
iteration?: number;
preferred_sp_yn?: QuotationDataPreferredSpYn;
preferred_sp_id?: QuotationDataPreferredSpId;
@ -45,6 +50,7 @@ export interface QuotationData {
participation_count?: number;
item_id?: QuotationDataItemId;
item_name?: QuotationDataItemName;
creator_name?: QuotationDataCreatorName;
created_at?: QuotationDataCreatedAt;
updated_at?: QuotationDataUpdatedAt;
}

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type QuotationDataCreatorName = string | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type QuotationDataMdPrice = number | null;

View File

@ -0,0 +1,9 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { SupplierType } from './supplierType';
export type QuotationDataSupplierType = SupplierType | null;

View File

@ -14,7 +14,6 @@ export type QuotationStatus = typeof QuotationStatus[keyof typeof QuotationStatu
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const QuotationStatus = {
CREATED: 1,
ACTIVE: 2,
IN_PROGRESS: 2,
CLOSED: 3,
ON_HOLD: 4,
} as const;

View File

@ -6,7 +6,9 @@
*/
/**
* quotations.type 코드값. 1=renego(재협상 1:1), 2=requote(재견적 1:N).
* quotations.type 코드값. 신규/재 × 협상(1:1)/견적(1:N).
1=재협상(1:1), 2=재견적(1:N), 3=신규협상(1:1), 4=신규견적(1:N).
기존 데이터 보존 위해 재협상/재견적 코드(1·2)는 고정, 신규는 3·4로 추가.
*/
export type QuotationType = typeof QuotationType[keyof typeof QuotationType];
@ -15,4 +17,6 @@ export type QuotationType = typeof QuotationType[keyof typeof QuotationType];
export const QuotationType = {
RENEGO: 1,
REQUOTE: 2,
NEW_NEGO: 3,
NEW_QUOTE: 4,
} as const;

View File

@ -17,6 +17,7 @@ export interface ReqCreateCard {
number?: ReqCreateCardNumber;
script?: ReqCreateCardScript;
edit_script?: ReqCreateCardEditScript;
usage_type?: number;
status?: number;
condition?: ReqCreateCardCondition;
memo?: ReqCreateCardMemo;

View File

@ -5,12 +5,10 @@
* OpenAPI spec version: 0.1.0
*/
export interface ReqCreateAccount {
export interface ReqCreateCompanyUser {
id?: string;
password?: string;
company_id?: string;
name?: string;
email?: string;
contact_number?: string;
role?: number;
}

View File

@ -12,6 +12,9 @@ import type { ReqCreateItemSpec } from './reqCreateItemSpec';
import type { ReqCreateItemManufacturer } from './reqCreateItemManufacturer';
import type { ReqCreateItemMadeIn } from './reqCreateItemMadeIn';
import type { ReqCreateItemPrice } from './reqCreateItemPrice';
import type { ReqCreateItemInternetLowestPrice } from './reqCreateItemInternetLowestPrice';
import type { ReqCreateItemPurchasePrice } from './reqCreateItemPurchasePrice';
import type { ReqCreateItemSellingPrice } from './reqCreateItemSellingPrice';
import type { ReqCreateItemMoq } from './reqCreateItemMoq';
import type { ReqCreateItemLeadTime } from './reqCreateItemLeadTime';
import type { ReqCreateItemQuantityUnit } from './reqCreateItemQuantityUnit';
@ -31,6 +34,9 @@ export interface ReqCreateItem {
made_in?: ReqCreateItemMadeIn;
price?: ReqCreateItemPrice;
internet_lowest_price_yn?: boolean;
internet_lowest_price?: ReqCreateItemInternetLowestPrice;
purchase_price?: ReqCreateItemPurchasePrice;
selling_price?: ReqCreateItemSellingPrice;
moq?: ReqCreateItemMoq;
lead_time?: ReqCreateItemLeadTime;
quantity_unit?: ReqCreateItemQuantityUnit;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqCreateItemInternetLowestPrice = number | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqCreateItemPurchasePrice = number | null;

View File

@ -0,0 +1,8 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ReqCreateItemSellingPrice = number | null;

View File

@ -10,6 +10,8 @@ import type { ReqCreateQuotationManagerName } from './reqCreateQuotationManagerN
import type { ReqCreateQuotationManagerEmail } from './reqCreateQuotationManagerEmail';
import type { ReqCreateQuotationManagerContactNumber } from './reqCreateQuotationManagerContactNumber';
import type { ReqCreateQuotationMemo } from './reqCreateQuotationMemo';
import type { ReqCreateQuotationMdPrice } from './reqCreateQuotationMdPrice';
import type { ReqCreateQuotationSupplierType } from './reqCreateQuotationSupplierType';
export interface ReqCreateQuotation {
qt_setting_id: string;
@ -24,6 +26,8 @@ export interface ReqCreateQuotation {
manager_email?: ReqCreateQuotationManagerEmail;
manager_contact_number?: ReqCreateQuotationManagerContactNumber;
memo?: ReqCreateQuotationMemo;
md_price?: ReqCreateQuotationMdPrice;
supplier_type?: ReqCreateQuotationSupplierType;
item_ids?: string[];
supplier_ids?: string[];
card_ids?: string[];

Some files were not shown because too many files have changed in this diff Show More