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:
commit
0ac37625fc
@ -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)
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 = ""
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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` 한 방**이면 끝.
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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 # 재견적전용
|
||||
|
||||
@ -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])
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 그대로.
|
||||
|
||||
@ -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
|
||||
|
||||
204
negodata/backend/crud/dashboard_crud.py
Normal file
204
negodata/backend/crud/dashboard_crud.py
Normal 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
|
||||
107
negodata/backend/crud/notification_crud.py
Normal file
107
negodata/backend/crud/notification_crud.py
Normal 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
|
||||
@ -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 = (
|
||||
|
||||
@ -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
|
||||
|
||||
@ -11,3 +11,5 @@ python-multipart
|
||||
openpyxl
|
||||
httpx
|
||||
apscheduler>=3.10
|
||||
azure-communication-email>=1.0 # 초청 메일 1순위 발송 채널(ACS Email)
|
||||
aiosmtplib>=3.0 # 초청 메일 폴백(SMTP)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
58
negodata/backend/router/v1/company/protocol.py
Normal file
58
negodata/backend/router/v1/company/protocol.py
Normal 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
|
||||
51
negodata/backend/router/v1/company/user.py
Normal file
51
negodata/backend/router/v1/company/user.py
Normal 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)))
|
||||
14
negodata/backend/router/v1/dashboard/dashboard.py
Normal file
14
negodata/backend/router/v1/dashboard/dashboard.py
Normal 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))
|
||||
49
negodata/backend/router/v1/dashboard/protocol.py
Normal file
49
negodata/backend/router/v1/dashboard/protocol.py
Normal 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 추가 스코프)
|
||||
@ -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
|
||||
|
||||
32
negodata/backend/router/v1/notification/notification.py
Normal file
32
negodata/backend/router/v1/notification/notification.py
Normal 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)))
|
||||
33
negodata/backend/router/v1/notification/protocol.py
Normal file
33
negodata/backend/router/v1/notification/protocol.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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....) 형태로 반환한다.
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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차 수행됨.
|
||||
|
||||
@ -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}
|
||||
|
||||
158
negodata/backend/services/company_user_service.py
Normal file
158
negodata/backend/services/company_user_service.py
Normal 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
|
||||
101
negodata/backend/services/dashboard_service.py
Normal file
101
negodata/backend/services/dashboard_service.py
Normal 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
|
||||
125
negodata/backend/services/email.py
Normal file
125
negodata/backend/services/email.py
Normal 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
|
||||
70
negodata/backend/services/email_templates/invite_email.html
Normal file
70
negodata/backend/services/email_templates/invite_email.html
Normal 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>
|
||||
88
negodata/backend/services/notification.py
Normal file
88
negodata/backend/services/notification.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
180
negodata/backend/tests/test_close_and_decide_fixes.py
Normal file
180
negodata/backend/tests/test_close_and_decide_fixes.py
Normal 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()
|
||||
140
negodata/backend/tests/test_company_scope.py
Normal file
140
negodata/backend/tests/test_company_scope.py
Normal 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
|
||||
88
negodata/backend/tests/test_company_user.py
Normal file
88
negodata/backend/tests/test_company_user.py
Normal 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},
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
99
negodata/backend/tests/test_notification.py
Normal file
99
negodata/backend/tests/test_notification.py
Normal 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"})},
|
||||
)
|
||||
224
negodata/backend/tests/test_quotation_close_notify.py
Normal file
224
negodata/backend/tests/test_quotation_close_notify.py
Normal 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())
|
||||
98
negodata/backend/tests/test_quotation_create.py
Normal file
98
negodata/backend/tests/test_quotation_create.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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 스토어
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
417
negodata/front/src/api/generated/company-user/company-user.ts
Normal file
417
negodata/front/src/api/generated/company-user/company-user.ts
Normal 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);
|
||||
}
|
||||
|
||||
124
negodata/front/src/api/generated/dashboard/dashboard.ts
Normal file
124
negodata/front/src/api/generated/dashboard/dashboard.ts
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
20
negodata/front/src/api/generated/model/cardUsageType.ts
Normal file
20
negodata/front/src/api/generated/model/cardUsageType.ts
Normal 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;
|
||||
28
negodata/front/src/api/generated/model/companyUserData.ts
Normal file
28
negodata/front/src/api/generated/model/companyUserData.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -5,4 +5,4 @@
|
||||
* OpenAPI spec version: 0.1.0
|
||||
*/
|
||||
|
||||
export type ResCreateAccountMsg = string | null;
|
||||
export type CompanyUserDataName = string | null;
|
||||
@ -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;
|
||||
@ -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[];
|
||||
}
|
||||
@ -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[];
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
19
negodata/front/src/api/generated/model/dashboardScope.ts
Normal file
19
negodata/front/src/api/generated/model/dashboardScope.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
};
|
||||
@ -26,6 +26,10 @@ start_from?: string | null;
|
||||
* 시작일시 이전(ISO)
|
||||
*/
|
||||
start_to?: string | null;
|
||||
/**
|
||||
* 내 견적만 보기(작성자=로그인 유저)
|
||||
*/
|
||||
mine?: boolean;
|
||||
/**
|
||||
* @minimum 1
|
||||
*/
|
||||
|
||||
22
negodata/front/src/api/generated/model/listUsersParams.ts
Normal file
22
negodata/front/src/api/generated/model/listUsersParams.ts
Normal 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;
|
||||
};
|
||||
22
negodata/front/src/api/generated/model/notificationData.ts
Normal file
22
negodata/front/src/api/generated/model/notificationData.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
20
negodata/front/src/api/generated/model/notificationType.ts
Normal file
20
negodata/front/src/api/generated/model/notificationType.ts
Normal 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;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -17,6 +17,7 @@ export interface ReqCreateCard {
|
||||
number?: ReqCreateCardNumber;
|
||||
script?: ReqCreateCardScript;
|
||||
edit_script?: ReqCreateCardEditScript;
|
||||
usage_type?: number;
|
||||
status?: number;
|
||||
condition?: ReqCreateCardCondition;
|
||||
memo?: ReqCreateCardMemo;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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
Loading…
Reference in New Issue
Block a user