diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index df2fbf8..8726e34 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -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) diff --git a/backend/common/enums.py b/backend/common/enums.py index b600a04..cd241d2 100644 --- a/backend/common/enums.py +++ b/backend/common/enums.py @@ -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): diff --git a/backend/router/v1/negotiation/protocol.py b/backend/router/v1/negotiation/protocol.py index 0134cfe..af2ad66 100644 --- a/backend/router/v1/negotiation/protocol.py +++ b/backend/router/v1/negotiation/protocol.py @@ -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 = "" diff --git a/backend/router/v1/negotiation/session.py b/backend/router/v1/negotiation/session.py index 324a634..e1f3dca 100644 --- a/backend/router/v1/negotiation/session.py +++ b/backend/router/v1/negotiation/session.py @@ -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), diff --git a/backend/scripts/dev_seed.sql b/backend/scripts/dev_seed.sql index 039efe5..9d513f3 100644 --- a/backend/scripts/dev_seed.sql +++ b/backend/scripts/dev_seed.sql @@ -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 diff --git a/frontend/src/features/chat/hooks/useChatController.ts b/frontend/src/features/chat/hooks/useChatController.ts index fd18d01..64c4ba8 100644 --- a/frontend/src/features/chat/hooks/useChatController.ts +++ b/frontend/src/features/chat/hooks/useChatController.ts @@ -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(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(() => { diff --git a/negodata/backend/README.md b/negodata/backend/README.md index 87efd66..a475691 100644 --- a/negodata/backend/README.md +++ b/negodata/backend/README.md @@ -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 `. 회사 소유 자원은 토큰의 회사로 스코프되고, 계정 관리는 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` 한 방**이면 끝. diff --git a/negodata/backend/common/database/db_session_manager.py b/negodata/backend/common/database/db_session_manager.py index 31d9d2f..c2b95cd 100644 --- a/negodata/backend/common/database/db_session_manager.py +++ b/negodata/backend/common/database/db_session_manager.py @@ -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() diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index c3c9e31..ce9c570 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -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): diff --git a/negodata/backend/common/enums.py b/negodata/backend/common/enums.py index 9c1b0f7..64c8e70 100644 --- a/negodata/backend/common/enums.py +++ b/negodata/backend/common/enums.py @@ -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 # 재견적전용 diff --git a/negodata/backend/common/models/gmodel.py b/negodata/backend/common/models/gmodel.py index cfa6d5d..bb956fe 100644 --- a/negodata/backend/common/models/gmodel.py +++ b/negodata/backend/common/models/gmodel.py @@ -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]) diff --git a/negodata/backend/config/config_models.py b/negodata/backend/config/config_models.py index 97d11ac..a3864df 100644 --- a/negodata/backend/config/config_models.py +++ b/negodata/backend/config/config_models.py @@ -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 " + 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) diff --git a/negodata/backend/config/server_configs.py b/negodata/backend/config/server_configs.py index f9ceaf1..7042709 100644 --- a/negodata/backend/config/server_configs.py +++ b/negodata/backend/config/server_configs.py @@ -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 그대로. diff --git a/negodata/backend/conftest.py b/negodata/backend/conftest.py index da559b8..655d03c 100644 --- a/negodata/backend/conftest.py +++ b/negodata/backend/conftest.py @@ -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 diff --git a/negodata/backend/crud/dashboard_crud.py b/negodata/backend/crud/dashboard_crud.py new file mode 100644 index 0000000..965eb5c --- /dev/null +++ b/negodata/backend/crud/dashboard_crud.py @@ -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 diff --git a/negodata/backend/crud/notification_crud.py b/negodata/backend/crud/notification_crud.py new file mode 100644 index 0000000..05d173c --- /dev/null +++ b/negodata/backend/crud/notification_crud.py @@ -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 diff --git a/negodata/backend/crud/quotation_crud.py b/negodata/backend/crud/quotation_crud.py index 9a1dddb..83d3cc1 100644 --- a/negodata/backend/crud/quotation_crud.py +++ b/negodata/backend/crud/quotation_crud.py @@ -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 = ( diff --git a/negodata/backend/crud/user_crud.py b/negodata/backend/crud/user_crud.py index 58a1cbf..25ba03e 100644 --- a/negodata/backend/crud/user_crud.py +++ b/negodata/backend/crud/user_crud.py @@ -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 diff --git a/negodata/backend/requirements.txt b/negodata/backend/requirements.txt index 1d50362..fa46e28 100644 --- a/negodata/backend/requirements.txt +++ b/negodata/backend/requirements.txt @@ -11,3 +11,5 @@ python-multipart openpyxl httpx apscheduler>=3.10 +azure-communication-email>=1.0 # 초청 메일 1순위 발송 채널(ACS Email) +aiosmtplib>=3.0 # 초청 메일 폴백(SMTP) diff --git a/negodata/backend/router/router.py b/negodata/backend/router/router.py index 035373e..f2ceabc 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -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.. 를 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) diff --git a/negodata/backend/router/v1/auth/account.py b/negodata/backend/router/v1/auth/account.py index f2a7d3c..8eb38ca 100644 --- a/negodata/backend/router/v1/auth/account.py +++ b/negodata/backend/router/v1/auth/account.py @@ -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)) diff --git a/negodata/backend/router/v1/auth/protocol.py b/negodata/backend/router/v1/auth/protocol.py index f07c0d0..1134e1e 100644 --- a/negodata/backend/router/v1/auth/protocol.py +++ b/negodata/backend/router/v1/auth/protocol.py @@ -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): diff --git a/negodata/backend/router/v1/card/protocol.py b/negodata/backend/router/v1/card/protocol.py index 2bf3e19..bd54f39 100644 --- a/negodata/backend/router/v1/card/protocol.py +++ b/negodata/backend/router/v1/card/protocol.py @@ -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 diff --git a/negodata/backend/router/v1/company/protocol.py b/negodata/backend/router/v1/company/protocol.py new file mode 100644 index 0000000..91bd450 --- /dev/null +++ b/negodata/backend/router/v1/company/protocol.py @@ -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 diff --git a/negodata/backend/router/v1/company/user.py b/negodata/backend/router/v1/company/user.py new file mode 100644 index 0000000..76df668 --- /dev/null +++ b/negodata/backend/router/v1/company/user.py @@ -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))) diff --git a/negodata/backend/router/v1/dashboard/dashboard.py b/negodata/backend/router/v1/dashboard/dashboard.py new file mode 100644 index 0000000..c257e3b --- /dev/null +++ b/negodata/backend/router/v1/dashboard/dashboard.py @@ -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)) diff --git a/negodata/backend/router/v1/dashboard/protocol.py b/negodata/backend/router/v1/dashboard/protocol.py new file mode 100644 index 0000000..52492ed --- /dev/null +++ b/negodata/backend/router/v1/dashboard/protocol.py @@ -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 추가 스코프) diff --git a/negodata/backend/router/v1/item/protocol.py b/negodata/backend/router/v1/item/protocol.py index 1ef449c..fd8d7e6 100644 --- a/negodata/backend/router/v1/item/protocol.py +++ b/negodata/backend/router/v1/item/protocol.py @@ -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 diff --git a/negodata/backend/router/v1/notification/notification.py b/negodata/backend/router/v1/notification/notification.py new file mode 100644 index 0000000..6b1d6be --- /dev/null +++ b/negodata/backend/router/v1/notification/notification.py @@ -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))) diff --git a/negodata/backend/router/v1/notification/protocol.py b/negodata/backend/router/v1/notification/protocol.py new file mode 100644 index 0000000..93e0eb7 --- /dev/null +++ b/negodata/backend/router/v1/notification/protocol.py @@ -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 diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index f302cf5..31901e6 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -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 diff --git a/negodata/backend/router/v1/quotation/quotation.py b/negodata/backend/router/v1/quotation/quotation.py index 2c60663..ec1326f 100644 --- a/negodata/backend/router/v1/quotation/quotation.py +++ b/negodata/backend/router/v1/quotation/quotation.py @@ -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)) diff --git a/negodata/backend/router/v1/validator/dependencies.py b/negodata/backend/router/v1/validator/dependencies.py index 72b70cf..0bb784f 100644 --- a/negodata/backend/router/v1/validator/dependencies.py +++ b/negodata/backend/router/v1/validator/dependencies.py @@ -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....) 형태로 반환한다. diff --git a/negodata/backend/scheduler/jobs.py b/negodata/backend/scheduler/jobs.py index bf104c7..c8b9966 100644 --- a/negodata/backend/scheduler/jobs.py +++ b/negodata/backend/scheduler/jobs.py @@ -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()) diff --git a/negodata/backend/services/auth_service.py b/negodata/backend/services/auth_service.py index d3c39af..cfa320c 100644 --- a/negodata/backend/services/auth_service.py +++ b/negodata/backend/services/auth_service.py @@ -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차 수행됨. diff --git a/negodata/backend/services/card_service.py b/negodata/backend/services/card_service.py index ff2f13c..513086d 100644 --- a/negodata/backend/services/card_service.py +++ b/negodata/backend/services/card_service.py @@ -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} diff --git a/negodata/backend/services/company_user_service.py b/negodata/backend/services/company_user_service.py new file mode 100644 index 0000000..a45c98f --- /dev/null +++ b/negodata/backend/services/company_user_service.py @@ -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 diff --git a/negodata/backend/services/dashboard_service.py b/negodata/backend/services/dashboard_service.py new file mode 100644 index 0000000..2335f04 --- /dev/null +++ b/negodata/backend/services/dashboard_service.py @@ -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 diff --git a/negodata/backend/services/email.py b/negodata/backend/services/email.py new file mode 100644 index 0000000..eaea9ed --- /dev/null +++ b/negodata/backend/services/email.py @@ -0,0 +1,125 @@ +"""협상 초청 메일 발송. + +1순위: Azure Communication Services(ACS) Email (endpoint + accesskey + 검증된 sender). +2순위(폴백): SMTP (aiosmtplib). +둘 다 미설정이면 EmailUnavailable 을 던진다(조용한 실패 0). + +설정 출처: config..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 diff --git a/negodata/backend/services/email_templates/invite_email.html b/negodata/backend/services/email_templates/invite_email.html new file mode 100644 index 0000000..aa79386 --- /dev/null +++ b/negodata/backend/services/email_templates/invite_email.html @@ -0,0 +1,70 @@ + + + + +
+ + + + + + + + + + + + + + + + + +
+ NEGODATA +
+

협상 참여 요청

+

+ $supplier_name 담당자님,
+ 아래 견적 건의 협상에 참여해 주세요. +

+ + + + + + + + + + + + + + + +
견적명$quotation_name
견적번호$qt_number
협상 마감$deadline
+ + + + + + +
+ + + + +
+ + 협상 참여하기 → +
+
+
+

+ 버튼이 열리지 않으면 아래 링크를 복사해 접속하세요.
+ https://nego.o2o.kr +

+
+

본 메일은 협상 견적 시스템에서 자동 발송되었습니다.

+
diff --git a/negodata/backend/services/notification.py b/negodata/backend/services/notification.py new file mode 100644 index 0000000..4c366c4 --- /dev/null +++ b/negodata/backend/services/notification.py @@ -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 diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index 4cf7690..c7fe91f 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -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 diff --git a/negodata/backend/tests/test_auth.py b/negodata/backend/tests/test_auth.py index ea23cce..1c44628 100644 --- a/negodata/backend/tests/test_auth.py +++ b/negodata/backend/tests/test_auth.py @@ -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) diff --git a/negodata/backend/tests/test_close_and_decide_fixes.py b/negodata/backend/tests/test_close_and_decide_fixes.py new file mode 100644 index 0000000..45f9b6f --- /dev/null +++ b/negodata/backend/tests/test_close_and_decide_fixes.py @@ -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() diff --git a/negodata/backend/tests/test_company_scope.py b/negodata/backend/tests/test_company_scope.py new file mode 100644 index 0000000..94aa93e --- /dev/null +++ b/negodata/backend/tests/test_company_scope.py @@ -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 diff --git a/negodata/backend/tests/test_company_user.py b/negodata/backend/tests/test_company_user.py new file mode 100644 index 0000000..04bd3cc --- /dev/null +++ b/negodata/backend/tests/test_company_user.py @@ -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}, + ) diff --git a/negodata/backend/tests/test_features.py b/negodata/backend/tests/test_features.py index 05eb5ca..f6a2e12 100644 --- a/negodata/backend/tests/test_features.py +++ b/negodata/backend/tests/test_features.py @@ -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 diff --git a/negodata/backend/tests/test_item.py b/negodata/backend/tests/test_item.py index c8c1cf8..c994cab 100644 --- a/negodata/backend/tests/test_item.py +++ b/negodata/backend/tests/test_item.py @@ -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 diff --git a/negodata/backend/tests/test_notification.py b/negodata/backend/tests/test_notification.py new file mode 100644 index 0000000..a8db585 --- /dev/null +++ b/negodata/backend/tests/test_notification.py @@ -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"})}, + ) diff --git a/negodata/backend/tests/test_quotation_close_notify.py b/negodata/backend/tests/test_quotation_close_notify.py new file mode 100644 index 0000000..95d0ce9 --- /dev/null +++ b/negodata/backend/tests/test_quotation_close_notify.py @@ -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()) diff --git a/negodata/backend/tests/test_quotation_create.py b/negodata/backend/tests/test_quotation_create.py new file mode 100644 index 0000000..f8192ce --- /dev/null +++ b/negodata/backend/tests/test_quotation_create.py @@ -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 diff --git a/negodata/backend/tests/test_scheduler.py b/negodata/backend/tests/test_scheduler.py index 73abe7d..0abc014 100644 --- a/negodata/backend/tests/test_scheduler.py +++ b/negodata/backend/tests/test_scheduler.py @@ -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) diff --git a/negodata/front/README.md b/negodata/front/README.md index f22beba..a2b84f5 100644 --- a/negodata/front/README.md +++ b/negodata/front/README.md @@ -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 스토어 diff --git a/negodata/front/src/api/generated/auth/auth.ts b/negodata/front/src/api/generated/auth/auth.ts index 1ce886e..ade4767 100644 --- a/negodata/front/src/api/generated/auth/auth.ts +++ b/negodata/front/src/api/generated/auth/auth.ts @@ -25,9 +25,8 @@ import type { import type { HTTPValidationError, - ReqCreateAccount, ReqLogin, - ResCreateAccount, + ReqUpdateMe, ResLogin, ResMe, ResRefreshToken @@ -106,71 +105,6 @@ export const useLogin = ,signal?: AbortSignal -) => { - - - return customFetch( - {url: `/v1/auth/create`, method: 'POST', - headers: {'Content-Type': 'application/json', }, - data: reqCreateAccount, signal - }, - options); - } - - - -export const getCreateAccountMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateAccount}, TContext>, request?: SecondParameter} -): UseMutationOptions>, 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>, {data: ReqCreateAccount}> = (props) => { - const {data} = props ?? {}; - - return createAccount(data,requestOptions) - } - - - - - return { mutationFn, ...mutationOptions }} - - export type CreateAccountMutationResult = NonNullable>> - export type CreateAccountMutationBody = ReqCreateAccount - export type CreateAccountMutationError = void | HTTPValidationError - - /** - * @summary 계정 생성 - */ -export const useCreateAccount = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateAccount}, TContext>, request?: SecondParameter} - , queryClient?: QueryClient): UseMutationResult< - Awaited>, - TError, - {data: ReqCreateAccount}, - TContext - > => { - - const mutationOptions = getCreateAccountMutationOptions(options); - - return useMutation(mutationOptions, queryClient); - } - /** * refresh 토큰으로 access 토큰을 재발급한다. * @summary 액세스 토큰 갱신 */ @@ -326,3 +260,68 @@ export function useMe>, TError = void>( +/** + * 본인 이름/이메일/연락처/비밀번호를 수정한다(권한·소속·ID 변경 불가). + * @summary 내 정보 수정 + */ +export const updateMe = ( + reqUpdateMe: ReqUpdateMe, + options?: SecondParameter,) => { + + + return customFetch( + {url: `/v1/auth/me`, method: 'PATCH', + headers: {'Content-Type': 'application/json', }, + data: reqUpdateMe + }, + options); + } + + + +export const getUpdateMeMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter} +): UseMutationOptions>, 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>, {data: ReqUpdateMe}> = (props) => { + const {data} = props ?? {}; + + return updateMe(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type UpdateMeMutationResult = NonNullable>> + export type UpdateMeMutationBody = ReqUpdateMe + export type UpdateMeMutationError = void | HTTPValidationError + + /** + * @summary 내 정보 수정 + */ +export const useUpdateMe = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqUpdateMe}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ReqUpdateMe}, + TContext + > => { + + const mutationOptions = getUpdateMeMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + \ No newline at end of file diff --git a/negodata/front/src/api/generated/company-user/company-user.ts b/negodata/front/src/api/generated/company-user/company-user.ts new file mode 100644 index 0000000..c6af7f6 --- /dev/null +++ b/negodata/front/src/api/generated/company-user/company-user.ts @@ -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 unknown> = Parameters[1]; + + + +/** + * @summary 회사 유저 목록(최고관리자) + */ +export const listUsers = ( + params?: ListUsersParams, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {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 = >, TError = void | HTTPValidationError>(params?: ListUsersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListUsersQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => listUsers(params, requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListUsersQueryResult = NonNullable>> +export type ListUsersQueryError = void | HTTPValidationError + + +export function useListUsers>, TError = void | HTTPValidationError>( + params: undefined | ListUsersParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListUsers>, TError = void | HTTPValidationError>( + params?: ListUsersParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListUsers>, TError = void | HTTPValidationError>( + params?: ListUsersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 회사 유저 목록(최고관리자) + */ + +export function useListUsers>, TError = void | HTTPValidationError>( + params?: ListUsersParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListUsersQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 회사 유저 생성(일반 권한 고정) + */ +export const createUser = ( + reqCreateCompanyUser: ReqCreateCompanyUser, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/company/user/create`, method: 'POST', + headers: {'Content-Type': 'application/json', }, + data: reqCreateCompanyUser, signal + }, + options); + } + + + +export const getCreateUserMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateCompanyUser}, TContext>, request?: SecondParameter} +): UseMutationOptions>, 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>, {data: ReqCreateCompanyUser}> = (props) => { + const {data} = props ?? {}; + + return createUser(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type CreateUserMutationResult = NonNullable>> + export type CreateUserMutationBody = ReqCreateCompanyUser + export type CreateUserMutationError = void | HTTPValidationError + + /** + * @summary 회사 유저 생성(일반 권한 고정) + */ +export const useCreateUser = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqCreateCompanyUser}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ReqCreateCompanyUser}, + TContext + > => { + + const mutationOptions = getCreateUserMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 회사 유저 조회 + */ +export const getUser = ( + userId: string, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/company/user/${userId}`, method: 'GET', signal + }, + options); + } + + + + +export const getGetUserQueryKey = (userId?: string,) => { + return [ + `/v1/company/user/${userId}` + ] as const; + } + + +export const getGetUserQueryOptions = >, TError = void | HTTPValidationError>(userId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetUserQueryKey(userId); + + + + const queryFn: QueryFunction>> = ({ signal }) => getUser(userId, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(userId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetUserQueryResult = NonNullable>> +export type GetUserQueryError = void | HTTPValidationError + + +export function useGetUser>, TError = void | HTTPValidationError>( + userId: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetUser>, TError = void | HTTPValidationError>( + userId: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetUser>, TError = void | HTTPValidationError>( + userId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 회사 유저 조회 + */ + +export function useGetUser>, TError = void | HTTPValidationError>( + userId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetUserQueryOptions(userId,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 회사 유저 수정 + */ +export const updateUser = ( + userId: string, + reqUpdateCompanyUser: ReqUpdateCompanyUser, + options?: SecondParameter,) => { + + + return customFetch( + {url: `/v1/company/user/update/${userId}`, method: 'PATCH', + headers: {'Content-Type': 'application/json', }, + data: reqUpdateCompanyUser + }, + options); + } + + + +export const getUpdateUserMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{userId: string;data: ReqUpdateCompanyUser}, TContext>, request?: SecondParameter} +): UseMutationOptions>, 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>, {userId: string;data: ReqUpdateCompanyUser}> = (props) => { + const {userId,data} = props ?? {}; + + return updateUser(userId,data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type UpdateUserMutationResult = NonNullable>> + export type UpdateUserMutationBody = ReqUpdateCompanyUser + export type UpdateUserMutationError = void | HTTPValidationError + + /** + * @summary 회사 유저 수정 + */ +export const useUpdateUser = (options?: { mutation?:UseMutationOptions>, TError,{userId: string;data: ReqUpdateCompanyUser}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {userId: string;data: ReqUpdateCompanyUser}, + TContext + > => { + + const mutationOptions = getUpdateUserMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 회사 유저 삭제 + */ +export const deleteUser = ( + userId: string, + options?: SecondParameter,) => { + + + return customFetch( + {url: `/v1/company/user/delete/${userId}`, method: 'DELETE' + }, + options); + } + + + +export const getDeleteUserMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{userId: string}, TContext>, request?: SecondParameter} +): UseMutationOptions>, 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>, {userId: string}> = (props) => { + const {userId} = props ?? {}; + + return deleteUser(userId,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type DeleteUserMutationResult = NonNullable>> + + export type DeleteUserMutationError = void | HTTPValidationError + + /** + * @summary 회사 유저 삭제 + */ +export const useDeleteUser = (options?: { mutation?:UseMutationOptions>, TError,{userId: string}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {userId: string}, + TContext + > => { + + const mutationOptions = getDeleteUserMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + \ No newline at end of file diff --git a/negodata/front/src/api/generated/dashboard/dashboard.ts b/negodata/front/src/api/generated/dashboard/dashboard.ts new file mode 100644 index 0000000..66333ba --- /dev/null +++ b/negodata/front/src/api/generated/dashboard/dashboard.ts @@ -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 unknown> = Parameters[1]; + + + +/** + * @summary 대시보드 요약(회사 전체 + 내 견적) + */ +export const getDashboardSummary = ( + + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/dashboard/summary`, method: 'GET', signal + }, + options); + } + + + + +export const getGetDashboardSummaryQueryKey = () => { + return [ + `/v1/dashboard/summary` + ] as const; + } + + +export const getGetDashboardSummaryQueryOptions = >, TError = void>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetDashboardSummaryQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getDashboardSummary(requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetDashboardSummaryQueryResult = NonNullable>> +export type GetDashboardSummaryQueryError = void + + +export function useGetDashboardSummary>, TError = void>( + options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetDashboardSummary>, TError = void>( + options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetDashboardSummary>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 대시보드 요약(회사 전체 + 내 견적) + */ + +export function useGetDashboardSummary>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetDashboardSummaryQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + diff --git a/negodata/front/src/api/generated/model/cardData.ts b/negodata/front/src/api/generated/model/cardData.ts index c39fba8..ea26d31 100644 --- a/negodata/front/src/api/generated/model/cardData.ts +++ b/negodata/front/src/api/generated/model/cardData.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/cardUsageType.ts b/negodata/front/src/api/generated/model/cardUsageType.ts new file mode 100644 index 0000000..1b9abcf --- /dev/null +++ b/negodata/front/src/api/generated/model/cardUsageType.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/companyUserData.ts b/negodata/front/src/api/generated/model/companyUserData.ts new file mode 100644 index 0000000..2e5e0cd --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserData.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/companyUserDataContactNumber.ts b/negodata/front/src/api/generated/model/companyUserDataContactNumber.ts new file mode 100644 index 0000000..b0b22b7 --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataContactNumber.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/companyUserDataCreatedAt.ts b/negodata/front/src/api/generated/model/companyUserDataCreatedAt.ts new file mode 100644 index 0000000..f404d0c --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataCreatedAt.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/companyUserDataEmail.ts b/negodata/front/src/api/generated/model/companyUserDataEmail.ts new file mode 100644 index 0000000..4f7ae11 --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataEmail.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/companyUserDataLastAccessedAt.ts b/negodata/front/src/api/generated/model/companyUserDataLastAccessedAt.ts new file mode 100644 index 0000000..5934ea6 --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataLastAccessedAt.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/resCreateAccountMsg.ts b/negodata/front/src/api/generated/model/companyUserDataName.ts similarity index 71% rename from negodata/front/src/api/generated/model/resCreateAccountMsg.ts rename to negodata/front/src/api/generated/model/companyUserDataName.ts index 4f87fb8..09040fc 100644 --- a/negodata/front/src/api/generated/model/resCreateAccountMsg.ts +++ b/negodata/front/src/api/generated/model/companyUserDataName.ts @@ -5,4 +5,4 @@ * OpenAPI spec version: 0.1.0 */ -export type ResCreateAccountMsg = string | null; +export type CompanyUserDataName = string | null; diff --git a/negodata/front/src/api/generated/model/companyUserDataUpdatedAt.ts b/negodata/front/src/api/generated/model/companyUserDataUpdatedAt.ts new file mode 100644 index 0000000..cfe5ed5 --- /dev/null +++ b/negodata/front/src/api/generated/model/companyUserDataUpdatedAt.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/dashboardActionList.ts b/negodata/front/src/api/generated/model/dashboardActionList.ts new file mode 100644 index 0000000..f6de712 --- /dev/null +++ b/negodata/front/src/api/generated/model/dashboardActionList.ts @@ -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[]; +} diff --git a/negodata/front/src/api/generated/model/dashboardEmailUnsent.ts b/negodata/front/src/api/generated/model/dashboardEmailUnsent.ts new file mode 100644 index 0000000..68f3075 --- /dev/null +++ b/negodata/front/src/api/generated/model/dashboardEmailUnsent.ts @@ -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[]; +} diff --git a/negodata/front/src/api/generated/model/dashboardEmailUnsentItem.ts b/negodata/front/src/api/generated/model/dashboardEmailUnsentItem.ts new file mode 100644 index 0000000..c80738a --- /dev/null +++ b/negodata/front/src/api/generated/model/dashboardEmailUnsentItem.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/dashboardQuotationRef.ts b/negodata/front/src/api/generated/model/dashboardQuotationRef.ts new file mode 100644 index 0000000..6ca7858 --- /dev/null +++ b/negodata/front/src/api/generated/model/dashboardQuotationRef.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/dashboardQuotationRefEndTime.ts b/negodata/front/src/api/generated/model/dashboardQuotationRefEndTime.ts new file mode 100644 index 0000000..b1f20fc --- /dev/null +++ b/negodata/front/src/api/generated/model/dashboardQuotationRefEndTime.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/dashboardScope.ts b/negodata/front/src/api/generated/model/dashboardScope.ts new file mode 100644 index 0000000..a6d1137 --- /dev/null +++ b/negodata/front/src/api/generated/model/dashboardScope.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/deliveryType.ts b/negodata/front/src/api/generated/model/deliveryType.ts index 2f51973..b1bd428 100644 --- a/negodata/front/src/api/generated/model/deliveryType.ts +++ b/negodata/front/src/api/generated/model/deliveryType.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index 6501c93..89ffeec 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -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'; \ No newline at end of file diff --git a/negodata/front/src/api/generated/model/itemData.ts b/negodata/front/src/api/generated/model/itemData.ts index 47c1448..1477144 100644 --- a/negodata/front/src/api/generated/model/itemData.ts +++ b/negodata/front/src/api/generated/model/itemData.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/itemDataInternetLowestPrice.ts b/negodata/front/src/api/generated/model/itemDataInternetLowestPrice.ts new file mode 100644 index 0000000..5b60282 --- /dev/null +++ b/negodata/front/src/api/generated/model/itemDataInternetLowestPrice.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/itemDataPurchasePrice.ts b/negodata/front/src/api/generated/model/itemDataPurchasePrice.ts new file mode 100644 index 0000000..3ccaf6d --- /dev/null +++ b/negodata/front/src/api/generated/model/itemDataPurchasePrice.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/itemDataSellingPrice.ts b/negodata/front/src/api/generated/model/itemDataSellingPrice.ts new file mode 100644 index 0000000..af31fd1 --- /dev/null +++ b/negodata/front/src/api/generated/model/itemDataSellingPrice.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/listNotificationsParams.ts b/negodata/front/src/api/generated/model/listNotificationsParams.ts new file mode 100644 index 0000000..f61f178 --- /dev/null +++ b/negodata/front/src/api/generated/model/listNotificationsParams.ts @@ -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; +}; diff --git a/negodata/front/src/api/generated/model/listQuotationsParams.ts b/negodata/front/src/api/generated/model/listQuotationsParams.ts index a526f5f..758a89b 100644 --- a/negodata/front/src/api/generated/model/listQuotationsParams.ts +++ b/negodata/front/src/api/generated/model/listQuotationsParams.ts @@ -26,6 +26,10 @@ start_from?: string | null; * 시작일시 이전(ISO) */ start_to?: string | null; +/** + * 내 견적만 보기(작성자=로그인 유저) + */ +mine?: boolean; /** * @minimum 1 */ diff --git a/negodata/front/src/api/generated/model/listUsersParams.ts b/negodata/front/src/api/generated/model/listUsersParams.ts new file mode 100644 index 0000000..6f87482 --- /dev/null +++ b/negodata/front/src/api/generated/model/listUsersParams.ts @@ -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; +}; diff --git a/negodata/front/src/api/generated/model/notificationData.ts b/negodata/front/src/api/generated/model/notificationData.ts new file mode 100644 index 0000000..802fdca --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationData.ts @@ -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; +} diff --git a/negodata/front/src/api/generated/model/notificationDataCreatedAt.ts b/negodata/front/src/api/generated/model/notificationDataCreatedAt.ts new file mode 100644 index 0000000..41c533d --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationDataCreatedAt.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/notificationDataData.ts b/negodata/front/src/api/generated/model/notificationDataData.ts new file mode 100644 index 0000000..21fd015 --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationDataData.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/notificationDataReadAt.ts b/negodata/front/src/api/generated/model/notificationDataReadAt.ts new file mode 100644 index 0000000..448ac0d --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationDataReadAt.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/notificationDataRefQtId.ts b/negodata/front/src/api/generated/model/notificationDataRefQtId.ts new file mode 100644 index 0000000..3f5aee7 --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationDataRefQtId.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/notificationDataRefSessionId.ts b/negodata/front/src/api/generated/model/notificationDataRefSessionId.ts new file mode 100644 index 0000000..024106b --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationDataRefSessionId.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/notificationType.ts b/negodata/front/src/api/generated/model/notificationType.ts new file mode 100644 index 0000000..6f76b94 --- /dev/null +++ b/negodata/front/src/api/generated/model/notificationType.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/quotationData.ts b/negodata/front/src/api/generated/model/quotationData.ts index ae7106b..e8f0e8b 100644 --- a/negodata/front/src/api/generated/model/quotationData.ts +++ b/negodata/front/src/api/generated/model/quotationData.ts @@ -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; } diff --git a/negodata/front/src/api/generated/model/quotationDataCreatorName.ts b/negodata/front/src/api/generated/model/quotationDataCreatorName.ts new file mode 100644 index 0000000..401912b --- /dev/null +++ b/negodata/front/src/api/generated/model/quotationDataCreatorName.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/quotationDataMdPrice.ts b/negodata/front/src/api/generated/model/quotationDataMdPrice.ts new file mode 100644 index 0000000..6115cca --- /dev/null +++ b/negodata/front/src/api/generated/model/quotationDataMdPrice.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/quotationDataSupplierType.ts b/negodata/front/src/api/generated/model/quotationDataSupplierType.ts new file mode 100644 index 0000000..8f47105 --- /dev/null +++ b/negodata/front/src/api/generated/model/quotationDataSupplierType.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/quotationStatus.ts b/negodata/front/src/api/generated/model/quotationStatus.ts index 1758d54..7d526fd 100644 --- a/negodata/front/src/api/generated/model/quotationStatus.ts +++ b/negodata/front/src/api/generated/model/quotationStatus.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/quotationType.ts b/negodata/front/src/api/generated/model/quotationType.ts index 87f9dce..407907f 100644 --- a/negodata/front/src/api/generated/model/quotationType.ts +++ b/negodata/front/src/api/generated/model/quotationType.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/reqCreateCard.ts b/negodata/front/src/api/generated/model/reqCreateCard.ts index 30a465b..467e7da 100644 --- a/negodata/front/src/api/generated/model/reqCreateCard.ts +++ b/negodata/front/src/api/generated/model/reqCreateCard.ts @@ -17,6 +17,7 @@ export interface ReqCreateCard { number?: ReqCreateCardNumber; script?: ReqCreateCardScript; edit_script?: ReqCreateCardEditScript; + usage_type?: number; status?: number; condition?: ReqCreateCardCondition; memo?: ReqCreateCardMemo; diff --git a/negodata/front/src/api/generated/model/reqCreateAccount.ts b/negodata/front/src/api/generated/model/reqCreateCompanyUser.ts similarity index 74% rename from negodata/front/src/api/generated/model/reqCreateAccount.ts rename to negodata/front/src/api/generated/model/reqCreateCompanyUser.ts index 9fd90fe..795c416 100644 --- a/negodata/front/src/api/generated/model/reqCreateAccount.ts +++ b/negodata/front/src/api/generated/model/reqCreateCompanyUser.ts @@ -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; } diff --git a/negodata/front/src/api/generated/model/reqCreateItem.ts b/negodata/front/src/api/generated/model/reqCreateItem.ts index ccfb89a..6c90917 100644 --- a/negodata/front/src/api/generated/model/reqCreateItem.ts +++ b/negodata/front/src/api/generated/model/reqCreateItem.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/reqCreateItemInternetLowestPrice.ts b/negodata/front/src/api/generated/model/reqCreateItemInternetLowestPrice.ts new file mode 100644 index 0000000..af0c8c8 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateItemInternetLowestPrice.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/reqCreateItemPurchasePrice.ts b/negodata/front/src/api/generated/model/reqCreateItemPurchasePrice.ts new file mode 100644 index 0000000..1f91c80 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateItemPurchasePrice.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/reqCreateItemSellingPrice.ts b/negodata/front/src/api/generated/model/reqCreateItemSellingPrice.ts new file mode 100644 index 0000000..5183140 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateItemSellingPrice.ts @@ -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; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotation.ts b/negodata/front/src/api/generated/model/reqCreateQuotation.ts index 60dab99..e51bce6 100644 --- a/negodata/front/src/api/generated/model/reqCreateQuotation.ts +++ b/negodata/front/src/api/generated/model/reqCreateQuotation.ts @@ -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[]; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationMdPrice.ts b/negodata/front/src/api/generated/model/reqCreateQuotationMdPrice.ts new file mode 100644 index 0000000..3b5e687 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateQuotationMdPrice.ts @@ -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 ReqCreateQuotationMdPrice = number | null; diff --git a/negodata/front/src/api/generated/model/reqCreateQuotationSupplierType.ts b/negodata/front/src/api/generated/model/reqCreateQuotationSupplierType.ts new file mode 100644 index 0000000..38bc008 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateQuotationSupplierType.ts @@ -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 ReqCreateQuotationSupplierType = number | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCard.ts b/negodata/front/src/api/generated/model/reqUpdateCard.ts index 470eb5c..646a297 100644 --- a/negodata/front/src/api/generated/model/reqUpdateCard.ts +++ b/negodata/front/src/api/generated/model/reqUpdateCard.ts @@ -8,6 +8,7 @@ import type { ReqUpdateCardName } from './reqUpdateCardName'; import type { ReqUpdateCardNumber } from './reqUpdateCardNumber'; import type { ReqUpdateCardScript } from './reqUpdateCardScript'; import type { ReqUpdateCardEditScript } from './reqUpdateCardEditScript'; +import type { ReqUpdateCardUsageType } from './reqUpdateCardUsageType'; import type { ReqUpdateCardStatus } from './reqUpdateCardStatus'; import type { ReqUpdateCardCondition } from './reqUpdateCardCondition'; import type { ReqUpdateCardMemo } from './reqUpdateCardMemo'; @@ -17,6 +18,7 @@ export interface ReqUpdateCard { number?: ReqUpdateCardNumber; script?: ReqUpdateCardScript; edit_script?: ReqUpdateCardEditScript; + usage_type?: ReqUpdateCardUsageType; status?: ReqUpdateCardStatus; condition?: ReqUpdateCardCondition; memo?: ReqUpdateCardMemo; diff --git a/negodata/front/src/api/generated/model/reqUpdateCardUsageType.ts b/negodata/front/src/api/generated/model/reqUpdateCardUsageType.ts new file mode 100644 index 0000000..7b36b65 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCardUsageType.ts @@ -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 ReqUpdateCardUsageType = number | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUser.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUser.ts new file mode 100644 index 0000000..04d58ee --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUser.ts @@ -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 { ReqUpdateCompanyUserName } from './reqUpdateCompanyUserName'; +import type { ReqUpdateCompanyUserEmail } from './reqUpdateCompanyUserEmail'; +import type { ReqUpdateCompanyUserContactNumber } from './reqUpdateCompanyUserContactNumber'; +import type { ReqUpdateCompanyUserStatus } from './reqUpdateCompanyUserStatus'; +import type { ReqUpdateCompanyUserPassword } from './reqUpdateCompanyUserPassword'; + +export interface ReqUpdateCompanyUser { + name?: ReqUpdateCompanyUserName; + email?: ReqUpdateCompanyUserEmail; + contact_number?: ReqUpdateCompanyUserContactNumber; + status?: ReqUpdateCompanyUserStatus; + password?: ReqUpdateCompanyUserPassword; +} diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserContactNumber.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserContactNumber.ts new file mode 100644 index 0000000..9c9d955 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserContactNumber.ts @@ -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 ReqUpdateCompanyUserContactNumber = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserEmail.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserEmail.ts new file mode 100644 index 0000000..f1f6ea8 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserEmail.ts @@ -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 ReqUpdateCompanyUserEmail = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserName.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserName.ts new file mode 100644 index 0000000..6c35074 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserName.ts @@ -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 ReqUpdateCompanyUserName = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserPassword.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserPassword.ts new file mode 100644 index 0000000..b64bc68 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserPassword.ts @@ -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 ReqUpdateCompanyUserPassword = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanyUserStatus.ts b/negodata/front/src/api/generated/model/reqUpdateCompanyUserStatus.ts new file mode 100644 index 0000000..de4ead2 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanyUserStatus.ts @@ -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 { UserStatus } from './userStatus'; + +export type ReqUpdateCompanyUserStatus = UserStatus | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateItem.ts b/negodata/front/src/api/generated/model/reqUpdateItem.ts index b295929..2b0dca9 100644 --- a/negodata/front/src/api/generated/model/reqUpdateItem.ts +++ b/negodata/front/src/api/generated/model/reqUpdateItem.ts @@ -15,6 +15,9 @@ import type { ReqUpdateItemManufacturer } from './reqUpdateItemManufacturer'; import type { ReqUpdateItemMadeIn } from './reqUpdateItemMadeIn'; import type { ReqUpdateItemPrice } from './reqUpdateItemPrice'; import type { ReqUpdateItemInternetLowestPriceYn } from './reqUpdateItemInternetLowestPriceYn'; +import type { ReqUpdateItemInternetLowestPrice } from './reqUpdateItemInternetLowestPrice'; +import type { ReqUpdateItemPurchasePrice } from './reqUpdateItemPurchasePrice'; +import type { ReqUpdateItemSellingPrice } from './reqUpdateItemSellingPrice'; import type { ReqUpdateItemMoq } from './reqUpdateItemMoq'; import type { ReqUpdateItemLeadTime } from './reqUpdateItemLeadTime'; import type { ReqUpdateItemQuantityUnit } from './reqUpdateItemQuantityUnit'; @@ -34,6 +37,9 @@ export interface ReqUpdateItem { made_in?: ReqUpdateItemMadeIn; price?: ReqUpdateItemPrice; internet_lowest_price_yn?: ReqUpdateItemInternetLowestPriceYn; + internet_lowest_price?: ReqUpdateItemInternetLowestPrice; + purchase_price?: ReqUpdateItemPurchasePrice; + selling_price?: ReqUpdateItemSellingPrice; moq?: ReqUpdateItemMoq; lead_time?: ReqUpdateItemLeadTime; quantity_unit?: ReqUpdateItemQuantityUnit; diff --git a/negodata/front/src/api/generated/model/reqUpdateItemInternetLowestPrice.ts b/negodata/front/src/api/generated/model/reqUpdateItemInternetLowestPrice.ts new file mode 100644 index 0000000..c4ff037 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateItemInternetLowestPrice.ts @@ -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 ReqUpdateItemInternetLowestPrice = number | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateItemPurchasePrice.ts b/negodata/front/src/api/generated/model/reqUpdateItemPurchasePrice.ts new file mode 100644 index 0000000..00a7f4b --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateItemPurchasePrice.ts @@ -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 ReqUpdateItemPurchasePrice = number | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateItemSellingPrice.ts b/negodata/front/src/api/generated/model/reqUpdateItemSellingPrice.ts new file mode 100644 index 0000000..8e410a0 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateItemSellingPrice.ts @@ -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 ReqUpdateItemSellingPrice = number | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateMe.ts b/negodata/front/src/api/generated/model/reqUpdateMe.ts new file mode 100644 index 0000000..27a80f4 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateMe.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ReqUpdateMeName } from './reqUpdateMeName'; +import type { ReqUpdateMeEmail } from './reqUpdateMeEmail'; +import type { ReqUpdateMeContactNumber } from './reqUpdateMeContactNumber'; +import type { ReqUpdateMePassword } from './reqUpdateMePassword'; + +export interface ReqUpdateMe { + name?: ReqUpdateMeName; + email?: ReqUpdateMeEmail; + contact_number?: ReqUpdateMeContactNumber; + password?: ReqUpdateMePassword; +} diff --git a/negodata/front/src/api/generated/model/reqUpdateMeContactNumber.ts b/negodata/front/src/api/generated/model/reqUpdateMeContactNumber.ts new file mode 100644 index 0000000..dc4995b --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateMeContactNumber.ts @@ -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 ReqUpdateMeContactNumber = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateMeEmail.ts b/negodata/front/src/api/generated/model/reqUpdateMeEmail.ts new file mode 100644 index 0000000..dd96611 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateMeEmail.ts @@ -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 ReqUpdateMeEmail = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateMeName.ts b/negodata/front/src/api/generated/model/reqUpdateMeName.ts new file mode 100644 index 0000000..f464c30 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateMeName.ts @@ -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 ReqUpdateMeName = string | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateMePassword.ts b/negodata/front/src/api/generated/model/reqUpdateMePassword.ts new file mode 100644 index 0000000..660e7b0 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateMePassword.ts @@ -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 ReqUpdateMePassword = string | null; diff --git a/negodata/front/src/api/generated/model/resCompanyUser.ts b/negodata/front/src/api/generated/model/resCompanyUser.ts new file mode 100644 index 0000000..cf32213 --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUser.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResCompanyUserMsg } from './resCompanyUserMsg'; +import type { ResCompanyUserUser } from './resCompanyUserUser'; + +export interface ResCompanyUser { + result?: ErrorInfo; + msg?: ResCompanyUserMsg; + user?: ResCompanyUserUser; +} diff --git a/negodata/front/src/api/generated/model/resCompanyUserList.ts b/negodata/front/src/api/generated/model/resCompanyUserList.ts new file mode 100644 index 0000000..da6eecc --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUserList.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResCompanyUserListMsg } from './resCompanyUserListMsg'; +import type { CompanyUserData } from './companyUserData'; + +export interface ResCompanyUserList { + result?: ErrorInfo; + msg?: ResCompanyUserListMsg; + total?: number; + page?: number; + size?: number; + users?: CompanyUserData[]; +} diff --git a/negodata/front/src/api/generated/model/resCompanyUserListMsg.ts b/negodata/front/src/api/generated/model/resCompanyUserListMsg.ts new file mode 100644 index 0000000..54d0fae --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUserListMsg.ts @@ -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 ResCompanyUserListMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resCompanyUserMsg.ts b/negodata/front/src/api/generated/model/resCompanyUserMsg.ts new file mode 100644 index 0000000..031ecba --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUserMsg.ts @@ -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 ResCompanyUserMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resCompanyUserUser.ts b/negodata/front/src/api/generated/model/resCompanyUserUser.ts new file mode 100644 index 0000000..f1fdf01 --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanyUserUser.ts @@ -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 { CompanyUserData } from './companyUserData'; + +export type ResCompanyUserUser = CompanyUserData | null; diff --git a/negodata/front/src/api/generated/model/resDashboardSummary.ts b/negodata/front/src/api/generated/model/resDashboardSummary.ts new file mode 100644 index 0000000..8e8d74b --- /dev/null +++ b/negodata/front/src/api/generated/model/resDashboardSummary.ts @@ -0,0 +1,16 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResDashboardSummaryMsg } from './resDashboardSummaryMsg'; +import type { DashboardScope } from './dashboardScope'; + +export interface ResDashboardSummary { + result?: ErrorInfo; + msg?: ResDashboardSummaryMsg; + company?: DashboardScope; + mine?: DashboardScope; +} diff --git a/negodata/front/src/api/generated/model/resDashboardSummaryMsg.ts b/negodata/front/src/api/generated/model/resDashboardSummaryMsg.ts new file mode 100644 index 0000000..5b07b96 --- /dev/null +++ b/negodata/front/src/api/generated/model/resDashboardSummaryMsg.ts @@ -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 ResDashboardSummaryMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resCreateAccount.ts b/negodata/front/src/api/generated/model/resDeleteCompanyUser.ts similarity index 56% rename from negodata/front/src/api/generated/model/resCreateAccount.ts rename to negodata/front/src/api/generated/model/resDeleteCompanyUser.ts index 527ebe5..7a03135 100644 --- a/negodata/front/src/api/generated/model/resCreateAccount.ts +++ b/negodata/front/src/api/generated/model/resDeleteCompanyUser.ts @@ -5,10 +5,9 @@ * OpenAPI spec version: 0.1.0 */ import type { ErrorInfo } from './errorInfo'; -import type { ResCreateAccountMsg } from './resCreateAccountMsg'; +import type { ResDeleteCompanyUserMsg } from './resDeleteCompanyUserMsg'; -export interface ResCreateAccount { +export interface ResDeleteCompanyUser { result?: ErrorInfo; - msg?: ResCreateAccountMsg; - user_id?: string; + msg?: ResDeleteCompanyUserMsg; } diff --git a/negodata/front/src/api/generated/model/resDeleteCompanyUserMsg.ts b/negodata/front/src/api/generated/model/resDeleteCompanyUserMsg.ts new file mode 100644 index 0000000..ad9f70c --- /dev/null +++ b/negodata/front/src/api/generated/model/resDeleteCompanyUserMsg.ts @@ -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 ResDeleteCompanyUserMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resLastSupplierType.ts b/negodata/front/src/api/generated/model/resLastSupplierType.ts new file mode 100644 index 0000000..a6f3f29 --- /dev/null +++ b/negodata/front/src/api/generated/model/resLastSupplierType.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResLastSupplierTypeMsg } from './resLastSupplierTypeMsg'; +import type { ResLastSupplierTypeSupplierType } from './resLastSupplierTypeSupplierType'; +import type { ResLastSupplierTypeQtNumber } from './resLastSupplierTypeQtNumber'; + +export interface ResLastSupplierType { + result?: ErrorInfo; + msg?: ResLastSupplierTypeMsg; + supplier_type?: ResLastSupplierTypeSupplierType; + qt_number?: ResLastSupplierTypeQtNumber; +} diff --git a/negodata/front/src/api/generated/model/resLastSupplierTypeMsg.ts b/negodata/front/src/api/generated/model/resLastSupplierTypeMsg.ts new file mode 100644 index 0000000..ee369fc --- /dev/null +++ b/negodata/front/src/api/generated/model/resLastSupplierTypeMsg.ts @@ -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 ResLastSupplierTypeMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resLastSupplierTypeQtNumber.ts b/negodata/front/src/api/generated/model/resLastSupplierTypeQtNumber.ts new file mode 100644 index 0000000..01ac822 --- /dev/null +++ b/negodata/front/src/api/generated/model/resLastSupplierTypeQtNumber.ts @@ -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 ResLastSupplierTypeQtNumber = string | null; diff --git a/negodata/front/src/api/generated/model/resLastSupplierTypeSupplierType.ts b/negodata/front/src/api/generated/model/resLastSupplierTypeSupplierType.ts new file mode 100644 index 0000000..313d621 --- /dev/null +++ b/negodata/front/src/api/generated/model/resLastSupplierTypeSupplierType.ts @@ -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 ResLastSupplierTypeSupplierType = SupplierType | null; diff --git a/negodata/front/src/api/generated/model/resNotificationList.ts b/negodata/front/src/api/generated/model/resNotificationList.ts new file mode 100644 index 0000000..a46b741 --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotificationList.ts @@ -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 { ErrorInfo } from './errorInfo'; +import type { ResNotificationListMsg } from './resNotificationListMsg'; +import type { NotificationData } from './notificationData'; + +export interface ResNotificationList { + result?: ErrorInfo; + msg?: ResNotificationListMsg; + total?: number; + page?: number; + size?: number; + notifications?: NotificationData[]; + unread?: number; +} diff --git a/negodata/front/src/api/generated/model/resNotificationListMsg.ts b/negodata/front/src/api/generated/model/resNotificationListMsg.ts new file mode 100644 index 0000000..83fbeb8 --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotificationListMsg.ts @@ -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 ResNotificationListMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resNotificationRead.ts b/negodata/front/src/api/generated/model/resNotificationRead.ts new file mode 100644 index 0000000..bb005aa --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotificationRead.ts @@ -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 { ErrorInfo } from './errorInfo'; +import type { ResNotificationReadMsg } from './resNotificationReadMsg'; + +export interface ResNotificationRead { + result?: ErrorInfo; + msg?: ResNotificationReadMsg; +} diff --git a/negodata/front/src/api/generated/model/resNotificationReadMsg.ts b/negodata/front/src/api/generated/model/resNotificationReadMsg.ts new file mode 100644 index 0000000..ecf1e41 --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotificationReadMsg.ts @@ -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 ResNotificationReadMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resNotifySessions.ts b/negodata/front/src/api/generated/model/resNotifySessions.ts new file mode 100644 index 0000000..6d23058 --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotifySessions.ts @@ -0,0 +1,17 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResNotifySessionsMsg } from './resNotifySessionsMsg'; + +export interface ResNotifySessions { + result?: ErrorInfo; + msg?: ResNotifySessionsMsg; + sent?: number; + failed?: number; + skipped?: number; + total?: number; +} diff --git a/negodata/front/src/api/generated/model/resNotifySessionsMsg.ts b/negodata/front/src/api/generated/model/resNotifySessionsMsg.ts new file mode 100644 index 0000000..2d1d777 --- /dev/null +++ b/negodata/front/src/api/generated/model/resNotifySessionsMsg.ts @@ -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 ResNotifySessionsMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdown.ts b/negodata/front/src/api/generated/model/resTargetBreakdown.ts new file mode 100644 index 0000000..4697eed --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdown.ts @@ -0,0 +1,33 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResTargetBreakdownMsg } from './resTargetBreakdownMsg'; +import type { ResTargetBreakdownMdPrice } from './resTargetBreakdownMdPrice'; +import type { ResTargetBreakdownInternetLowest } from './resTargetBreakdownInternetLowest'; +import type { ResTargetBreakdownPurchase } from './resTargetBreakdownPurchase'; +import type { ResTargetBreakdownSelling } from './resTargetBreakdownSelling'; +import type { TargetCandidate } from './targetCandidate'; +import type { ResTargetBreakdownChosenBasis } from './resTargetBreakdownChosenBasis'; +import type { ResTargetBreakdownTargetAnchoringPrice } from './resTargetBreakdownTargetAnchoringPrice'; + +export interface ResTargetBreakdown { + result?: ErrorInfo; + msg?: ResTargetBreakdownMsg; + is_new?: boolean; + is_inherited?: boolean; + md_price?: ResTargetBreakdownMdPrice; + internet_lowest?: ResTargetBreakdownInternetLowest; + purchase?: ResTargetBreakdownPurchase; + selling?: ResTargetBreakdownSelling; + fee?: number; + margin?: number; + anchoring_value?: number; + candidates?: TargetCandidate[]; + chosen_basis?: ResTargetBreakdownChosenBasis; + target_price?: number; + target_anchoring_price?: ResTargetBreakdownTargetAnchoringPrice; +} diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownChosenBasis.ts b/negodata/front/src/api/generated/model/resTargetBreakdownChosenBasis.ts new file mode 100644 index 0000000..b6ace49 --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownChosenBasis.ts @@ -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 ResTargetBreakdownChosenBasis = string | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownInternetLowest.ts b/negodata/front/src/api/generated/model/resTargetBreakdownInternetLowest.ts new file mode 100644 index 0000000..84cd371 --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownInternetLowest.ts @@ -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 ResTargetBreakdownInternetLowest = number | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownMdPrice.ts b/negodata/front/src/api/generated/model/resTargetBreakdownMdPrice.ts new file mode 100644 index 0000000..c5c7008 --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownMdPrice.ts @@ -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 ResTargetBreakdownMdPrice = number | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownMsg.ts b/negodata/front/src/api/generated/model/resTargetBreakdownMsg.ts new file mode 100644 index 0000000..0ee0e6f --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownMsg.ts @@ -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 ResTargetBreakdownMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownPurchase.ts b/negodata/front/src/api/generated/model/resTargetBreakdownPurchase.ts new file mode 100644 index 0000000..4aba8a4 --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownPurchase.ts @@ -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 ResTargetBreakdownPurchase = number | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownSelling.ts b/negodata/front/src/api/generated/model/resTargetBreakdownSelling.ts new file mode 100644 index 0000000..a8307f3 --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownSelling.ts @@ -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 ResTargetBreakdownSelling = number | null; diff --git a/negodata/front/src/api/generated/model/resTargetBreakdownTargetAnchoringPrice.ts b/negodata/front/src/api/generated/model/resTargetBreakdownTargetAnchoringPrice.ts new file mode 100644 index 0000000..34ce92e --- /dev/null +++ b/negodata/front/src/api/generated/model/resTargetBreakdownTargetAnchoringPrice.ts @@ -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 ResTargetBreakdownTargetAnchoringPrice = number | null; diff --git a/negodata/front/src/api/generated/model/sessionData.ts b/negodata/front/src/api/generated/model/sessionData.ts index b48fec3..69f19fb 100644 --- a/negodata/front/src/api/generated/model/sessionData.ts +++ b/negodata/front/src/api/generated/model/sessionData.ts @@ -5,12 +5,14 @@ * OpenAPI spec version: 0.1.0 */ import type { QuotationType } from './quotationType'; +import type { SessionDataTargetAnchoringPrice } from './sessionDataTargetAnchoringPrice'; import type { SessionStatus } from './sessionStatus'; import type { SessionDataBidPrice } from './sessionDataBidPrice'; import type { SessionDataBidAt } from './sessionDataBidAt'; import type { SessionDataRejectReason } from './sessionDataRejectReason'; import type { SessionDataRejectPrice } from './sessionDataRejectPrice'; import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType'; +import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt'; export interface SessionData { session_id: string; @@ -21,6 +23,7 @@ export interface SessionData { qt_round: number; qt_type: QuotationType; target_price: number; + target_anchoring_price?: SessionDataTargetAnchoringPrice; status: SessionStatus; bid_price?: SessionDataBidPrice; bid_at?: SessionDataBidAt; @@ -28,5 +31,6 @@ export interface SessionData { reject_reason?: SessionDataRejectReason; reject_price?: SessionDataRejectPrice; reject_delivery_type?: SessionDataRejectDeliveryType; + email_sent_at?: SessionDataEmailSentAt; url?: string; } diff --git a/negodata/front/src/api/generated/model/sessionDataEmailSentAt.ts b/negodata/front/src/api/generated/model/sessionDataEmailSentAt.ts new file mode 100644 index 0000000..aff7d65 --- /dev/null +++ b/negodata/front/src/api/generated/model/sessionDataEmailSentAt.ts @@ -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 SessionDataEmailSentAt = string | null; diff --git a/negodata/front/src/api/generated/model/sessionDataTargetAnchoringPrice.ts b/negodata/front/src/api/generated/model/sessionDataTargetAnchoringPrice.ts new file mode 100644 index 0000000..510d4bd --- /dev/null +++ b/negodata/front/src/api/generated/model/sessionDataTargetAnchoringPrice.ts @@ -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 SessionDataTargetAnchoringPrice = number | null; diff --git a/negodata/front/src/api/generated/model/supplierType.ts b/negodata/front/src/api/generated/model/supplierType.ts new file mode 100644 index 0000000..38cc6ca --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierType.ts @@ -0,0 +1,21 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +/** + * quotations.supplier_type 코드값. 없음(0,미지정)/유통(1)/제조(2)/총판(3). +없음은 프론트 폼에 '없음'으로 노출. KTC 앵커링 코드(기타=0)와 매핑 시 0↔없음 대응. + */ +export type SupplierType = typeof SupplierType[keyof typeof SupplierType]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const SupplierType = { + NONE: 0, + DISTRIBUTION: 1, + MANUFACTURE: 2, + SOLE_AGENCY: 3, +} as const; diff --git a/negodata/front/src/api/generated/model/targetCandidate.ts b/negodata/front/src/api/generated/model/targetCandidate.ts new file mode 100644 index 0000000..e3c30f3 --- /dev/null +++ b/negodata/front/src/api/generated/model/targetCandidate.ts @@ -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 TargetCandidate { + basis: string; + label: string; + value: number; +} diff --git a/negodata/front/src/api/generated/model/userRole.ts b/negodata/front/src/api/generated/model/userRole.ts index 14c2610..860ab73 100644 --- a/negodata/front/src/api/generated/model/userRole.ts +++ b/negodata/front/src/api/generated/model/userRole.ts @@ -6,7 +6,9 @@ */ /** - * users.role 코드값. + * users.role 코드값. negodata 유저는 전부 회사 직원(관리자측) — +의미 있는 구분은 '직원 계정 관리 권한 유무' 하나뿐이라 2단계로 둔다. +1=일반, 2=최고관리자(직원 계정 생성·관리). */ export type UserRole = typeof UserRole[keyof typeof UserRole]; @@ -14,5 +16,5 @@ export type UserRole = typeof UserRole[keyof typeof UserRole]; // eslint-disable-next-line @typescript-eslint/no-redeclare export const UserRole = { USER: 1, - MANAGER: 2, + OWNER: 2, } as const; diff --git a/negodata/front/src/api/generated/model/userStatus.ts b/negodata/front/src/api/generated/model/userStatus.ts new file mode 100644 index 0000000..d0a40bb --- /dev/null +++ b/negodata/front/src/api/generated/model/userStatus.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +/** + * users.status 코드값. + */ +export type UserStatus = typeof UserStatus[keyof typeof UserStatus]; + + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const UserStatus = { + ACTIVE: 1, + INACTIVE: 2, +} as const; diff --git a/negodata/front/src/api/generated/notification/notification.ts b/negodata/front/src/api/generated/notification/notification.ts new file mode 100644 index 0000000..438880a --- /dev/null +++ b/negodata/front/src/api/generated/notification/notification.ts @@ -0,0 +1,257 @@ +/** + * 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, + ListNotificationsParams, + ResNotificationList, + ResNotificationRead +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * @summary 알림 목록(+안읽음 수) + */ +export const listNotifications = ( + params?: ListNotificationsParams, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/notification/list`, method: 'GET', + params, signal + }, + options); + } + + + + +export const getListNotificationsQueryKey = (params?: ListNotificationsParams,) => { + return [ + `/v1/notification/list`, ...(params ? [params]: []) + ] as const; + } + + +export const getListNotificationsQueryOptions = >, TError = void | HTTPValidationError>(params?: ListNotificationsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getListNotificationsQueryKey(params); + + + + const queryFn: QueryFunction>> = ({ signal }) => listNotifications(params, requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type ListNotificationsQueryResult = NonNullable>> +export type ListNotificationsQueryError = void | HTTPValidationError + + +export function useListNotifications>, TError = void | HTTPValidationError>( + params: undefined | ListNotificationsParams, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useListNotifications>, TError = void | HTTPValidationError>( + params?: ListNotificationsParams, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useListNotifications>, TError = void | HTTPValidationError>( + params?: ListNotificationsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 알림 목록(+안읽음 수) + */ + +export function useListNotifications>, TError = void | HTTPValidationError>( + params?: ListNotificationsParams, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getListNotificationsQueryOptions(params,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 전체 읽음 처리 + */ +export const readAll = ( + + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/notification/read-all`, method: 'POST', signal + }, + options); + } + + + +export const getReadAllMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,void, TContext> => { + +const mutationKey = ['readAll']; +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>, void> = () => { + + + return readAll(requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type ReadAllMutationResult = NonNullable>> + + export type ReadAllMutationError = void + + /** + * @summary 전체 읽음 처리 + */ +export const useReadAll = (options?: { mutation?:UseMutationOptions>, TError,void, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + void, + TContext + > => { + + const mutationOptions = getReadAllMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** + * @summary 알림 읽음 처리 + */ +export const readOne = ( + notificationId: string, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/notification/${notificationId}/read`, method: 'POST', signal + }, + options); + } + + + +export const getReadOneMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{notificationId: string}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{notificationId: string}, TContext> => { + +const mutationKey = ['readOne']; +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>, {notificationId: string}> = (props) => { + const {notificationId} = props ?? {}; + + return readOne(notificationId,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type ReadOneMutationResult = NonNullable>> + + export type ReadOneMutationError = void | HTTPValidationError + + /** + * @summary 알림 읽음 처리 + */ +export const useReadOne = (options?: { mutation?:UseMutationOptions>, TError,{notificationId: string}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {notificationId: string}, + TContext + > => { + + const mutationOptions = getReadOneMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + \ No newline at end of file diff --git a/negodata/front/src/api/generated/quotation/quotation.ts b/negodata/front/src/api/generated/quotation/quotation.ts index c20a974..a0a91a2 100644 --- a/negodata/front/src/api/generated/quotation/quotation.ts +++ b/negodata/front/src/api/generated/quotation/quotation.ts @@ -30,13 +30,16 @@ import type { ReqRegenerateQuotation, ResCreateQuotation, ResDeleteQuotation, + ResLastSupplierType, + ResNotifySessions, ResQuotation, ResQuotationCards, ResQuotationList, ResQuotationResult, ResQuotationSessions, ResQuotationStatus, - ResSessionChat + ResSessionChat, + ResTargetBreakdown } from '.././model'; import { customFetch } from '../../mutator/custom-fetch'; @@ -515,6 +518,68 @@ export function useGetQuotationSessions,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/quotation/${qtId}/notify`, method: 'POST', signal + }, + options); + } + + + +export const getNotifyQuotationMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{qtId: string}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{qtId: string}, TContext> => { + +const mutationKey = ['notifyQuotation']; +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>, {qtId: string}> = (props) => { + const {qtId} = props ?? {}; + + return notifyQuotation(qtId,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type NotifyQuotationMutationResult = NonNullable>> + + export type NotifyQuotationMutationError = void | HTTPValidationError + + /** + * @summary 협상 초청 메일 발송 + */ +export const useNotifyQuotation = (options?: { mutation?:UseMutationOptions>, TError,{qtId: string}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {qtId: string}, + TContext + > => { + + const mutationOptions = getNotifyQuotationMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** * @summary 채팅 상세 */ export const getSessionChat = ( @@ -607,6 +672,160 @@ export function useGetSessionChat,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/quotation/session/${sessionId}/target-breakdown`, method: 'GET', signal + }, + options); + } + + + + +export const getGetTargetBreakdownQueryKey = (sessionId?: string,) => { + return [ + `/v1/quotation/session/${sessionId}/target-breakdown` + ] as const; + } + + +export const getGetTargetBreakdownQueryOptions = >, TError = void | HTTPValidationError>(sessionId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetTargetBreakdownQueryKey(sessionId); + + + + const queryFn: QueryFunction>> = ({ signal }) => getTargetBreakdown(sessionId, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(sessionId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetTargetBreakdownQueryResult = NonNullable>> +export type GetTargetBreakdownQueryError = void | HTTPValidationError + + +export function useGetTargetBreakdown>, TError = void | HTTPValidationError>( + sessionId: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetTargetBreakdown>, TError = void | HTTPValidationError>( + sessionId: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetTargetBreakdown>, TError = void | HTTPValidationError>( + sessionId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 세션 목표가 산정내역 + */ + +export function useGetTargetBreakdown>, TError = void | HTTPValidationError>( + sessionId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetTargetBreakdownQueryOptions(sessionId,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 세션 초청 메일 재발송 + */ +export const notifySession = ( + sessionId: string, + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/quotation/session/${sessionId}/notify`, method: 'POST', signal + }, + options); + } + + + +export const getNotifySessionMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{sessionId: string}, TContext> => { + +const mutationKey = ['notifySession']; +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>, {sessionId: string}> = (props) => { + const {sessionId} = props ?? {}; + + return notifySession(sessionId,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type NotifySessionMutationResult = NonNullable>> + + export type NotifySessionMutationError = void | HTTPValidationError + + /** + * @summary 세션 초청 메일 재발송 + */ +export const useNotifySession = (options?: { mutation?:UseMutationOptions>, TError,{sessionId: string}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {sessionId: string}, + TContext + > => { + + const mutationOptions = getNotifySessionMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + /** * @summary 낙찰 결과 */ export const getQuotationResult = ( @@ -852,6 +1071,98 @@ export const useDeleteQuotation = ,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/quotation/supplier/${supplierId}/last-type`, method: 'GET', signal + }, + options); + } + + + + +export const getGetSupplierLastTypeQueryKey = (supplierId?: string,) => { + return [ + `/v1/quotation/supplier/${supplierId}/last-type` + ] as const; + } + + +export const getGetSupplierLastTypeQueryOptions = >, TError = void | HTTPValidationError>(supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetSupplierLastTypeQueryKey(supplierId); + + + + const queryFn: QueryFunction>> = ({ signal }) => getSupplierLastType(supplierId, requestOptions, signal); + + + + + + return { queryKey, queryFn, enabled: !!(supplierId), ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetSupplierLastTypeQueryResult = NonNullable>> +export type GetSupplierLastTypeQueryError = void | HTTPValidationError + + +export function useGetSupplierLastType>, TError = void | HTTPValidationError>( + supplierId: string, options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetSupplierLastType>, TError = void | HTTPValidationError>( + supplierId: string, options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetSupplierLastType>, TError = void | HTTPValidationError>( + supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 협력사 직전 견적 유형 + */ + +export function useGetSupplierLastType>, TError = void | HTTPValidationError>( + supplierId: string, options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetSupplierLastTypeQueryOptions(supplierId,options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** * @summary 견적 조회 */ export const getQuotation = ( diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index e8a8cbe..b19501d 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -1,13 +1,17 @@ import {createBrowserRouter, redirect} from 'react-router'; import {initAuth} from '../features/auth/service'; -import {isLoggedIn} from '../stores/auth'; +import {isLoggedIn, hasRole} from '../stores/auth'; import AuthenticatedLayout from '@/components/layout/AuthenticatedLayout'; import LoginPage from '../pages/login'; +import DashboardPage from '../pages/dashboard'; import ForbiddenPage from '../pages/forbidden'; +import NotFoundPage from '../pages/not-found'; import ProductsPage from '../pages/products'; import PartnersPage from '../pages/partners'; import QuotationPage from '../pages/quotation'; import CardsPage from '../pages/cards'; +import MembersPage from '../pages/members'; +import NotificationsPage from '../pages/notifications'; export const router = createBrowserRouter([ // dev 전용: import.meta.env.DEV가 false인 프로덕션 빌드에선 이 배열 항목과 @@ -24,13 +28,13 @@ export const router = createBrowserRouter([ : []), { index: true, - loader: () => redirect('/products'), + loader: () => redirect('/dashboard'), }, { path: 'login', loader: async () => { await initAuth(); - if (isLoggedIn()) return redirect('/products'); + if (isLoggedIn()) return redirect('/dashboard'); return null; }, Component: LoginPage, @@ -51,10 +55,22 @@ export const router = createBrowserRouter([ }, Component: AuthenticatedLayout, children: [ + {path: 'dashboard', Component: DashboardPage}, {path: 'products', Component: ProductsPage}, {path: 'partners', Component: PartnersPage}, {path: 'quotation', Component: QuotationPage}, {path: 'cards', Component: CardsPage}, + {path: 'notifications', Component: NotificationsPage}, + { + // 최고관리자 전용. 부모 loader 가 initAuth 를 마친 뒤 실행되므로 유저 상태가 복원돼 있다. + path: 'members', + loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')), + Component: MembersPage, + }, ], }, + { + path: '*', + Component: NotFoundPage, + }, ]); diff --git a/negodata/front/src/components/layout/AuthenticatedLayout.tsx b/negodata/front/src/components/layout/AuthenticatedLayout.tsx index 444902f..9bd8977 100644 --- a/negodata/front/src/components/layout/AuthenticatedLayout.tsx +++ b/negodata/front/src/components/layout/AuthenticatedLayout.tsx @@ -5,10 +5,13 @@ import {useAuth} from '@/features/auth/useAuth'; import {showToast} from '@/lib/notify'; const PAGE_TO_PATH: Record = { + DASHBOARD: '/dashboard', PRODUCTS: '/products', PARTNERS: '/partners', QUOTATION: '/quotation', CARDS: '/cards', + MEMBERS: '/members', + NOTIFICATIONS: '/notifications', }; export default function AuthenticatedLayout() { diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index ee7aeeb..57bec99 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -1,13 +1,18 @@ import { useState, type ReactNode, type ElementType } from 'react'; import { PageType } from '@/types'; import { useAuth } from '@/features/auth/useAuth'; +import { ProfileSheet } from '@/features/auth/components/ProfileSheet'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Typography } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; +import { NotificationBell } from './NotificationBell'; import { + LayoutDashboard, Briefcase, Users, + UserCog, + UserPen, FileSpreadsheet, Layers, LogOut, @@ -28,18 +33,24 @@ interface LayoutProps { type SidebarUser = ReturnType['user']; -const menuItems = [ - { type: 'PRODUCTS' as PageType, label: '상품관리', icon: Briefcase, id: 'sidebar-products' }, - { type: 'PARTNERS' as PageType, label: '협력사관리', icon: Users, id: 'sidebar-partners' }, - { type: 'QUOTATION' as PageType, label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' }, - { type: 'CARDS' as PageType, label: '협상카드관리', icon: Layers, id: 'sidebar-cards' }, +// ownerOnly 항목은 최고관리자에게만 노출된다(렌더 시 user.role 로 필터). +const menuItems: { type: PageType; label: string; icon: ElementType; id: string; ownerOnly?: boolean }[] = [ + { type: 'DASHBOARD', label: '대시보드', icon: LayoutDashboard, id: 'sidebar-dashboard' }, + { type: 'PRODUCTS', label: '상품관리', icon: Briefcase, id: 'sidebar-products' }, + { type: 'PARTNERS', label: '협력사관리', icon: Users, id: 'sidebar-partners' }, + { type: 'QUOTATION', label: '견적관리', icon: FileSpreadsheet, id: 'sidebar-quotation' }, + { type: 'CARDS', label: '협상카드관리', icon: Layers, id: 'sidebar-cards' }, + { type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true }, ]; const pageLabelMap: Record = { + DASHBOARD: '대시보드', PRODUCTS: '상품관리', PARTNERS: '협력사관리', QUOTATION: '견적관리', CARDS: '협상카드관리', + MEMBERS: '회원관리', + NOTIFICATIONS: '알림', }; export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) { @@ -50,6 +61,7 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay const [isDark, setIsDark] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(true); // 데스크톱 접기 토글 const [isMobileOpen, setIsMobileOpen] = useState(false); // 모바일 드로어 열림 + const [isProfileOpen, setIsProfileOpen] = useState(false); // 내 정보 수정 시트 // 사이드바 펼침 여부(라벨/프로필 노출 기준). 모바일 드로어는 항상 펼친 상태로 본다. const expanded = isMobileOpen || isSidebarOpen; @@ -126,7 +138,9 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay {/* Nav Items */}